Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/import-as-template.md
Original file line number Diff line number Diff line change
@@ -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.
100 changes: 90 additions & 10 deletions packages/app/src/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
Copy,
CopyPlus,
EyeOff,
FileKey,
FilePlus,
FolderOpen,
FolderPlus,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -755,6 +758,7 @@ function FileTreeMenu({
onStartCreating,
onCreateFromTemplate,
onDuplicate,
onImportTemplate,
onDelete,
onExpandSubtree,
onCollapseSubtree,
Expand Down Expand Up @@ -1134,16 +1138,44 @@ function FileTreeMenu({
<>
<DropdownMenuSeparator />
{!isAsset ? (
<DropdownMenuItem
disabled={anyActionBusy}
onSelect={() => {
close();
onDuplicate(target);
}}
>
<CopyPlus aria-hidden="true" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={anyActionBusy}>
<FileKey aria-hidden="true" />
<Trans>Import as template</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
disabled={anyActionBusy}
onSelect={() => {
close();
onImportTemplate(target, false);
}}
>
<Trans>Keep original file</Trans>
</DropdownMenuItem>
<DropdownMenuItem
disabled={anyActionBusy}
onSelect={() => {
close();
onImportTemplate(target, true);
}}
>
<Trans>Convert (delete original)</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={anyActionBusy}
onSelect={() => {
close();
onDuplicate(target);
}}
>
<CopyPlus aria-hidden="true" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
</>
) : null}
<DropdownMenuItem
disabled={anyActionBusy}
Expand Down Expand Up @@ -2152,6 +2184,53 @@ export function FileTree({
handleDuplicateTargetRef.current = handleDuplicateTarget;
});

async function handleImportTemplate(target: FileTreeTarget, deleteSource: boolean) {
if (target.kind !== 'file') return;
if (busyPathRef.current !== null) return;
const clearBusyState = () => {
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;
Expand Down Expand Up @@ -4446,6 +4525,7 @@ export function FileTree({
startCreating('file', parentDir, { template: templateName })
}
onDuplicate={handleDuplicateTarget}
onImportTemplate={handleImportTemplate}
onDelete={(targets) => setDeleteRequest({ targets })}
onExpandSubtree={expandSubtree}
onCollapseSubtree={collapseSubtree}
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/lib/folder-config-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
}
5 changes: 5 additions & 0 deletions packages/app/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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"],
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/locales/pseudo/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"-3ois8": ["Ĥàŕď ƀŕēàķ"],
"-5jISU": [["0", "plural", { "one": ["#", " ţēḿƥĺàţē"], "other": ["#", " ţēḿƥĺàţēś"] }]],
"-8PP0T": ["Ḿàţćĥ ćàśē"],
"-FeyRg": ["Ķēēƥ ōŕĩĝĩńàĺ ƒĩĺē"],
"-K0AvT": ["Ďĩśćōńńēćţ"],
"-LHLx7": ["ēďĩţēď ţēḿƥĺàţē"],
"-QCLWk": ["Ţēḿƥĺàţēś àvàĩĺàƀĺē"],
Expand Down Expand Up @@ -549,6 +550,7 @@
"BQu9Pg": ["Śŷḿĺĩńķ"],
"BUyQnq": ["Ćōũĺďń'ţ ŕēśţàŕţ ţĥē śēŕvēŕ. Ţŕŷ `ōķ śţàŕţ` ĩń ţĥĩś ƒōĺďēŕ."],
"BY-g4_": ["Àďď ƥàţţēŕń"],
"Ba4D2x": ["Ţēḿƥĺàţē ĩḿƥōŕţēď"],
"Bdha5y": ["Ƥũƀĺĩśĥĩńĝ..."],
"BeWTQy": ["Ĺōàďĩńĝ śţàŕţēŕ ƥàćķś"],
"BgV29F": ["Vĩśũàĺ ēďĩţōŕ ƒĩńď ĩńƥũţ ƒōćũśēď"],
Expand Down Expand Up @@ -1168,6 +1170,7 @@
"PR9FSv": ["Ḿōvē ţō ţĥē ńēxţ vĩśũàĺ-ēďĩţōŕ ƒĩńď ŕēśũĺţ."],
"PRnH8G": ["ōƒ ", ["0"]],
"PVUn_c": ["Ōƥēń ƒōĺďēŕ"],
"PYmp-0": ["Ĩḿƥōŕţ àś ţēḿƥĺàţē"],
"PZtgO0": ["Ōƥēńĩńĝ ŵōŕķţŕēē"],
"P_bX4O": ["Ĥōŵ ţō śēţ ũƥ"],
"Pck6eI": [
Expand Down Expand Up @@ -1783,6 +1786,7 @@
"dOHMA0": ["Ƒàĩĺēď ţō ḿōvē"],
"dOxPd4": ["Ƒōōţńōţē"],
"dPOFid": ["Śĥōŵ ōŕ ĥĩďē ţēŕḿĩńàĺ"],
"dQHSCs": ["Ćōńvēŕţ (ďēĺēţē ōŕĩĝĩńàĺ)"],
"dTaauv": ["Ŕēàśōń: ", ["reason"]],
"dUCQo4": ["Ŕēńàḿēď ţō \"", ["trimmed"], "\""],
"dUQ7_J": [
Expand Down Expand Up @@ -2406,6 +2410,7 @@
"rD-woT": [
"Ţēĺĺ ũś ŵĥàţ ŵēńţ ŵŕōńĝ àńď ŵē'ĺĺ ĝàţĥēŕ ţĥē ĺōĝś. Ńōţĥĩńĝ ĺēàvēś ŷōũŕ Ḿàć ũńţĩĺ ŷōũ'vē ŕēvĩēŵēď ĩţ."
],
"rDOuNw": ["Ƒàĩĺēď ţō ĩḿƥōŕţ ţēḿƥĺàţē"],
"rFmBG3": ["Ćōĺōŕ ţĥēḿē"],
"rFw02Q": ["Ũƥĺōàďĩńĝ ", ["keyName"]],
"rJVoVX": ["Śōũŕćē ƒĩńď ŕēśũĺţś"],
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/locales/pseudo/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 ""
Expand Down
Loading