diff --git a/.changeset/import-as-template.md b/.changeset/import-as-template.md new file mode 100644 index 000000000..ca654c40f --- /dev/null +++ b/.changeset/import-as-template.md @@ -0,0 +1,5 @@ +--- +'@inkeep/open-knowledge': patch +--- + +Add "Import as template" to the File Tree context menu. Right-click a markdown file to copy it into the folder's `.ok/templates/`, with the option to keep the original or convert it (delete the source). Backed by a new `POST /api/template/import` endpoint that carries over the source frontmatter. diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index 2c6f62a57..a5a401d1a 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -29,6 +29,7 @@ import { Copy, CopyPlus, EyeOff, + FileKey, FilePlus, FolderOpen, FolderPlus, @@ -195,6 +196,7 @@ import { subscribeToFileTreeMenuActionDuplicate, subscribeToFileTreeMenuActionRename, } from '@/lib/file-tree-menu-action-events'; +import { importTemplate } from '@/lib/folder-config-api'; import { parseServerResponse, parseSuccessOrWarn } from '@/lib/parse-server-response'; import { createRefreshScheduler } from '@/lib/refresh-scheduler'; import { getRelaunchInFlightSnapshot, useRelaunchInFlight } from '@/lib/relaunch-store'; @@ -572,6 +574,7 @@ interface FileTreeMenuProps { * Drives the folder menu's "New from template" hover submenu. */ onCreateFromTemplate: (parentDir: string, templateName: string) => void; onDuplicate: (target: FileTreeTarget) => void; + onImportTemplate: (target: FileTreeTarget, deleteSource: boolean) => void; onDelete: (targets: FileTreeTarget[]) => void; onExpandSubtree: (treePath: string) => void; onCollapseSubtree: (treePath: string) => void; @@ -755,6 +758,7 @@ function FileTreeMenu({ onStartCreating, onCreateFromTemplate, onDuplicate, + onImportTemplate, onDelete, onExpandSubtree, onCollapseSubtree, @@ -1134,16 +1138,44 @@ function FileTreeMenu({ <> {!isAsset ? ( - { - close(); - onDuplicate(target); - }} - > - + <> + + + + + { + close(); + onImportTemplate(target, false); + }} + > + Keep original file + + { + close(); + onImportTemplate(target, true); + }} + > + Convert (delete original) + + + + { + close(); + onDuplicate(target); + }} + > + + ) : null} (null); const [deleteRequest, setDeleteRequest] = useState(null); + const [templateConvertRequest, setTemplateConvertRequest] = useState(null); /** * Set when `shell.trashItem` returns `{ ok: false }` for one or more * targets during the Step 1 trash flow. Drives the rendering of @@ -2152,6 +2185,62 @@ export function FileTree({ handleDuplicateTargetRef.current = handleDuplicateTarget; }); + async function handleImportTemplate(target: FileTreeTarget, deleteSource: boolean) { + if (target.kind !== 'file') return; + if (deleteSource) { + setTemplateConvertRequest(target); + return; + } + await executeImportTemplate(target, false); + } + + async function executeImportTemplate(target: FileTreeTarget, deleteSource: boolean) { + if (busyPathRef.current !== null) return; + const clearBusyState = () => { + setBusyPath(null); + busyPathRef.current = null; + setTemplateConvertRequest(null); + }; + busyPathRef.current = target.path; + setBusyPath(target.path); + setError(null); + + const appPath = target.path; + const slash = appPath.lastIndexOf('/'); + const targetFolder = slash === -1 ? '' : appPath.slice(0, slash); + + const res = await importTemplate({ + sourcePath: target.path, + targetFolder, + deleteSource, + }); + + if (!res.ok) { + toast.error(t`Failed to import template`, { description: res.error }); + clearBusyState(); + return; + } + + if (deleteSource) { + await applyDeleteAftermath([target], [target.path], []); + // Optimistically remove from view if deleted, standard watcher sweeps later + setDocuments((current) => { + const next = current.filter( + (entry) => !(isDocumentEntry(entry) && entry.docName === target.path), + ); + resetModelToDocuments(next); + markNextDocumentsAsApplied(next); + return next; + }); + emitDocumentsChanged(['files', 'backlinks', 'graph']); + } + + toast.success(t`Template imported`, { + description: res.path, + }); + clearBusyState(); + } + function recoverMarkdownRenameConflict(message: string): boolean { const bareDestinationPath = parseAlreadyExistsRenamePath(message); if (!bareDestinationPath || markdownTreeExtension(bareDestinationPath)) return false; @@ -4446,6 +4535,7 @@ export function FileTree({ startCreating('file', parentDir, { template: templateName }) } onDuplicate={handleDuplicateTarget} + onImportTemplate={handleImportTemplate} onDelete={(targets) => setDeleteRequest({ targets })} onExpandSubtree={expandSubtree} onCollapseSubtree={collapseSubtree} @@ -4514,6 +4604,29 @@ export function FileTree({ /> )} + { + if (!open && !busyPath) setTemplateConvertRequest(null); + }} + > + {templateConvertRequest && ( + executeImportTemplate(templateConvertRequest, true)} + /> + )} + { diff --git a/packages/app/src/lib/folder-config-api.ts b/packages/app/src/lib/folder-config-api.ts index 258b3d7fd..951374b27 100644 --- a/packages/app/src/lib/folder-config-api.ts +++ b/packages/app/src/lib/folder-config-api.ts @@ -164,3 +164,41 @@ export async function moveTemplate(input: { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } + +/** + * POST `/api/template/import` — import an existing markdown document as a template. + */ +export async function importTemplate(input: { + sourcePath: string; + targetFolder: string; + name?: string; + title?: string; + deleteSource?: boolean; +}): Promise< + { ok: true; path: string; created: boolean; warnings: string[] } | { ok: false; error: string } +> { + try { + const res = await fetch('/api/template/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + return { ok: false, error: await readErrorBody(res) }; + } + const payload = (await res.json().catch(() => null)) as { + path?: string; + created?: boolean; + warnings?: string[]; + } | null; + emitTemplatesChanged(); + return { + ok: true, + path: payload?.path ?? '', + created: payload?.created ?? false, + warnings: payload?.warnings ?? [], + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 3c32d3e1a..eea4394a1 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -11,6 +11,7 @@ "-3ois8": ["Hard break"], "-5jISU": [["0", "plural", { "one": ["#", " template"], "other": ["#", " templates"] }]], "-8PP0T": ["Match case"], + "-FeyRg": ["Keep original file"], "-K0AvT": ["Disconnect"], "-LHLx7": ["edited template"], "-QCLWk": ["Templates available"], @@ -553,6 +554,7 @@ "BQu9Pg": ["Symlink"], "BUyQnq": ["Couldn't restart the server. Try `ok start` in this folder."], "BY-g4_": ["Add pattern"], + "Ba4D2x": ["Template imported"], "Bdha5y": ["Publishing..."], "BeWTQy": ["Loading starter packs"], "BgV29F": ["Visual editor find input focused"], @@ -1190,6 +1192,7 @@ "PR9FSv": ["Move to the next visual-editor find result."], "PRnH8G": ["of ", ["0"]], "PVUn_c": ["Open folder"], + "PYmp-0": ["Import as template"], "PZtgO0": ["Opening worktree"], "P_bX4O": ["How to set up"], "Pck6eI": [ @@ -1260,6 +1263,9 @@ "RGCCrg": ["Copy share link"], "RGkNeJ": ["Initialize a starter pack"], "RJ3a8Z": ["Looking for <0>", ["lookingForUrl"], "."], + "RMQe3p": [ + "Are you sure you want to convert this file into a template? The original file will be deleted. This action cannot be undone." + ], "RN62Q8": [ "We'll build <0>openknowledge.skill, save it to <1>~/Downloads, and open the Claude Desktop App for you." ], @@ -1804,6 +1810,7 @@ "dOHMA0": ["Failed to move"], "dOxPd4": ["Footnote"], "dPOFid": ["Show or hide terminal"], + "dQHSCs": ["Convert (delete original)"], "dTaauv": ["Reason: ", ["reason"]], "dUCQo4": ["Renamed to \"", ["trimmed"], "\""], "dUQ7_J": [ @@ -1958,6 +1965,7 @@ "hHMv6l": ["Delete block"], "hIQkLb": ["New chat"], "hItdtk": ["Browse folder"], + "hLod4_": ["Convert to template"], "hLy8b1": ["Updated to Version ", ["version"]], "hQXiDc": ["Search branches..."], "hSBIG-": ["A title, description, tags — anything that describes this folder."], @@ -1979,6 +1987,7 @@ "hnq8fP": ["Remove ", ["label"], " from context"], "hpDtUm": ["External wiki link"], "hpzRlK": ["Reopen closed tab"], + "hvZwVx": ["Convert"], "hwFOFD": ["Settings failed to load"], "hxBHxQ": ["A line with a footnote<0><1>[1] and a definition shown below."], "hxEUVJ": ["Initialize ", ["selectedPackName"]], @@ -2434,6 +2443,7 @@ "rD-woT": [ "Tell us what went wrong and we'll gather the logs. Nothing leaves your Mac until you've reviewed it." ], + "rDOuNw": ["Failed to import template"], "rFmBG3": ["Color theme"], "rFw02Q": ["Uploading ", ["keyName"]], "rJVoVX": ["Source find results"], @@ -2583,6 +2593,7 @@ ] ], "uksqrP": ["Repository not found. It may have been renamed, deleted, or moved."], + "ul1Atu": ["Converting..."], "un8kBM": ["Creating page: ", ["linkTitle"], "."], "ur2XSb": ["Visual editor"], "uuoHOU": ["Command failed."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 1d798a7d2..b5cbff011 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -1041,6 +1041,10 @@ msgstr "Apply link" msgid "Applying your change" msgstr "Applying your change" +#: src/components/FileTree.tsx +msgid "Are you sure you want to convert this file into a template? The original file will be deleted. This action cannot be undone." +msgstr "Are you sure you want to convert this file into a template? The original file will be deleted. This action cannot be undone." + #: src/components/FileTree.tsx msgid "Are you sure you want to delete {folderName}/ and all files inside? This action cannot be undone." msgstr "Are you sure you want to delete {folderName}/ and all files inside? This action cannot be undone." @@ -1920,6 +1924,14 @@ msgstr "Content root" msgid "Content rules not yet loaded — try again in a moment" msgstr "Content rules not yet loaded — try again in a moment" +#: src/components/FileTree.tsx +msgid "Convert" +msgstr "Convert" + +#: src/components/FileTree.tsx +msgid "Convert (delete original)" +msgstr "Convert (delete original)" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "Convert selection to footnote" @@ -1928,6 +1940,14 @@ msgstr "Convert selection to footnote" msgid "Convert the current block to a paragraph." msgstr "Convert the current block to a paragraph." +#: src/components/FileTree.tsx +msgid "Convert to template" +msgstr "Convert to template" + +#: src/components/FileTree.tsx +msgid "Converting..." +msgstr "Converting..." + #: src/components/empty-state/CopyablePromptList.tsx #: src/components/InstallInClaudeDesktopDialog.tsx #: src/editor/extensions/CodeBlockView.tsx @@ -3344,6 +3364,10 @@ msgstr "Failed to enable semantic search — {detail}" msgid "Failed to enable sync — {detail}" msgstr "Failed to enable sync — {detail}" +#: src/components/FileTree.tsx +msgid "Failed to import template" +msgstr "Failed to import template" + #: src/components/ActivityModeContent.tsx msgid "Failed to load activity" msgstr "Failed to load activity" @@ -4130,6 +4154,10 @@ msgstr "Ignore patterns (raw text)" msgid "Import" msgstr "Import" +#: src/components/FileTree.tsx +msgid "Import as template" +msgstr "Import as template" + #: src/components/SeedDialog.tsx msgid "In a subfolder" msgstr "In a subfolder" @@ -4388,6 +4416,10 @@ msgstr "Keep file deleted" msgid "Keep my version" msgstr "Keep my version" +#: src/components/FileTree.tsx +msgid "Keep original file" +msgstr "Keep original file" + #: src/components/SeedDialog.tsx msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." msgstr "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." @@ -7784,6 +7816,10 @@ msgstr "template \"{name}\"" msgid "Template \"{trimmedTitle}\" created" msgstr "Template \"{trimmedTitle}\" created" +#: src/components/FileTree.tsx +msgid "Template imported" +msgstr "Template imported" + #: src/components/empty-state/CreateView.tsx msgid "Template list" msgstr "Template list" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index acb0f34e2..ebd4d6541 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -11,6 +11,7 @@ "-3ois8": ["Ĥàŕď ƀŕēàķ"], "-5jISU": [["0", "plural", { "one": ["#", " ţēḿƥĺàţē"], "other": ["#", " ţēḿƥĺàţēś"] }]], "-8PP0T": ["Ḿàţćĥ ćàśē"], + "-FeyRg": ["Ķēēƥ ōŕĩĝĩńàĺ ƒĩĺē"], "-K0AvT": ["Ďĩśćōńńēćţ"], "-LHLx7": ["ēďĩţēď ţēḿƥĺàţē"], "-QCLWk": ["Ţēḿƥĺàţēś àvàĩĺàƀĺē"], @@ -553,6 +554,7 @@ "BQu9Pg": ["Śŷḿĺĩńķ"], "BUyQnq": ["Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ. Ţŕŷ `ōķ śţàŕţ` ĩń ţĥĩś ƒōĺďēŕ."], "BY-g4_": ["Àďď ƥàţţēŕń"], + "Ba4D2x": ["Ţēḿƥĺàţē ĩḿƥōŕţēď"], "Bdha5y": ["Ƥũƀĺĩśĥĩńĝ..."], "BeWTQy": ["Ĺōàďĩńĝ śţàŕţēŕ ƥàćķś"], "BgV29F": ["Vĩśũàĺ ēďĩţōŕ ƒĩńď ĩńƥũţ ƒōćũśēď"], @@ -1190,6 +1192,7 @@ "PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."], "PRnH8G": ["ōƒ ", ["0"]], "PVUn_c": ["Ōƥēń ƒōĺďēŕ"], + "PYmp-0": ["Ĩḿƥōŕţ àś ţēḿƥĺàţē"], "PZtgO0": ["Ōƥēńĩńĝ ŵōŕķţŕēē"], "P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"], "Pck6eI": [ @@ -1260,6 +1263,9 @@ "RGCCrg": ["Ćōƥŷ śĥàŕē ĺĩńķ"], "RGkNeJ": ["Ĩńĩţĩàĺĩźē à śţàŕţēŕ ƥàćķ"], "RJ3a8Z": ["Ĺōōķĩńĝ ƒōŕ <0>", ["lookingForUrl"], "."], + "RMQe3p": [ + "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ćōńvēŕţ ţĥĩś ƒĩĺē ĩńţō à ţēḿƥĺàţē? Ţĥē ōŕĩĝĩńàĺ ƒĩĺē ŵĩĺĺ ƀē ďēĺēţēď. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē." + ], "RN62Q8": [ "Ŵē'ĺĺ ƀũĩĺď <0>ōƥēńķńōŵĺēďĝē.śķĩĺĺ, śàvē ĩţ ţō <1>~/Ďōŵńĺōàďś, àńď ōƥēń ţĥē Ćĺàũďē Ďēśķţōƥ Àƥƥ ƒōŕ ŷōũ." ], @@ -1804,6 +1810,7 @@ "dOHMA0": ["Ƒàĩĺēď ţō ḿōvē"], "dOxPd4": ["Ƒōōţńōţē"], "dPOFid": ["Śĥōŵ ōŕ ĥĩďē ţēŕḿĩńàĺ"], + "dQHSCs": ["Ćōńvēŕţ (ďēĺēţē ōŕĩĝĩńàĺ)"], "dTaauv": ["Ŕēàśōń: ", ["reason"]], "dUCQo4": ["Ŕēńàḿēď ţō \"", ["trimmed"], "\""], "dUQ7_J": [ @@ -1958,6 +1965,7 @@ "hHMv6l": ["Ďēĺēţē ƀĺōćķ"], "hIQkLb": ["Ńēŵ ćĥàţ"], "hItdtk": ["ßŕōŵśē ƒōĺďēŕ"], + "hLod4_": ["Ćōńvēŕţ ţō ţēḿƥĺàţē"], "hLy8b1": ["Ũƥďàţēď ţō Vēŕśĩōń ", ["version"]], "hQXiDc": ["Śēàŕćĥ ƀŕàńćĥēś..."], "hSBIG-": ["À ţĩţĺē, ďēśćŕĩƥţĩōń, ţàĝś — àńŷţĥĩńĝ ţĥàţ ďēśćŕĩƀēś ţĥĩś ƒōĺďēŕ."], @@ -1979,6 +1987,7 @@ "hnq8fP": ["Ŕēḿōvē ", ["label"], " ƒŕōḿ ćōńţēxţ"], "hpDtUm": ["Ēxţēŕńàĺ ŵĩķĩ ĺĩńķ"], "hpzRlK": ["Ŕēōƥēń ćĺōśēď ţàƀ"], + "hvZwVx": ["Ćōńvēŕţ"], "hwFOFD": ["Śēţţĩńĝś ƒàĩĺēď ţō ĺōàď"], "hxBHxQ": ["À ĺĩńē ŵĩţĥ à ƒōōţńōţē<0><1>[1] àńď à ďēƒĩńĩţĩōń śĥōŵń ƀēĺōŵ."], "hxEUVJ": ["Ĩńĩţĩàĺĩźē ", ["selectedPackName"]], @@ -2434,6 +2443,7 @@ "rD-woT": [ "Ţēĺĺ ũś ŵĥàţ ŵēńţ ŵŕōńĝ àńď ŵē'ĺĺ ĝàţĥēŕ ţĥē ĺōĝś. Ńōţĥĩńĝ ĺēàvēś ŷōũŕ Ḿàć ũńţĩĺ ŷōũ'vē ŕēvĩēŵēď ĩţ." ], + "rDOuNw": ["Ƒàĩĺēď ţō ĩḿƥōŕţ ţēḿƥĺàţē"], "rFmBG3": ["Ćōĺōŕ ţĥēḿē"], "rFw02Q": ["Ũƥĺōàďĩńĝ ", ["keyName"]], "rJVoVX": ["Śōũŕćē ƒĩńď ŕēśũĺţś"], @@ -2583,6 +2593,7 @@ ] ], "uksqrP": ["Ŕēƥōśĩţōŕŷ ńōţ ƒōũńď. Ĩţ ḿàŷ ĥàvē ƀēēń ŕēńàḿēď, ďēĺēţēď, ōŕ ḿōvēď."], + "ul1Atu": ["Ćōńvēŕţĩńĝ..."], "un8kBM": ["Ćŕēàţĩńĝ ƥàĝē: ", ["linkTitle"], "."], "ur2XSb": ["Vĩśũàĺ ēďĩţōŕ"], "uuoHOU": ["Ćōḿḿàńď ƒàĩĺēď."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 9942df5f6..53f1a4fb2 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -1037,6 +1037,10 @@ msgstr "" msgid "Applying your change" msgstr "" +#: src/components/FileTree.tsx +msgid "Are you sure you want to convert this file into a template? The original file will be deleted. This action cannot be undone." +msgstr "" + #: src/components/FileTree.tsx msgid "Are you sure you want to delete {folderName}/ and all files inside? This action cannot be undone." msgstr "" @@ -1916,6 +1920,14 @@ msgstr "" msgid "Content rules not yet loaded — try again in a moment" msgstr "" +#: src/components/FileTree.tsx +msgid "Convert" +msgstr "" + +#: src/components/FileTree.tsx +msgid "Convert (delete original)" +msgstr "" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "" @@ -1924,6 +1936,14 @@ msgstr "" msgid "Convert the current block to a paragraph." msgstr "" +#: src/components/FileTree.tsx +msgid "Convert to template" +msgstr "" + +#: src/components/FileTree.tsx +msgid "Converting..." +msgstr "" + #: src/components/empty-state/CopyablePromptList.tsx #: src/components/InstallInClaudeDesktopDialog.tsx #: src/editor/extensions/CodeBlockView.tsx @@ -3340,6 +3360,10 @@ msgstr "" msgid "Failed to enable sync — {detail}" msgstr "" +#: src/components/FileTree.tsx +msgid "Failed to import template" +msgstr "" + #: src/components/ActivityModeContent.tsx msgid "Failed to load activity" msgstr "" @@ -4126,6 +4150,10 @@ msgstr "" msgid "Import" msgstr "" +#: src/components/FileTree.tsx +msgid "Import as template" +msgstr "" + #: src/components/SeedDialog.tsx msgid "In a subfolder" msgstr "" @@ -4384,6 +4412,10 @@ msgstr "" msgid "Keep my version" msgstr "" +#: src/components/FileTree.tsx +msgid "Keep original file" +msgstr "" + #: src/components/SeedDialog.tsx msgid "Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about." msgstr "" @@ -7780,6 +7812,10 @@ msgstr "" msgid "Template \"{trimmedTitle}\" created" msgstr "" +#: src/components/FileTree.tsx +msgid "Template imported" +msgstr "" + #: src/components/empty-state/CreateView.tsx msgid "Template list" msgstr "" diff --git a/packages/app/tests/integration/api-error-envelope/template-import.test.ts b/packages/app/tests/integration/api-error-envelope/template-import.test.ts new file mode 100644 index 000000000..4d1430baf --- /dev/null +++ b/packages/app/tests/integration/api-error-envelope/template-import.test.ts @@ -0,0 +1,124 @@ +/** + * Narrow-integration smoke test for `handleTemplateImport` + * (`POST /api/template/import`). + * + * Covers the two user-facing paths from the File Tree "Import as template" + * menu plus a guard: + * - Keep original: template created, source doc left intact. + * - Convert (delete original): template created AND source doc removed from + * disk. This is the destructive path, so it gets an explicit assertion. + * - Regression guard for the source `title` NOT being baked into the + * instantiated doc-frontmatter (it belongs only in the `template:` + * identity block). + * - Missing source → 404 + `urn:ok:error:doc-not-found`. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ProblemDetailsSchema, TemplateImportSuccessSchema } from '@inkeep/open-knowledge-core'; +import { HARNESS_BOOT_TIMEOUT_MS } from '../harness-boot-timeout'; +import { createTestServer, type TestServer, wait } from '../test-harness'; + +let server: TestServer; + +beforeAll(async () => { + server = await createTestServer(); +}, HARNESS_BOOT_TIMEOUT_MS); + +afterAll(async () => { + await server.cleanup(); +}); + +async function writeSource(docName: string, markdown: string): Promise { + const res = await fetch(`http://127.0.0.1:${server.port}/api/agent-write-md`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ markdown, position: 'replace', docName }), + }); + expect(res.status).toBe(200); + // The import handler reads the source file off disk (existsSync gate), so wait + // for the persistence debounce to flush before importing. + const filePath = join(server.contentDir, `${docName}.md`); + for (let i = 0; i < 100; i++) { + if (existsSync(filePath)) return; + await wait(50); + } + throw new Error(`source ${docName}.md never flushed to disk`); +} + +function importTemplate(body: Record): Promise { + return fetch(`http://127.0.0.1:${server.port}/api/template/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('template import (POST /api/template/import)', () => { + test('keep original: template created, source left intact', async () => { + await writeSource('src-keep', '---\ntitle: Keep Source\n---\n\n# Heading\n\nbody text\n'); + + const res = await importTemplate({ + sourcePath: 'src-keep', + targetFolder: '', + deleteSource: false, + }); + expect(res.status).toBe(200); + const parsed = TemplateImportSuccessSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.created).toBe(true); + expect(parsed.data.path).toContain('.ok/templates/src-keep.md'); + } + + expect(existsSync(join(server.contentDir, '.ok', 'templates', 'src-keep.md'))).toBe(true); + expect(existsSync(join(server.contentDir, 'src-keep.md'))).toBe(true); + }); + + test('title is not baked into the instantiated doc-frontmatter', async () => { + await writeSource('src-title', '---\ntitle: UniqueImportTitleXYZ\n---\n\n# Heading\n\nbody\n'); + + const res = await importTemplate({ sourcePath: 'src-title', targetFolder: '' }); + expect(res.status).toBe(200); + + const tmpl = readFileSync(join(server.contentDir, '.ok', 'templates', 'src-title.md'), 'utf-8'); + // The title lives ONLY in the `template:` identity block; if it were also + // carried into the instantiated doc-frontmatter it would appear twice. + const occurrences = tmpl.split('UniqueImportTitleXYZ').length - 1; + expect(occurrences).toBe(1); + }); + + test('convert: template created AND source doc deleted', async () => { + await writeSource('src-convert', '---\ntitle: Convert Source\n---\n\n# Doc\n\nbody\n'); + expect(existsSync(join(server.contentDir, 'src-convert.md'))).toBe(true); + + const res = await importTemplate({ + sourcePath: 'src-convert', + targetFolder: '', + deleteSource: true, + }); + expect(res.status).toBe(200); + const parsed = TemplateImportSuccessSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + + expect(existsSync(join(server.contentDir, '.ok', 'templates', 'src-convert.md'))).toBe(true); + // The destructive half: the source document must be gone from disk. + for (let i = 0; i < 100; i++) { + if (!existsSync(join(server.contentDir, 'src-convert.md'))) break; + await wait(50); + } + expect(existsSync(join(server.contentDir, 'src-convert.md'))).toBe(false); + }); + + test('missing source emits 404 + doc-not-found', async () => { + const res = await importTemplate({ sourcePath: 'no-such-doc', targetFolder: '' }); + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toBe('application/problem+json'); + const parsed = ProblemDetailsSchema.safeParse(await res.json()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.type).toBe('urn:ok:error:doc-not-found'); + } + }); +}); diff --git a/packages/app/tests/integration/attribution-sweep-coverage.test.ts b/packages/app/tests/integration/attribution-sweep-coverage.test.ts index accea3f14..da42fb579 100644 --- a/packages/app/tests/integration/attribution-sweep-coverage.test.ts +++ b/packages/app/tests/integration/attribution-sweep-coverage.test.ts @@ -130,6 +130,13 @@ const EXEMPT_HANDLERS = new Set([ // other read handlers below. 'handleLintDoc', 'handleLintAudit', + // `/api/template/import` — imports an existing doc as a template. Same + // project-config posture as `handleTemplate`; it DOES thread + // `extractActorIdentity` (folder timeline) + `extractAgentIdentity` (template + // write session), but is exempt from the identity-required sweep because the + // single-file-mode guard emits before identity extraction — same rationale as + // the sibling template handlers. + 'handleTemplateImport', // `/api/templates` — project-wide flat enumeration of every template // (read-only). Returns the union of all `/.ok/templates/*.md`; // same rationale as `handleTagsList` — read path, no agent identity. diff --git a/packages/app/tests/integration/conflict-gate-coverage.test.ts b/packages/app/tests/integration/conflict-gate-coverage.test.ts index 94e0b077d..935b0ac2d 100644 --- a/packages/app/tests/integration/conflict-gate-coverage.test.ts +++ b/packages/app/tests/integration/conflict-gate-coverage.test.ts @@ -51,6 +51,10 @@ const REQUIRED_HANDLERS = [ 'handleTemplatePut', 'handleTemplateDelete', 'handleTemplateMove', + // `/api/template/import` — copies a source doc into `.ok/templates/` and, + // when `deleteSource`, removes the source. The source-delete branch gates via + // `respondDocInConflict`; the template target gates via `checkTemplateConflictGate`. + 'handleTemplateImport', // Skill CONTENT-doc writers (skills-as-content): a PROJECT `SKILL.md` and its // `.md` references are real CRDT content docs, so their CRDT paired-write // path must refuse a mid-conflict doc via `checkSkillDocConflictGate`. The diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4e1c78b9..950792556 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -962,6 +962,10 @@ export { TemplateFrontmatterSchema, type TemplateGetSuccess, TemplateGetSuccessSchema, + type TemplateImportRequest, + TemplateImportRequestSchema, + type TemplateImportSuccess, + TemplateImportSuccessSchema, type TemplateMoveRequest, TemplateMoveRequestSchema, type TemplateMoveSuccess, diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts index df1697036..bf268c93a 100644 --- a/packages/core/src/schemas/api/tags-search.ts +++ b/packages/core/src/schemas/api/tags-search.ts @@ -254,6 +254,37 @@ export const TemplatePutSuccessSchema = z .strict() satisfies StandardSchemaV1; export type TemplatePutSuccess = z.infer; +/** + * Request body for `POST /api/template/import`. `sourcePath` is the relative + * path to the source markdown document, `targetFolder` is the folder where the + * template should be saved, `name` is the optional template name, and `title` + * is the optional template title. `deleteSource` is optional to support move-instead-of-copy. + */ +export const TemplateImportRequestSchema = z + .object({ + sourcePath: z.string().min(1), + targetFolder: z.string(), + name: z.string().optional(), + title: z.string().optional(), + deleteSource: z.boolean().optional(), + ...agentIdentityFields, + summary: summaryField, + }) + .strict() satisfies StandardSchemaV1; +export type TemplateImportRequest = z.infer; + +/** + * Success body for `POST /api/template/import`. + */ +export const TemplateImportSuccessSchema = z + .object({ + path: z.string().min(1), + created: z.boolean(), + warnings: z.array(z.string()), + }) + .strict() satisfies StandardSchemaV1; +export type TemplateImportSuccess = z.infer; + /** * Success body for `DELETE /api/template?name=&folder=`. `existed` is * `true` when the file was deleted; `false` when the operation was a no-op diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 286ce3f62..4cefaaec8 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -217,6 +217,8 @@ import { TagsListSuccessSchema, TemplateDeleteSuccessSchema, TemplateGetSuccessSchema, + TemplateImportRequestSchema, + TemplateImportSuccessSchema, TemplateMoveRequestSchema, TemplateMoveSuccessSchema, TemplatePutRequestSchema, @@ -247,7 +249,7 @@ import { } from '@inkeep/open-knowledge-core/shadow-repo-layout'; import busboy from 'busboy'; import { fileTypeFromBuffer } from 'file-type'; -import { parse as parseYaml } from 'yaml'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { z } from 'zod'; import { captureEffect } from './activity-log.ts'; import { listAgentActivity, synthesizeVersionDiff } from './agent-activity.ts'; @@ -13376,7 +13378,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { function checkTemplateConflictGate( folder: string, name: string, - handler: 'template-put' | 'template-delete' | 'template-move', + handler: 'template-put' | 'template-delete' | 'template-move' | 'template-import', res: ServerResponse, ): boolean { if (!name) return false; @@ -13968,6 +13970,262 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { { handler: 'template-move', method: 'POST' }, ); + const handleTemplateImport = withValidation( + TemplateImportRequestSchema, + async (_req, res, body) => { + try { + if (ephemeral) { + errorResponse( + res, + 403, + 'urn:ok:error:single-file-mode', + 'Templates are not available in single-file mode.', + { handler: 'template-import' }, + ); + return; + } + + const actor = extractActorIdentity( + body as unknown as Record, + getPrincipal, + ); + if (actor.kind === 'invalid-summary') { + errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Summary must be a string.', { + handler: 'template-import', + }); + return; + } + + const sourcePath = body.sourcePath; + if (!isSafeDocName(sourcePath)) { + errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Invalid sourcePath.', { + handler: 'template-import', + }); + return; + } + + const sourceDocName = resolveAlias(sourcePath); + if (isSystemDoc(sourceDocName) || isConfigDoc(sourceDocName)) { + errorResponse( + res, + 400, + 'urn:ok:error:reserved-doc-name', + `'${sourceDocName}' is a reserved document name.`, + { handler: 'template-import' }, + ); + return; + } + + const sourceFilePath = resolveContentEntryPath(contentDir, 'file', sourceDocName); + if (!existsSync(sourceFilePath)) { + errorResponse( + res, + 404, + 'urn:ok:error:doc-not-found', + `Source document not found: ${sourceDocName}.`, + { + handler: 'template-import', + }, + ); + return; + } + + const existing = hocuspocus.documents.get(sourceDocName); + if (body.deleteSource) { + const deleteEngine = getSyncEngine?.(); + const deleteTrackedFiles = new Set( + deleteEngine ? deleteEngine.getConflicts().map((c) => c.file) : [], + ); + const conflictedByLifecycle = existing !== undefined && isDocInConflict(existing); + const conflictedByStore = deleteTrackedFiles.has(sourcePath); + if (conflictedByLifecycle || conflictedByStore) { + respondDocInConflict( + res, + new DocInConflictError({ file: sourcePath }), + 'template-import', + ); + return; + } + } + + // Read source content + let sourceContent = ''; + if (existing) { + sourceContent = existing.getText('source').toString(); + } else { + const dc = await hocuspocus.openDirectConnection(sourceDocName); + try { + const document = dc.document; + if (!document) { + errorResponse( + res, + 500, + 'urn:ok:error:doc-not-available', + 'Source document is not available.', + { + handler: 'template-import', + }, + ); + return; + } + sourceContent = document.getText('source').toString(); + } finally { + await dc.disconnect(); + } + } + + // Determine target template name + let name = body.name; + if (!name) { + const { basename } = splitContentPath(sourcePath); + const nameWithoutExt = basename.replace(/\.(md|mdx)$/i, ''); + name = nameWithoutExt.replace(/[^A-Za-z0-9_-]/g, '-').toLowerCase(); + name = name.replace(/^[-_]+|[-_]+$/g, ''); + name ||= 'imported-template'; + } + + if (!validateTemplateName(name, res, 'template-import')) return; + + const validated = validateFolderRel(body.targetFolder, res, 'folder', 'template-import'); + if (!validated) return; + + if (checkTemplateConflictGate(validated.folderRel, name, 'template-import', res)) return; + + // Parse existing frontmatter of the source file to extract the title/description/tags + const { frontmatter: sourceFmText, body: sourceBody } = stripFrontmatter(sourceContent); + const cleanFmText = unwrapFrontmatterFences(sourceFmText); + let sourceFmObj: Record = {}; + try { + if (cleanFmText.trim()) { + sourceFmObj = parseYaml(cleanFmText) as Record; + } + } catch { + // Malformed frontmatter — treat the source as having none. + } + + const templateTitle = + body.title || (sourceFmObj?.title as string) || extractPageTitle(sourceContent, name); + const templateDescription = (sourceFmObj?.description as string) || ''; + const templateTags = Array.isArray(sourceFmObj?.tags) ? (sourceFmObj.tags as string[]) : []; + + // For the starter content, we can use the original document frontmatter but remove `template:` + // if it somehow got there. Keep other fields. We also drop `title` so it doesn't get baked into every instance. + const starterFmObj = { ...sourceFmObj }; + delete starterFmObj.template; + delete starterFmObj.title; + + let starterContent = ''; + if (Object.keys(starterFmObj).length > 0) { + const fmYaml = stringifyYaml(starterFmObj); + starterContent = fmYaml.trim() + '\n'; + } + starterContent = starterContent ? `---\n${starterContent}---\n${sourceBody}` : sourceBody; + + const composed = composeTemplateContent({ + name, + body: starterContent, + frontmatter: { + title: templateTitle, + description: templateDescription, + tags: templateTags, + }, + }); + + if (!composed.ok) { + errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Invalid template request.', { + handler: 'template-import', + detail: composed.error.code, + cause: new Error(composed.error.message), + }); + return; + } + + const templateFilePath = resolve( + validated.resolvedContentDir, + validated.folderRel, + '.ok', + 'templates', + `${name}.md`, + ); + const templateCreated = !existsSync(templateFilePath); + const templateRelPath = relative(validated.resolvedContentDir, templateFilePath) + .split(/[\\/]/) + .filter(Boolean) + .join('/'); + const templateDocName = templateDocNameFor(validated.folderRel, name); + + const { agentId, agentName, colorSeed, clientName } = extractAgentIdentity( + body as unknown as Record, + ); + const templateSession = await sessionManager.getSession(templateDocName, agentId, { + displayName: agentName, + colorSeed, + clientName, + }); + templateSession.dc.document.transact(() => { + composeAndWriteRawBody(templateSession.dc.document, composed.content, 'agent'); + }, templateSession.origin); + + const templateFlush = await flushDiskAndDetectOutcome(templateDocName); + if (templateFlush?.kind === 'failure') { + respondPersistenceFailure(res, templateFlush.failure, 'template-import'); + return; + } + if (templateFlush?.kind === 'divergence') { + respondDiskDivergence(res, 'template-import'); + return; + } + + attributeOkArtifactWrite( + actor, + okArtifactKey('template', validated.folderRel, name), + `template-import: ${templateRelPath}`, + ); + + if (body.deleteSource) { + const deletedDocNames = [sourceDocName]; + await captureAndCloseDocuments(deletedDocNames, 'deleted-upstream'); + if (recentlyRemovedDocs) { + recentlyRemovedDocs.setDeleted(sourceDocName); + } + tracedUnlinkSync(sourceFilePath); + mutateFileIndex?.({ + kind: 'delete', + path: sourceFilePath, + docName: sourceDocName, + }); + } + + await commitOkArtifactWrite('template-import'); + signalChannel?.('files'); + + successResponse( + res, + 200, + TemplateImportSuccessSchema, + { + path: templateRelPath, + created: templateCreated, + warnings: composed.warnings, + }, + { handler: 'template-import' }, + ); + } catch (e) { + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Failed to import template.', + { + handler: 'template-import', + cause: e, + }, + ); + } + }, + { handler: 'template-import', method: 'POST' }, + ); + // ─── Skills (`/api/skill`, `/api/skills`) ────────────────────── // // Skills are fs-direct `.ok/skills//` artifacts (SKILL.md + optional @@ -18335,6 +18593,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { '/api/pages': handlePages, '/api/folder-config': handleFolderConfig, '/api/template': handleTemplate, + '/api/template/import': handleTemplateImport, '/api/templates': handleTemplatesList, '/api/skill': handleSkill, '/api/skill-file': handleSkillFile, @@ -18451,6 +18710,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { '/api/install-skill', '/api/folder-config', '/api/template', + '/api/template/import', '/api/skill', '/api/skill-file', '/api/skill/install',