From 9e6a0e0a58a80a9efd5a53a12852d0c83369e24e Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:16:17 +0530 Subject: [PATCH 1/5] feat: add "Import as template" to File Tree context menu --- packages/app/src/components/FileTree.tsx | 100 +++++++- packages/app/src/lib/folder-config-api.ts | 37 +++ packages/core/src/index.ts | 4 + packages/core/src/schemas/api/tags-search.ts | 32 +++ packages/server/src/api-extension.ts | 240 ++++++++++++++++++- 5 files changed, 401 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index 2c6f62a57..172082313 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, @@ -45,6 +46,7 @@ import { import { __iconNode as botIcon } from 'lucide-react/dist/esm/icons/bot'; import { __iconNode as link2Icon } from 'lucide-react/dist/esm/icons/link-2'; import { useTheme } from 'next-themes'; +import { importTemplate } from '@/lib/folder-config-api'; import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, @@ -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} { + setBusyPath(null); + busyPathRef.current = 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 +4525,7 @@ export function FileTree({ startCreating('file', parentDir, { template: templateName }) } onDuplicate={handleDuplicateTarget} + onImportTemplate={handleImportTemplate} onDelete={(targets) => setDeleteRequest({ targets })} onExpandSubtree={expandSubtree} onCollapseSubtree={collapseSubtree} diff --git a/packages/app/src/lib/folder-config-api.ts b/packages/app/src/lib/folder-config-api.ts index 258b3d7fd..f6c6b8d2c 100644 --- a/packages/app/src/lib/folder-config-api.ts +++ b/packages/app/src/lib/folder-config-api.ts @@ -164,3 +164,40 @@ 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/core/src/index.ts b/packages/core/src/index.ts index 273166548..e82b8bea3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -950,6 +950,10 @@ export { TemplatePutRequestSchema, type TemplatePutSuccess, TemplatePutSuccessSchema, + type TemplateImportRequest, + TemplateImportRequestSchema, + type TemplateImportSuccess, + TemplateImportSuccessSchema, type TemplatesListEntry, TemplatesListEntrySchema, type TemplatesListSuccess, diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts index df1697036..b1f07f23d 100644 --- a/packages/core/src/schemas/api/tags-search.ts +++ b/packages/core/src/schemas/api/tags-search.ts @@ -254,6 +254,38 @@ 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 14eb61c3a..b9c9ab0d8 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -221,6 +221,8 @@ import { TemplateMoveSuccessSchema, TemplatePutRequestSchema, TemplatePutSuccessSchema, + TemplateImportRequestSchema, + TemplateImportSuccessSchema, TemplatesListSuccessSchema, TestFlushGitSuccessSchema, TestRescanBacklinksSuccessSchema, @@ -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, synthesizeStackItemDiffText } from './agent-activity.ts'; @@ -13944,8 +13946,240 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { cause: e, }); } + ); + + 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, ''); + if (!name) { + 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 (e) { + // ignore parsing error, treat as empty + } + + 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. + const starterFmObj = { ...sourceFmObj }; + delete starterFmObj.template; + + 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-move', method: 'POST' }, + { handler: 'template-import', method: 'POST' }, ); // ─── Skills (`/api/skill`, `/api/skills`) ────────────────────── @@ -18271,6 +18505,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, @@ -18387,6 +18622,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', From 7dd2d94622cb5de561a0bf616480090860b76b04 Mon Sep 17 00:00:00 2001 From: Timothy Cardona Date: Fri, 17 Jul 2026 17:09:53 -0400 Subject: [PATCH 2/5] fix: make Import-as-template build and pass gates Fixups on top of the "Import as template" feature so it compiles and clears CI: - Restore handleTemplateMove's closing: the new handler splice dropped its `},` plus `{ handler: 'template-move', method: 'POST' }` options argument, which broke the parse of api-extension.ts (whole server failed to build). - Add 'template-import' to checkTemplateConflictGate's handler union (typecheck). - Register handleTemplateImport in the route meta-tests: REQUIRED in conflict-gate-coverage (it calls respondDocInConflict), EXEMPT in attribution-sweep-coverage (single-file-mode guard emits before identity). - Extract Lingui catalogs for the new UI strings (en + pseudo). - Add a changeset; tidy the empty catch and name fallback for lint. Co-authored-by: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> --- .changeset/import-as-template.md | 5 ++ packages/app/src/components/FileTree.tsx | 6 +- packages/app/src/lib/folder-config-api.ts | 5 +- packages/app/src/locales/en/messages.json | 5 ++ packages/app/src/locales/en/messages.po | 20 ++++++ packages/app/src/locales/pseudo/messages.json | 5 ++ packages/app/src/locales/pseudo/messages.po | 20 ++++++ .../attribution-sweep-coverage.test.ts | 7 +++ .../conflict-gate-coverage.test.ts | 4 ++ packages/core/src/index.ts | 8 +-- packages/core/src/schemas/api/tags-search.ts | 1 - packages/server/src/api-extension.ts | 63 +++++++++++++------ 12 files changed, 119 insertions(+), 30 deletions(-) create mode 100644 .changeset/import-as-template.md 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 172082313..b989e37cc 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -46,7 +46,6 @@ import { import { __iconNode as botIcon } from 'lucide-react/dist/esm/icons/bot'; import { __iconNode as link2Icon } from 'lucide-react/dist/esm/icons/link-2'; import { useTheme } from 'next-themes'; -import { importTemplate } from '@/lib/folder-config-api'; import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, @@ -197,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'; @@ -2215,8 +2215,8 @@ export function FileTree({ 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) + const next = current.filter( + (entry) => !(isDocumentEntry(entry) && entry.docName === target.path), ); resetModelToDocuments(next); markNextDocumentsAsApplied(next); diff --git a/packages/app/src/lib/folder-config-api.ts b/packages/app/src/lib/folder-config-api.ts index f6c6b8d2c..951374b27 100644 --- a/packages/app/src/lib/folder-config-api.ts +++ b/packages/app/src/lib/folder-config-api.ts @@ -174,7 +174,9 @@ export async function importTemplate(input: { name?: string; title?: string; deleteSource?: boolean; -}): Promise<{ ok: true; path: string; created: boolean; warnings: string[] } | { ok: false; error: string }> { +}): Promise< + { ok: true; path: string; created: boolean; warnings: string[] } | { ok: false; error: string } +> { try { const res = await fetch('/api/template/import', { method: 'POST', @@ -200,4 +202,3 @@ export async function importTemplate(input: { 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 bb7104612..32c8aac04 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"], @@ -549,6 +550,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"], @@ -1168,6 +1170,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": [ @@ -1783,6 +1786,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": [ @@ -2406,6 +2410,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"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 62f3f2558..bfbddd0bc 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -1891,6 +1891,10 @@ 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 (delete original)" +msgstr "Convert (delete original)" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "Convert selection to footnote" @@ -3286,6 +3290,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" @@ -4046,6 +4054,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" @@ -4304,6 +4316,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." @@ -7638,6 +7654,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 6ee049602..f64bf0214 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àĩĺàƀĺē"], @@ -549,6 +550,7 @@ "BQu9Pg": ["Śŷḿĺĩńķ"], "BUyQnq": ["Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ. Ţŕŷ `ōķ śţàŕţ` ĩń ţĥĩś ƒōĺďēŕ."], "BY-g4_": ["Àďď ƥàţţēŕń"], + "Ba4D2x": ["Ţēḿƥĺàţē ĩḿƥōŕţēď"], "Bdha5y": ["Ƥũƀĺĩśĥĩńĝ..."], "BeWTQy": ["Ĺōàďĩńĝ śţàŕţēŕ ƥàćķś"], "BgV29F": ["Vĩśũàĺ ēďĩţōŕ ƒĩńď ĩńƥũţ ƒōćũśēď"], @@ -1168,6 +1170,7 @@ "PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."], "PRnH8G": ["ōƒ ", ["0"]], "PVUn_c": ["Ōƥēń ƒōĺďēŕ"], + "PYmp-0": ["Ĩḿƥōŕţ àś ţēḿƥĺàţē"], "PZtgO0": ["Ōƥēńĩńĝ ŵōŕķţŕēē"], "P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"], "Pck6eI": [ @@ -1783,6 +1786,7 @@ "dOHMA0": ["Ƒàĩĺēď ţō ḿōvē"], "dOxPd4": ["Ƒōōţńōţē"], "dPOFid": ["Śĥōŵ ōŕ ĥĩďē ţēŕḿĩńàĺ"], + "dQHSCs": ["Ćōńvēŕţ (ďēĺēţē ōŕĩĝĩńàĺ)"], "dTaauv": ["Ŕēàśōń: ", ["reason"]], "dUCQo4": ["Ŕēńàḿēď ţō \"", ["trimmed"], "\""], "dUQ7_J": [ @@ -2406,6 +2410,7 @@ "rD-woT": [ "Ţēĺĺ ũś ŵĥàţ ŵēńţ ŵŕōńĝ àńď ŵē'ĺĺ ĝàţĥēŕ ţĥē ĺōĝś. Ńōţĥĩńĝ ĺēàvēś ŷōũŕ Ḿàć ũńţĩĺ ŷōũ'vē ŕēvĩēŵēď ĩţ." ], + "rDOuNw": ["Ƒàĩĺēď ţō ĩḿƥōŕţ ţēḿƥĺàţē"], "rFmBG3": ["Ćōĺōŕ ţĥēḿē"], "rFw02Q": ["Ũƥĺōàďĩńĝ ", ["keyName"]], "rJVoVX": ["Śōũŕćē ƒĩńď ŕēśũĺţś"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 2d0c30cda..fb05688a9 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -1887,6 +1887,10 @@ msgstr "" msgid "Content rules not yet loaded — try again in a moment" msgstr "" +#: src/components/FileTree.tsx +msgid "Convert (delete original)" +msgstr "" + #: src/editor/bubble-menu/FootnoteBubbleButton.tsx msgid "Convert selection to footnote" msgstr "" @@ -3282,6 +3286,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 "" @@ -4042,6 +4050,10 @@ msgstr "" msgid "Import" msgstr "" +#: src/components/FileTree.tsx +msgid "Import as template" +msgstr "" + #: src/components/SeedDialog.tsx msgid "In a subfolder" msgstr "" @@ -4300,6 +4312,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 "" @@ -7634,6 +7650,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/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 e82b8bea3..7972fc181 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -940,6 +940,10 @@ export { TemplateFrontmatterSchema, type TemplateGetSuccess, TemplateGetSuccessSchema, + type TemplateImportRequest, + TemplateImportRequestSchema, + type TemplateImportSuccess, + TemplateImportSuccessSchema, type TemplateMoveRequest, TemplateMoveRequestSchema, type TemplateMoveSuccess, @@ -950,10 +954,6 @@ export { TemplatePutRequestSchema, type TemplatePutSuccess, TemplatePutSuccessSchema, - type TemplateImportRequest, - TemplateImportRequestSchema, - type TemplateImportSuccess, - TemplateImportSuccessSchema, type TemplatesListEntry, TemplatesListEntrySchema, type TemplatesListSuccess, diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts index b1f07f23d..bf268c93a 100644 --- a/packages/core/src/schemas/api/tags-search.ts +++ b/packages/core/src/schemas/api/tags-search.ts @@ -285,7 +285,6 @@ export const TemplateImportSuccessSchema = z .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 b9c9ab0d8..379d5da11 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -217,12 +217,12 @@ import { TagsListSuccessSchema, TemplateDeleteSuccessSchema, TemplateGetSuccessSchema, + TemplateImportRequestSchema, + TemplateImportSuccessSchema, TemplateMoveRequestSchema, TemplateMoveSuccessSchema, TemplatePutRequestSchema, TemplatePutSuccessSchema, - TemplateImportRequestSchema, - TemplateImportSuccessSchema, TemplatesListSuccessSchema, TestFlushGitSuccessSchema, TestRescanBacklinksSuccessSchema, @@ -13358,7 +13358,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; @@ -13946,6 +13946,8 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { cause: e, }); } + }, + { handler: 'template-move', method: 'POST' }, ); const handleTemplateImport = withValidation( @@ -13996,9 +13998,15 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { 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', - }); + errorResponse( + res, + 404, + 'urn:ok:error:doc-not-found', + `Source document not found: ${sourceDocName}.`, + { + handler: 'template-import', + }, + ); return; } @@ -14011,7 +14019,11 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const conflictedByLifecycle = existing !== undefined && isDocInConflict(existing); const conflictedByStore = deleteTrackedFiles.has(sourcePath); if (conflictedByLifecycle || conflictedByStore) { - respondDocInConflict(res, new DocInConflictError({ file: sourcePath }), 'template-import'); + respondDocInConflict( + res, + new DocInConflictError({ file: sourcePath }), + 'template-import', + ); return; } } @@ -14025,9 +14037,15 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { try { const document = dc.document; if (!document) { - errorResponse(res, 500, 'urn:ok:error:doc-not-available', 'Source document is not available.', { - handler: 'template-import', - }); + errorResponse( + res, + 500, + 'urn:ok:error:doc-not-available', + 'Source document is not available.', + { + handler: 'template-import', + }, + ); return; } sourceContent = document.getText('source').toString(); @@ -14043,9 +14061,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const nameWithoutExt = basename.replace(/\.(md|mdx)$/i, ''); name = nameWithoutExt.replace(/[^A-Za-z0-9_-]/g, '-').toLowerCase(); name = name.replace(/^[-_]+|[-_]+$/g, ''); - if (!name) { - name = 'imported-template'; - } + name ||= 'imported-template'; } if (!validateTemplateName(name, res, 'template-import')) return; @@ -14063,11 +14079,12 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { if (cleanFmText.trim()) { sourceFmObj = parseYaml(cleanFmText) as Record; } - } catch (e) { - // ignore parsing error, treat as empty + } catch { + // Malformed frontmatter — treat the source as having none. } - const templateTitle = body.title || (sourceFmObj?.title as string) || extractPageTitle(sourceContent, name); + 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[]) : []; @@ -14173,10 +14190,16 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { { handler: 'template-import' }, ); } catch (e) { - errorResponse(res, 500, 'urn:ok:error:internal-server-error', 'Failed to import template.', { - handler: 'template-import', - cause: e, - }); + errorResponse( + res, + 500, + 'urn:ok:error:internal-server-error', + 'Failed to import template.', + { + handler: 'template-import', + cause: e, + }, + ); } }, { handler: 'template-import', method: 'POST' }, From ec73e78cfe23c817e33aafdd4f70c56d5d7f8f4d Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:54:45 +0530 Subject: [PATCH 3/5] fix: address template import PR feedback --- packages/app/src/components/FileTree.tsx | 28 ++++++++++++++++++++++++ packages/server/src/api-extension.ts | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index b989e37cc..a1952b38a 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -1429,6 +1429,7 @@ export function FileTree({ const [unfilteredRootEntryCount, setUnfilteredRootEntryCount] = useState(0); const [busyPath, setBusyPath] = useState(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 @@ -2186,10 +2187,19 @@ export function FileTree({ 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); @@ -4594,6 +4604,24 @@ export function FileTree({ /> )} + { + if (!open && !busyPath) setTemplateConvertRequest(null); + }} + > + {templateConvertRequest && ( + executeImportTemplate(templateConvertRequest, true)} + /> + )} + { diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index 379d5da11..c3f728424 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -14089,9 +14089,10 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { 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. + // 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) { From e42923407196c3052920304187fc5f175ea08977 Mon Sep 17 00:00:00 2001 From: Timothy Cardona Date: Mon, 20 Jul 2026 11:07:14 -0400 Subject: [PATCH 4/5] chore: extract i18n catalogs for template-convert dialog + format Regenerate en/pseudo Lingui catalogs for the new convert-confirmation dialog strings, and apply the formatter to FileTree.tsx. Co-authored-by: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> --- packages/app/src/components/FileTree.tsx | 7 ++++++- packages/app/src/locales/en/messages.json | 6 ++++++ packages/app/src/locales/en/messages.po | 16 ++++++++++++++++ packages/app/src/locales/pseudo/messages.json | 6 ++++++ packages/app/src/locales/pseudo/messages.po | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index a1952b38a..a5a401d1a 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -4612,7 +4612,12 @@ export function FileTree({ > {templateConvertRequest && ( ", ["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." ], @@ -1950,6 +1953,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."], @@ -1970,6 +1974,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"]], @@ -2561,6 +2566,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 bfbddd0bc..3302378b4 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -1015,6 +1015,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." @@ -1891,6 +1895,10 @@ 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)" @@ -1903,6 +1911,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 diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index f64bf0214..fd32ab4d2 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -1240,6 +1240,9 @@ "RGCCrg": ["Ćōƥŷ śĥàŕē ĺĩńķ"], "RGkNeJ": ["Ĩńĩţĩàĺĩźē à śţàŕţēŕ ƥàćķ"], "RJ3a8Z": ["Ĺōōķĩńĝ ƒōŕ <0>", ["lookingForUrl"], "."], + "RMQe3p": [ + "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ćōńvēŕţ ţĥĩś ƒĩĺē ĩńţō à ţēḿƥĺàţē? Ţĥē ōŕĩĝĩńàĺ ƒĩĺē ŵĩĺĺ ƀē ďēĺēţēď. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē." + ], "RN62Q8": [ "Ŵē'ĺĺ ƀũĩĺď <0>ōƥēńķńōŵĺēďĝē.śķĩĺĺ, śàvē ĩţ ţō <1>~/Ďōŵńĺōàďś, àńď ōƥēń ţĥē Ćĺàũďē Ďēśķţōƥ Àƥƥ ƒōŕ ŷōũ." ], @@ -1950,6 +1953,7 @@ "hHMv6l": ["Ďēĺēţē ƀĺōćķ"], "hIQkLb": ["Ńēŵ ćĥàţ"], "hItdtk": ["ßŕōŵśē ƒōĺďēŕ"], + "hLod4_": ["Ćōńvēŕţ ţō ţēḿƥĺàţē"], "hLy8b1": ["Ũƥďàţēď ţō Vēŕśĩōń ", ["version"]], "hQXiDc": ["Śēàŕćĥ ƀŕàńćĥēś..."], "hSBIG-": ["À ţĩţĺē, ďēśćŕĩƥţĩōń, ţàĝś — àńŷţĥĩńĝ ţĥàţ ďēśćŕĩƀēś ţĥĩś ƒōĺďēŕ."], @@ -1970,6 +1974,7 @@ "hnq8fP": ["Ŕēḿōvē ", ["label"], " ƒŕōḿ ćōńţēxţ"], "hpDtUm": ["Ēxţēŕńàĺ ŵĩķĩ ĺĩńķ"], "hpzRlK": ["Ŕēōƥēń ćĺōśēď ţàƀ"], + "hvZwVx": ["Ćōńvēŕţ"], "hwFOFD": ["Śēţţĩńĝś ƒàĩĺēď ţō ĺōàď"], "hxBHxQ": ["À ĺĩńē ŵĩţĥ à ƒōōţńōţē<0><1>[1] àńď à ďēƒĩńĩţĩōń śĥōŵń ƀēĺōŵ."], "hxEUVJ": ["Ĩńĩţĩàĺĩźē ", ["selectedPackName"]], @@ -2561,6 +2566,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 fb05688a9..3a97ae6bf 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -1011,6 +1011,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 "" @@ -1887,6 +1891,10 @@ 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 "" @@ -1899,6 +1907,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 From e30c3cf770608b8d30199a0639c0be65d454343d Mon Sep 17 00:00:00 2001 From: Timothy Cardona Date: Mon, 20 Jul 2026 12:25:07 -0400 Subject: [PATCH 5/5] test: smoke-test /api/template/import (keep + convert + guards) Narrow-integration coverage for the new import endpoint, since the destructive "Convert (delete original)" path had none: - keep original: template created, source intact - convert: template created AND source doc deleted from disk - regression guard: source title is not baked into the instantiated frontmatter - missing source: 404 + urn:ok:error:doc-not-found Co-authored-by: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> --- .../template-import.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/app/tests/integration/api-error-envelope/template-import.test.ts 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'); + } + }); +});