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/2] 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/2] 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' },