Skip to content
Merged
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.
133 changes: 123 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 @@ -1397,6 +1429,7 @@ export function FileTree({
const [unfilteredRootEntryCount, setUnfilteredRootEntryCount] = useState(0);
const [busyPath, setBusyPath] = useState<string | null>(null);
const [deleteRequest, setDeleteRequest] = useState<FileTreeDeleteRequest | null>(null);
const [templateConvertRequest, setTemplateConvertRequest] = useState<FileTreeTarget | null>(null);
/**
* Set when `shell.trashItem` returns `{ ok: false }` for one or more
* targets during the Step 1 trash flow. Drives the rendering of
Expand Down Expand Up @@ -2152,6 +2185,62 @@ export function FileTree({
handleDuplicateTargetRef.current = handleDuplicateTarget;
});

async function handleImportTemplate(target: FileTreeTarget, deleteSource: boolean) {
if (target.kind !== 'file') return;
if (deleteSource) {
setTemplateConvertRequest(target);
return;
}
await executeImportTemplate(target, false);
}

async function executeImportTemplate(target: FileTreeTarget, deleteSource: boolean) {
if (busyPathRef.current !== null) return;
const clearBusyState = () => {
setBusyPath(null);
busyPathRef.current = null;
setTemplateConvertRequest(null);
};
busyPathRef.current = target.path;
setBusyPath(target.path);
setError(null);

const appPath = target.path;
const slash = appPath.lastIndexOf('/');
const targetFolder = slash === -1 ? '' : appPath.slice(0, slash);

const res = await importTemplate({
sourcePath: target.path,
targetFolder,
deleteSource,
});

if (!res.ok) {
toast.error(t`Failed to import template`, { description: res.error });
clearBusyState();
return;
}

if (deleteSource) {
await applyDeleteAftermath([target], [target.path], []);
// Optimistically remove from view if deleted, standard watcher sweeps later
setDocuments((current) => {
const next = current.filter(
(entry) => !(isDocumentEntry(entry) && entry.docName === target.path),
);
resetModelToDocuments(next);
markNextDocumentsAsApplied(next);
return next;
});
emitDocumentsChanged(['files', 'backlinks', 'graph']);
}

toast.success(t`Template imported`, {
description: res.path,
});
clearBusyState();
}

function recoverMarkdownRenameConflict(message: string): boolean {
const bareDestinationPath = parseAlreadyExistsRenamePath(message);
if (!bareDestinationPath || markdownTreeExtension(bareDestinationPath)) return false;
Expand Down Expand Up @@ -4446,6 +4535,7 @@ export function FileTree({
startCreating('file', parentDir, { template: templateName })
}
onDuplicate={handleDuplicateTarget}
onImportTemplate={handleImportTemplate}
onDelete={(targets) => setDeleteRequest({ targets })}
onExpandSubtree={expandSubtree}
onCollapseSubtree={collapseSubtree}
Expand Down Expand Up @@ -4514,6 +4604,29 @@ export function FileTree({
/>
)}
</Dialog>
<Dialog
open={!!templateConvertRequest}
onOpenChange={(open) => {
if (!open && !busyPath) setTemplateConvertRequest(null);
}}
>
{templateConvertRequest && (
<DeleteConfirmationDialog
itemName={
templateConvertRequest.name +
(templateConvertRequest.kind === 'file'
? (templateConvertRequest.docExt ?? '.md')
: '.md')
}
customTitle={t`Convert to template`}
customDescription={t`Are you sure you want to convert this file into a template? The original file will be deleted. This action cannot be undone.`}
customConfirmLabel={t`Convert`}
customConfirmLabelBusy={t`Converting...`}
isSubmitting={busyPath !== null}
onDelete={() => executeImportTemplate(templateConvertRequest, true)}
/>
)}
</Dialog>
<Dialog
open={!!trashFailure}
onOpenChange={(open) => {
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) };
}
}
11 changes: 11 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 @@ -553,6 +554,7 @@
"BQu9Pg": ["Symlink"],
"BUyQnq": ["Couldn't restart the server. Try `ok start` in this folder."],
"BY-g4_": ["Add pattern"],
"Ba4D2x": ["Template imported"],
"Bdha5y": ["Publishing..."],
"BeWTQy": ["Loading starter packs"],
"BgV29F": ["Visual editor find input focused"],
Expand Down Expand Up @@ -1190,6 +1192,7 @@
"PR9FSv": ["Move to the next visual-editor find result."],
"PRnH8G": ["of ", ["0"]],
"PVUn_c": ["Open folder"],
"PYmp-0": ["Import as template"],
"PZtgO0": ["Opening worktree"],
"P_bX4O": ["How to set up"],
"Pck6eI": [
Expand Down Expand Up @@ -1260,6 +1263,9 @@
"RGCCrg": ["Copy share link"],
"RGkNeJ": ["Initialize a starter pack"],
"RJ3a8Z": ["Looking for <0>", ["lookingForUrl"], "</0>."],
"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</0>, save it to <1>~/Downloads</1>, and open the Claude Desktop App for you."
],
Expand Down Expand Up @@ -1804,6 +1810,7 @@
"dOHMA0": ["Failed to move"],
"dOxPd4": ["Footnote"],
"dPOFid": ["Show or hide terminal"],
"dQHSCs": ["Convert (delete original)"],
"dTaauv": ["Reason: ", ["reason"]],
"dUCQo4": ["Renamed to \"", ["trimmed"], "\""],
"dUQ7_J": [
Expand Down Expand Up @@ -1958,6 +1965,7 @@
"hHMv6l": ["Delete block"],
"hIQkLb": ["New chat"],
"hItdtk": ["Browse folder"],
"hLod4_": ["Convert to template"],
"hLy8b1": ["Updated to Version ", ["version"]],
"hQXiDc": ["Search branches..."],
"hSBIG-": ["A title, description, tags — anything that describes this folder."],
Expand All @@ -1979,6 +1987,7 @@
"hnq8fP": ["Remove ", ["label"], " from context"],
"hpDtUm": ["External wiki link"],
"hpzRlK": ["Reopen closed tab"],
"hvZwVx": ["Convert"],
"hwFOFD": ["Settings failed to load"],
"hxBHxQ": ["A line with a footnote<0><1>[1]</1></0> and a definition shown below."],
"hxEUVJ": ["Initialize ", ["selectedPackName"]],
Expand Down Expand Up @@ -2434,6 +2443,7 @@
"rD-woT": [
"Tell us what went wrong and we'll gather the logs. Nothing leaves your Mac until you've reviewed it."
],
"rDOuNw": ["Failed to import template"],
"rFmBG3": ["Color theme"],
"rFw02Q": ["Uploading ", ["keyName"]],
"rJVoVX": ["Source find results"],
Expand Down Expand Up @@ -2583,6 +2593,7 @@
]
],
"uksqrP": ["Repository not found. It may have been renamed, deleted, or moved."],
"ul1Atu": ["Converting..."],
"un8kBM": ["Creating page: ", ["linkTitle"], "."],
"ur2XSb": ["Visual editor"],
"uuoHOU": ["Command failed."],
Expand Down
Loading