0.102.12: Open Knowledge Format (OKF) support + folder-panel/templates polish

This commit is contained in:
Human 2026-06-14 00:35:19 -07:00
parent 8e772a22df
commit d2378ca5fc
16 changed files with 1025 additions and 137 deletions

113
docs/okf-guide.md Normal file
View file

@ -0,0 +1,113 @@
# OKF in Stashpad — a getting-started guide
This guide assumes you know **nothing** about OKF *or* Stashpad. By the end you'll
know what OKF is, why Stashpad is a natural way to produce it, and exactly how to
turn one of your folders into an OKF bundle and share it.
---
## What is OKF?
**OKF (Open Knowledge Format)** is Google's open, vendor-neutral spec for packaging
curated knowledge so that LLMs and AI agents can browse and use it reliably. An OKF
"bundle" is deliberately simple — just a folder of plain Markdown files:
- **one file per "concept"** (a table, a dataset, a metric, a runbook, a note…),
- each with a little **YAML frontmatter** at the top (`type` is required;
`title` / `description` / `tags` / `timestamp` are recommended),
- **cross-links between concepts are relative Markdown links** (e.g.
`[Onboarding](onboarding.md)`), not app-specific `[[wikilinks]]`,
- an **`index.md`** in the folder that lists the concepts,
- the **file path is the concept's identity**.
That's the whole idea: knowledge as a tidy, linkable folder of Markdown that any
tool — or any AI — can read without a proprietary importer.
Spec & background:
- https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
- https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing/
## What is Stashpad?
**Stashpad** is an Obsidian plugin that turns a folder into a chat-style outliner:
you jot notes, nest them under one another, and navigate the tree. Under the hood
every note is just a Markdown file with YAML frontmatter, and the parent/child
structure is recorded there too.
## Why Stashpad is a good fit for OKF
Look at the two descriptions above — they're almost the same shape:
| OKF wants… | Stashpad already has… |
|---|---|
| one Markdown file per concept | one Markdown file per note |
| YAML frontmatter on each | frontmatter on every note |
| a folder of related concepts | a Stashpad folder of related notes |
| relative-Markdown cross-links | a real parent/child hierarchy |
| an `index.md` per folder | a known folder structure to generate one from |
So an OKF bundle is basically "a Stashpad folder, written down in OKF's
conventions." Stashpad can therefore **generate the OKF layer for you** — the
required frontmatter keys, the relative-Markdown links, and the `index.md` — instead
of you hand-authoring any of it. And it does so **without disturbing your notes**:
the OKF fields are added *alongside* Stashpad's own (nothing is renamed or removed),
so the same folder stays a normal Stashpad folder AND becomes a valid OKF bundle.
## Get started in ~1 minute
1. **Turn OKF on.** Settings → **Open Knowledge Format (OKF)** → toggle **Enable OKF**.
This creates an `OKF Template.md` file in your vault.
2. **Pick which folders use OKF.** Click **Create template + open Templates**. In the
Templates list, set a folder's template to `OKF Template.md`. Do this for as many
(or as few) folders as you want — it's per-folder. (Archive folders are skipped on
purpose; OKF is about sharing, archives are about privacy.)
3. **Build the OKF layer.** Back on the OKF page, click **Rebuild OKF frontmatter**.
For every OKF folder this writes the OKF fields onto each note and generates the
folder's `index.md`.
4. **Share it.** Right-click a note (or a selection) → **Export as OKF…** and tick the
format(s) you want: **.zip** or **.tar.gz** (portable OKF bundles) and/or **.stash**
(Stashpad's own re-importable format). The export lands in the folder's `_exports`.
That's it. Hand the `.zip`/`.tar.gz` to a colleague, an agent, or a data tool — it's
standard OKF.
## What Stashpad writes for you
On a note in an OKF folder, after a Rebuild:
- `okfType` — the concept type (defaults to `concept`; **edit this freely** per note).
- `okfTitle`, `okfTimestamp` — filled in if missing (yours to edit after).
- `okfParent`, `okfChildren` — relative-Markdown links to the parent/child notes
(Stashpad keeps these in sync; they mirror your tree).
And per folder: an **`index.md`** (`type: index`) with a nested list of the concepts
plus a short **legend** explaining the field names — so even an AI reading the *live*
folder knows how Stashpad's `okf*` fields map to OKF's standard keys.
> Why the `okf` prefix? So these never collide with your own `tags`/`title`/`type`
> frontmatter or other plugins'. On **export**, Stashpad maps them to OKF's bare spec
> keys (`type`/`title`/`description`/`tags`/`timestamp`) in the bundle, while keeping
> the `okf*` + `id` fields too so the bundle can be re-imported into Stashpad
> losslessly.
## Good to know
- **Complementary, never destructive.** Your existing Stashpad notes and links are
untouched; OKF fields are added on top.
- **Auto-updates, but not instantly.** When you add, move, or delete notes in an OKF
folder, Stashpad refreshes that folder's OKF fields + `index.md` automatically — a
few seconds *after you stop* (it's debounced, so a burst of changes becomes one
rebuild). A brand-new note gets `okfType` right away; its `okfParent`/`okfChildren`
and the folder's `index.md` catch up on that refresh. Want it immediately? Click
**Rebuild OKF frontmatter** in Settings → OKF.
- **Archive folders are excluded** from all OKF processing by design.
- **Export scope.** Exporting a note/selection bundles that subtree, and the bundle's
`index.md` reflects just what you exported.
## Glossary
- **Concept** — one OKF Markdown file (= one Stashpad note).
- **Bundle** — a folder of concepts + `index.md`, optionally zipped/tarred.
- **`index.md`** — the per-folder table of contents OKF expects (Stashpad generates it).
- **Rebuild** — Stashpad's pass that (re)writes the OKF fields + `index.md`.
</content>

173
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.100.0",
"version": "0.102.12",
"minAppVersion": "1.7.0",
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
"author": "Human",

View file

@ -1,6 +1,6 @@
{
"name": "stashpad-obsidian",
"version": "0.100.0",
"version": "0.102.12",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

51
release-notes/0.102.12.md Normal file
View file

@ -0,0 +1,51 @@
# 0.102.12 — Open Knowledge Format (OKF) support
Turn a Stashpad folder into a browsable **Open Knowledge Format** bundle — the open,
vendor-neutral way to share curated knowledge with LLMs and agents — without
hand-authoring any of it. See `docs/okf-guide.md` for a from-scratch walkthrough.
## OKF (new)
- **New "Open Knowledge Format (OKF)" settings tab.** Flip it on, and a folder you
opt in becomes a valid OKF bundle while staying a normal Stashpad folder —
nothing of yours is renamed or removed.
- **Opt in per folder via a template.** Enabling OKF creates an `OKF Template.md`;
assign it to the folders you want (all, some, or none) in Settings → Templates.
A one-click "Create template + open Templates" gets you there.
- **Complementary frontmatter.** OKF folders get `okfType` / `okfTitle` /
`okfTimestamp` (yours to edit) plus `okfParent` / `okfChildren` as relative-
Markdown links that mirror your note tree — added alongside Stashpad's own fields.
- **Generated `index.md`** per OKF folder (an OKF table-of-contents) with a legend
explaining the fields.
- **Export as OKF.** Right-click a note (or selection) → "Export as OKF…" and pick
any of **.zip** / **.tar.gz** (portable OKF bundles) or **.stash** (re-importable).
Bundles map the `okf*` fields to OKF's standard keys, keep the originals for
lossless re-import, include a scope-adjusted `index.md`, and bundle attachments.
- **Stays in sync automatically.** Adding, moving, or deleting notes refreshes the
folder's OKF fields + `index.md` a few seconds later (a manual "Rebuild" button
forces it immediately). Archive folders are always excluded.
## Folder panel
- **Pinned folders now appear in the top "Pinned" section**, mixed with pinned
notes, so notes and folders share one quick-access area. The folder list below is
unchanged (pinned folders still rank first there).
## Templates
- The per-folder template field's autocomplete is now **keyboard-driven**: ↑/↓ to
move through suggestions, Enter to pick, Esc to close, and Tab to complete the
path one segment at a time.
## Encryption
- Added a clear, prominent disclaimer in the Encryption settings: the encryption
feature is **AI-built and not human-audited or security-tested** — a best-effort
nice-to-have, not a guarantee. Don't rely on it for anything sensitive, and keep
your own unencrypted backups. (Still beta.)
## Polish
- Settings text alignment fixes across several sections, monospaced code references
in the settings copy, and clearer setup guidance.
</content>

View file

@ -23,15 +23,20 @@ import { fileURLToPath } from "node:url";
const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
const ARTIFACTS = ["main.js", "manifest.json", "styles.css"];
function resolveTarget() {
function resolveTargets() {
// Multiple targets supported: STASHPAD_DEPLOY or `.deploy-target` may list ONE
// PATH PER LINE (blank lines + `#` comments ignored). EVERY target is updated on
// each deploy — keep the Plugin Test vault and the Claude Dev Vault in sync so
// live testing never runs a stale build.
const fromText = (raw) => raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => resolve(l));
const envTarget = process.env.STASHPAD_DEPLOY?.trim();
if (envTarget) return resolve(envTarget);
if (envTarget) return fromText(envTarget);
const cfgPath = join(ROOT, ".deploy-target");
if (existsSync(cfgPath)) {
const raw = readFileSync(cfgPath, "utf8").trim();
if (raw) return resolve(raw);
const targets = fromText(readFileSync(cfgPath, "utf8"));
if (targets.length) return targets;
}
return null;
return [];
}
function fail(msg) {
@ -39,41 +44,39 @@ function fail(msg) {
process.exit(1);
}
const target = resolveTarget();
if (!target) {
const targets = resolveTargets();
if (targets.length === 0) {
fail(
"No deploy target configured. Set STASHPAD_DEPLOY env var or create a\n" +
".deploy-target file at the project root with the destination path.\n" +
".deploy-target file at the project root with one destination path per line.\n" +
"Example: /Users/you/MyVault/.obsidian/plugins/stashpad",
);
}
// Sanity-check: refuse to write to a path that doesn't look like a
// plugin folder (avoids accidental misconfigurations clobbering things).
const targetParent = dirname(target);
if (!existsSync(target)) {
try {
mkdirSync(target, { recursive: true });
} catch (e) {
fail(`Couldn't create destination folder: ${target}\n${e.message}`);
let totalCopied = 0;
const allMissing = new Set();
for (const target of targets) {
const targetParent = dirname(target);
if (!existsSync(target)) {
try { mkdirSync(target, { recursive: true }); }
catch (e) { fail(`Couldn't create destination folder: ${target}\n${e.message}`); }
}
}
if (!existsSync(targetParent)) {
fail(`Parent of destination doesn't exist: ${targetParent}`);
}
if (!existsSync(targetParent)) fail(`Parent of destination doesn't exist: ${targetParent}`);
let copied = 0;
let missing = [];
for (const name of ARTIFACTS) {
const src = join(ROOT, name);
if (!existsSync(src)) { missing.push(name); continue; }
const dst = join(target, name);
copyFileSync(src, dst);
const sz = statSync(dst).size;
console.log(`[deploy] ${name.padEnd(14)}${dst} (${sz} bytes)`);
copied++;
let copied = 0;
for (const name of ARTIFACTS) {
const src = join(ROOT, name);
if (!existsSync(src)) { allMissing.add(name); continue; }
const dst = join(target, name);
copyFileSync(src, dst);
const sz = statSync(dst).size;
console.log(`[deploy] ${name.padEnd(14)}${dst} (${sz} bytes)`);
copied++;
}
console.log(`[deploy] copied ${copied}/${ARTIFACTS.length}${target}`);
totalCopied += copied;
}
if (missing.length) {
console.warn(`[deploy] WARNING: missing artifacts: ${missing.join(", ")} — did you build?`);
if (allMissing.size) {
console.warn(`[deploy] WARNING: missing artifacts: ${[...allMissing].join(", ")} — did you build?`);
}
console.log(`[deploy] copied ${copied}/${ARTIFACTS.length}${target}`);
console.log(`[deploy] done — ${targets.length} target${targets.length === 1 ? "" : "s"}, ${totalCopied} file copies`);

View file

@ -3,7 +3,7 @@ import { ROOT_ID, type StashpadId, type TreeNode } from "../types";
import { buildStashZip, importStashZip, STASH_EXT } from "../stash-package";
import { argon2Available, encryptStash, resolveStashBytes, STASH_KDF_INFO } from "../stash-crypto";
import { secretIdForStashName } from "../passphrase";
import { ExportStashModal } from "../modals";
import { ExportStashModal, OkfExportModal } from "../modals";
import type { StashpadView } from "../view";
/** .stash import/export command group extracted from StashpadView
@ -33,6 +33,30 @@ export async function cmdExportStash(view: StashpadView, rootNode?: TreeNode): P
}, argon2Available).open();
}
/** Export the selection/cursor subtree as an OKF bundle (.zip / .tar.gz) and/or a
* Stashpad .stash, via plugin.exportOkf. */
export async function cmdExportOkf(view: StashpadView, rootNode?: TreeNode): Promise<void> {
const roots = collectExportRoots(view, rootNode);
if (roots.length === 0) { new Notice("Nothing to export."); return; }
const all = collectExportSubtree(view, roots);
if (all.length === 0) { new Notice("No exportable notes."); return; }
const folderTag = (view.noteFolder.split("/").pop() || view.noteFolder).trim();
const defaultBase = roots.length === 1 ? view.titleForNode(roots[0]) : `${folderTag}-okf`;
new OkfExportModal(view.app, defaultBase, all.length, (base, formats) => {
void (async () => {
try {
const written = await view.plugin.exportOkf(view.noteFolder, roots.map((r) => r.id), base, formats);
if (!written.length) { new Notice("Nothing exported."); return; }
view.plugin.notifications.show({
message: `Exported OKF — ${written.length} file${written.length === 1 ? "" : "s"}\`${view.noteFolder}/${(view.plugin.settings.exportFolder || "_exports")}\``,
kind: "success", category: "export", affectedPaths: written, folder: view.noteFolder, duration: 0,
});
await view.log.append({ type: "stash_export", id: roots[0].id, payload: { okf: true, paths: written, noteCount: all.length, rootIds: roots.map((r) => r.id) } });
} catch (e) { new Notice(`OKF export failed: ${(e as Error).message}`); console.error(e); }
})();
}).open();
}
/** Build + write the .stash with the chosen base name, optionally encrypted,
* then notify. */
async function runExport(view: StashpadView, roots: TreeNode[], all: TreeNode[], baseName: string, password: string | null, remember = false): Promise<void> {

View file

@ -137,11 +137,20 @@ export class StashpadFolderPanelView extends ItemView {
}
private renderPinned(list: HTMLElement): void {
// 0.102.x: pinned FOLDERS mix into the top "Pinned" section (above the pinned
// notes), using the same folder rows as the list below. The bottom Folders
// subsection is unchanged — pinned folders still appear there ranked-first.
const pinnedFolders = this.plugin.discoverStashpadFolders().filter((f) => this.folderState(f) === "pinned");
const pins = this.plugin.listPinnedNotes();
if (pins.length === 0) {
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "No pinned notes yet — pin a note from its right-click menu." });
if (pinnedFolders.length === 0 && pins.length === 0) {
list.createDiv({ cls: "stashpad-folderpanel-empty", text: "Nothing pinned yet — pin a note or folder from its right-click menu." });
return;
}
if (pinnedFolders.length > 0) {
const open = this.openFolders();
for (const folder of pinnedFolders) this.renderFolderRow(list, folder, open);
}
if (pins.length === 0) return;
const grouping = this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order";
if (grouping === "folder") {
// Group by Stashpad, MRU folder floated to the top (mirrors the Pinned panel).

View file

@ -16,6 +16,8 @@ import {
import { DEFAULT_STOPWORDS, bodyToSlug, buildFilename, parseIdFromFilename } from "./slug-service";
import { getActiveView, onActiveViewChange } from "./active-view";
import { importStashZip, buildStashZip, resolveNoteAttachmentFiles, STASH_EXT, splitFrontmatter } from "./stash-package";
import { ensureOkfTemplate, okfFolders, rebuildOkfForFolder, OKF_DEFAULT_TEMPLATE_PATH } from "./okf";
import { buildOkfBundleFiles, zipBundle, tarGzBundle } from "./okf-export";
import { formatDateTime } from "./format";
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
import { StashpadLog } from "./log";
@ -699,6 +701,14 @@ export default class StashpadPlugin extends Plugin {
// last readable plaintext copy, sitting in render-cache.json.
this.registerEvent(this.app.vault.on("delete", (f) => this.renderCacheStore.evict(f.path)));
this.registerEvent(this.app.vault.on("rename", (_f, oldPath) => this.renderCacheStore.evict(oldPath)));
// 0.102.x: OKF auto-rebuild — when a note is added/deleted/moved in an OKF
// folder, refresh that folder's OKF frontmatter + index.md (debounced). Gated
// through okfActiveFolders so it never runs when OKF is off / for non-OKF /
// archive folders. Frontmatter writes are "modify" events (not listened here),
// so this can't loop on its own work; index.md is ignored explicitly.
this.registerEvent(this.app.vault.on("create", (f) => this.onOkfFileEvent(f.path)));
this.registerEvent(this.app.vault.on("delete", (f) => this.onOkfFileEvent(f.path)));
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => { this.onOkfFileEvent(f.path); this.onOkfFileEvent(oldPath); }));
// 0.77.1: load the author registry and seed it with the local user.
await this.authorRegistry.load();
{
@ -1348,6 +1358,11 @@ export default class StashpadPlugin extends Plugin {
name: "Export selection to .stash",
callback: () => call("cmdExportStash"),
});
this.addCommand({
id: "stashpad-export-okf",
name: "Export selection as OKF bundle (.zip / .tar.gz / .stash)",
callback: () => call("cmdExportOkf"),
});
this.addCommand({
id: "stashpad-import-stash",
name: "Import .stash file…",
@ -2316,6 +2331,7 @@ export default class StashpadPlugin extends Plugin {
let fmWritten = 0;
let slugsRenamed = 0;
let imported = 0;
const okfSet = new Set(this.okfActiveFolders());
for (const folder of seen) {
try {
if (importSub) await ensureFolder(`${folder}/${importSub}`);
@ -2347,6 +2363,13 @@ export default class StashpadPlugin extends Plugin {
// landed (and any whose body was edited without the per-view
// scheduleSlugRename firing).
slugsRenamed += await this.rebootstrapFolderSlugs(folder);
// 0.102.x: rebootstrap also fixes OKF frontmatter + regenerates index.md
// for OKF-enabled folders (those using the OKF template). The OKF-section
// "Rebuild" button is just a scoped shortcut to this same pass — not an
// alias for the whole rebootstrap.
if (okfSet.has(folder.replace(/\/+$/, ""))) {
try { await rebuildOkfForFolder(this.app, folder); } catch (e) { console.warn("Stashpad: OKF rebuild during rebootstrap failed", folder, e); }
}
touched.push(folder);
} catch (e) {
console.warn(`Stashpad: rebootstrap skipped ${folder}`, e);
@ -3318,6 +3341,111 @@ export default class StashpadPlugin extends Plugin {
return (this.settings.archiveFolders ?? []).includes(cleaned);
}
/** Ensure the OKF template note exists and remember its path (called when OKF
* is enabled). */
async ensureOkfTemplate(): Promise<string> {
const path = await ensureOkfTemplate(this.app, this.settings.okfTemplatePath || undefined);
if (this.settings.okfTemplatePath !== path) { this.settings.okfTemplatePath = path; await this.saveSettings(); }
return path;
}
/** The OKF template path, defaulting to the standard name when the setting is
* empty (e.g. OKF was enabled in an older build before create-on-enable). */
okfTemplatePathOrDefault(): string {
return this.settings.okfTemplatePath || OKF_DEFAULT_TEMPLATE_PATH;
}
/** Folders the OKF process should ACTUALLY touch: only when OKF is on, only
* folders assigned the OKF template, and NEVER archive folders (their whole
* point is private-at-rest OKF would make them browsable/exportable). This
* is the single guard every OKF run goes through, so OKF-off / no-folders /
* excluded-folders can never accidentally trigger the process. */
okfActiveFolders(): string[] {
if (!this.settings.okfEnabled) return [];
return okfFolders(this.settings.noteTemplates, this.okfTemplatePathOrDefault())
.filter((f) => !this.isArchiveFolder(f));
}
/** Per-folder debounce timers for OKF auto-rebuild. */
private okfRebuildTimers = new Map<string, number>();
/** A vault file changed (create/delete/rename) if it's a real note in an
* active OKF folder, schedule a debounced rebuild of that folder. Ignores
* index.md (our own generated artifact, to avoid a writeeventrebuild loop)
* and reserved subfolders. */
private onOkfFileEvent(path: string): void {
if (!this.settings.okfEnabled) return;
if (!path.toLowerCase().endsWith(".md")) return;
const slash = path.lastIndexOf("/");
const folder = (slash >= 0 ? path.slice(0, slash) : "").replace(/\/+$/, "");
const name = slash >= 0 ? path.slice(slash + 1) : path;
if (name === "index.md") return;
if (/(^|\/)(_imports|_exports|_attachments|_deleted|\.stashpad)(\/|$)/.test(path)) return;
if (!this.okfActiveFolders().includes(folder)) return;
this.scheduleOkfRebuild(folder);
}
/** Debounced per-folder OKF rebuild (coalesces bursts like imports/resets). */
private scheduleOkfRebuild(folder: string): void {
const prev = this.okfRebuildTimers.get(folder);
if (prev != null) window.clearTimeout(prev);
this.okfRebuildTimers.set(folder, window.setTimeout(() => {
this.okfRebuildTimers.delete(folder);
if (!this.okfActiveFolders().includes(folder)) return; // re-check at fire time
void rebuildOkfForFolder(this.app, folder).catch((e) => console.warn("[Stashpad] OKF auto-rebuild failed", folder, e));
}, 2500));
}
/** Rebuild OKF frontmatter (relative-markdown links + defaults) + index.md for
* every active OKF folder. No-op when OKF is off / no folders use the template. */
async rebuildAllOkf(): Promise<{ folders: number; checked: number; written: number }> {
const folders = this.okfActiveFolders();
let checked = 0, written = 0;
for (const f of folders) { const r = await rebuildOkfForFolder(this.app, f); checked += r.checked; written += r.written; }
return { folders: folders.length, checked, written };
}
/** Export the subtree(s) rooted at `rootIds` in `folder` as OKF bundle(s) and/or
* a Stashpad .stash, written into the folder's export subfolder. Returns the
* paths written. zip/tar.gz are OKF bundles (spec keys mapped, scoped index.md);
* .stash is the native round-trip format. Reachable for tests + the command. */
async exportOkf(folder: string, rootIds: StashpadId[], baseName: string, formats: { zip?: boolean; targz?: boolean; stash?: boolean }): Promise<string[]> {
const cleaned = folder.replace(/\/+$/, "");
const rootNotes: { id: StashpadId; file: TFile }[] = [];
const allDescendants: { id: StashpadId; file: TFile }[] = [];
const files: TFile[] = [];
const scopeIds = new Set<string>();
for (const rid of rootIds) {
const sub = await collectSubtree(this.app, cleaned, rid);
if (!sub) continue;
rootNotes.push({ id: sub.rootNote.id, file: sub.rootNote.file });
files.push(sub.rootNote.file); scopeIds.add(sub.rootNote.id);
for (const d of sub.descendants) { allDescendants.push({ id: d.id, file: d.file }); files.push(d.file); scopeIds.add(d.id); }
}
if (!files.length) return [];
const safe = (baseName || "okf-export").replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 80) || "okf-export";
const stamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
const exportSub = (this.settings.exportFolder || "_exports").trim().replace(/^\/+|\/+$/g, "");
const exportFolder = `${cleaned}/${exportSub}`;
for (const seg of [cleaned, exportFolder]) { try { if (!(await this.app.vault.adapter.exists(seg))) await this.app.vault.adapter.mkdir(seg); } catch { /* */ } }
const written: string[] = [];
const write = async (name: string, data: Uint8Array) => {
const path = `${exportFolder}/${name}`;
await this.app.vault.createBinary(path, data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);
written.push(path);
};
if (formats.zip || formats.targz) {
const bundle = await buildOkfBundleFiles(this.app, files, cleaned, scopeIds);
if (formats.zip) await write(`${safe}-${stamp}.okf.zip`, await zipBundle(bundle));
if (formats.targz) await write(`${safe}-${stamp}.okf.tar.gz`, await tarGzBundle(bundle));
}
if (formats.stash) {
const buf = await buildStashZip(this.app, { rootNotes, allDescendants, sourceFolder: cleaned });
await write(`${safe}-${stamp}.${STASH_EXT}`, buf);
}
return written;
}
/** Ids of the markdown notes living directly in `folder` (read from DISK the
* metadata cache can lag and an under-read here would miss a real id collision
* on import). */

View file

@ -2068,3 +2068,39 @@ export class ImportDupChoiceModal extends Modal {
}
onClose(): void { if (!this.chose) this.onChoose("skip"); this.contentEl.empty(); }
}
/** OKF export: pick a name + one or more container formats. Delegates the actual
* bundle build to plugin.exportOkf (see okf-export.ts). */
export class OkfExportModal extends Modal {
private base: string;
constructor(app: App, defaultBase: string, private noteCount: number, private onConfirm: (base: string, formats: { zip: boolean; targz: boolean; stash: boolean }) => void) {
super(app); this.base = defaultBase;
}
onOpen(): void {
this.contentEl.empty();
this.modalEl.addClass("stashpad-export-modal");
this.titleEl.setText("Export as OKF");
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: `Export ${this.noteCount} note${this.noteCount === 1 ? "" : "s"} as an Open Knowledge Format bundle. Pick one or more formats.` });
const name = this.contentEl.createEl("input", { type: "text" }) as HTMLInputElement;
name.addClass("stashpad-export-name"); name.value = this.base; name.placeholder = "Export name";
const mk = (label: string, checked: boolean): HTMLInputElement => {
const row = this.contentEl.createDiv({ cls: "stashpad-okf-fmt" });
const cb = row.createEl("input", { type: "checkbox" }) as HTMLInputElement; cb.checked = checked;
row.createEl("label", { text: label });
return cb;
};
const zip = mk(".zip — OKF bundle (portable)", true);
const targz = mk(".tar.gz — OKF bundle (tarball)", false);
const stash = mk(".stash — Stashpad format (re-importable)", false);
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
const go = footer.createEl("button", { cls: "mod-cta", text: "Export" });
go.onclick = () => {
if (!zip.checked && !targz.checked && !stash.checked) { new Notice("Pick at least one format."); return; }
this.close();
this.onConfirm(name.value.trim() || this.base, { zip: zip.checked, targz: targz.checked, stash: stash.checked });
};
requestAnimationFrame(() => name.focus());
}
onClose(): void { this.contentEl.empty(); }
}

121
src/okf-export.ts Normal file
View file

@ -0,0 +1,121 @@
import type { App, TFile } from "obsidian";
import { splitFrontmatter } from "./stash-package";
import { okfTitleFromFile, buildOkfIndex, OKF_LEGEND } from "./okf";
/** OKF export (Phase 4): turn an exported subtree into an OKF BUNDLE concept
* markdown files whose frontmatter carries the bare OKF spec keys
* (`type`/`title`/`description`/`tags`/`timestamp`) mapped from our `okf*` fields,
* while KEEPING `id`/`okf*` redundantly so the bundle re-imports into Stashpad
* losslessly plus a scope-adjusted `index.md`, a `_okf.md` legend, and the
* referenced attachments. Packaged as .zip and/or .tar.gz (no new dependency:
* JSZip for zip, a tiny tar writer + the platform `CompressionStream` for
* .tar.gz). The Stashpad-native `.stash` remains a separate option. */
export interface BundleFile { name: string; data: Uint8Array; }
const te = new TextEncoder();
const enc = (s: string): Uint8Array => te.encode(s);
/** Minimal YAML scalar: quote when it has YAML-significant chars (the okf link
* values contain []() etc.). */
function yamlScalar(v: unknown): string {
if (typeof v === "number" || typeof v === "boolean") return String(v);
const s = String(v ?? "");
return /[:#[\]{}",&*!|>%@`]|^\s|\s$|^$/.test(s) ? JSON.stringify(s) : s;
}
/** Inject the OKF spec keys at the TOP of a note's frontmatter, mapped from its
* `okf*` fields, keeping the rest of the frontmatter verbatim (so id/parent/okf*
* survive for a lossless Stashpad re-import). */
function toConceptMarkdown(raw: string, file: TFile): string {
const { fm } = splitFrontmatter(raw);
const lines: string[] = [];
lines.push(`type: ${yamlScalar(typeof fm.okfType === "string" && fm.okfType ? fm.okfType : "concept")}`);
lines.push(`title: ${yamlScalar(typeof fm.okfTitle === "string" && fm.okfTitle ? fm.okfTitle : okfTitleFromFile(file))}`);
if (typeof fm.okfDescription === "string" && fm.okfDescription) lines.push(`description: ${yamlScalar(fm.okfDescription)}`);
const tags = fm.okfTags;
if (Array.isArray(tags) && tags.length) { lines.push("tags:"); for (const t of tags) lines.push(` - ${yamlScalar(t)}`); }
const ts = (typeof fm.okfTimestamp === "string" && fm.okfTimestamp) || (typeof fm.modified === "string" && fm.modified) || (typeof fm.created === "string" && fm.created) || "";
if (ts) lines.push(`timestamp: ${yamlScalar(ts)}`);
const block = lines.join("\n");
const m = raw.match(/^---\r?\n/);
if (m) return raw.slice(0, m[0].length) + block + "\n" + raw.slice(m[0].length);
return `---\n${block}\n---\n${raw}`; // note had no frontmatter
}
/** Build the OKF bundle's files for an exported subtree (within `folder`). */
export async function buildOkfBundleFiles(
app: App, files: TFile[], folder: string, scopeIds: Set<string>,
): Promise<BundleFile[]> {
const out: BundleFile[] = [];
const attachments = new Map<string, TFile>(); // basename -> file (dedup)
for (const f of files) {
const raw = await app.vault.read(f);
out.push({ name: f.name, data: enc(toConceptMarkdown(raw, f)) });
// Collect referenced attachments (resolved against this note's path).
for (const ref of raw.match(/!\[\[([^\]]+?)\]\]/g) ?? []) {
const inner = ref.slice(3, -2).split("|")[0].split("#")[0].trim();
const af = app.metadataCache.getFirstLinkpathDest(inner, f.path);
if (af && !attachments.has(af.name)) attachments.set(af.name, af);
}
}
for (const [name, af] of attachments) {
out.push({ name: `_attachments/${name}`, data: new Uint8Array(await app.vault.readBinary(af)) });
}
out.push({ name: "index.md", data: enc(await buildOkfIndex(app, folder, scopeIds)) });
out.push({ name: "_okf.md", data: enc(`# About this bundle\n\n${OKF_LEGEND.replace(/^> /gm, "")}\n`) });
return out;
}
/** Zip the bundle (JSZip, already a dependency). */
export async function zipBundle(files: BundleFile[]): Promise<Uint8Array> {
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
for (const f of files) zip.file(f.name, f.data);
return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
}
// ---- minimal tar (ustar) + gzip, no dependency ----
function octal(n: number, len: number): string {
return n.toString(8).padStart(len - 1, "0") + "\0";
}
function tarHeader(name: string, size: number): Uint8Array {
const h = new Uint8Array(512);
const put = (s: string, off: number, len: number) => { const b = enc(s); h.set(b.subarray(0, len), off); };
put(name.slice(0, 100), 0, 100);
put(octal(0o644, 8), 100, 8); // mode
put(octal(0, 8), 108, 8); // uid
put(octal(0, 8), 116, 8); // gid
put(octal(size, 12), 124, 12); // size
put(octal(0, 12), 136, 12); // mtime (0 — deterministic; Date.* is unavailable here anyway)
put(" ", 148, 8); // checksum placeholder (spaces)
h[156] = 0x30; // typeflag '0' (normal file)
put("ustar\0", 257, 6); put("00", 263, 2);
let sum = 0; for (let i = 0; i < 512; i++) sum += h[i];
put(octal(sum, 8), 148, 8);
return h;
}
function buildTar(files: BundleFile[]): Uint8Array {
const parts: Uint8Array[] = [];
let total = 0;
for (const f of files) {
const header = tarHeader(f.name, f.data.length);
parts.push(header, f.data);
total += 512 + f.data.length;
const pad = (512 - (f.data.length % 512)) % 512;
if (pad) { parts.push(new Uint8Array(pad)); total += pad; }
}
parts.push(new Uint8Array(1024)); total += 1024; // two zero blocks = EOF
const tar = new Uint8Array(total);
let off = 0; for (const p of parts) { tar.set(p, off); off += p.length; }
return tar;
}
async function gzip(data: Uint8Array): Promise<Uint8Array> {
const cs = new CompressionStream("gzip");
const stream = new Response(data as unknown as ArrayBuffer).body!.pipeThrough(cs);
return new Uint8Array(await new Response(stream).arrayBuffer());
}
/** Tar + gzip the bundle (no dependency). */
export async function tarGzBundle(files: BundleFile[]): Promise<Uint8Array> {
return gzip(buildTar(files));
}

157
src/okf.ts Normal file
View file

@ -0,0 +1,157 @@
import type { App, TFile } from "obsidian";
import { ROOT_ID } from "./types";
import { splitFrontmatter } from "./stash-package";
/** OKF (Open Knowledge Format) Phase 2: the complementary frontmatter model +
* the template + the per-folder rebuild. See docs/branches/okf.md.
*
* Design: COMPLEMENTARY, never subtractive. Stashpad keeps its id-based
* parent/parentLink/children untouched; OKF adds its own namespaced fields so a
* folder is browsable as OKF AND still a normal Stashpad folder:
* - `okfParent` / `okfChildren` RELATIVE MARKDOWN links (OKF's cross-link
* style), Stashpad-managed (reserved; rebuilt here).
* - `okfType` / `okfTitle` / `okfTimestamp` user-editable; filled with
* defaults only when missing. On EXPORT these map to OKF's bare spec keys
* (`type`/`title`/`timestamp`) that mapping is Phase 4. */
export const OKF_DEFAULT_TEMPLATE_PATH = "OKF Template.md";
/** Seed template: `okfType` is a user-editable default; {{body}} is where the
* composed note body lands. okfParent/okfChildren/okfTitle/okfTimestamp are
* filled by the rebuild, not the template (they're per-note/derived). */
export const OKF_TEMPLATE_BODY = "---\nokfType: concept\n---\n{{body}}\n";
/** Field legend embedded in each generated index.md so an LLM/agent reading the
* LIVE vault knows how Stashpad's namespaced fields map to OKF (we keep `okf*`
* live to avoid colliding with Obsidian's own `tags`/`title`; the bare spec keys
* are emitted on export). */
export const OKF_LEGEND = [
"> Generated by Stashpad — this folder is an Open Knowledge Format (OKF) bundle.",
"> Field legend: `okfType`→`type`, `okfTitle`→`title`, `okfDescription`→`description`, `okfTags`→`tags`, `okfTimestamp`→`timestamp`; `okfParent`/`okfChildren` are relative-markdown cross-links. Stashpad's own `id`/`parent` are extensions — ignore them for OKF.",
].join("\n");
/** Human/LLM-readable label for an OKF link (drop the -<id> filename suffix). */
export function okfTitleFromFile(file: TFile): string {
return file.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ").trim() || file.basename;
}
/** A relative markdown link to a sibling note (Stashpad folders are flat, so the
* target is just the filename). Spaces %-encoded so the link is valid. */
function okfLink(file: TFile): string {
return `[${okfTitleFromFile(file)}](${encodeURI(file.name)})`;
}
/** Create the OKF template note if it's missing; returns its vault path. */
export async function ensureOkfTemplate(app: App, path = OKF_DEFAULT_TEMPLATE_PATH): Promise<string> {
try {
if (!(await app.vault.adapter.exists(path))) await app.vault.create(path, OKF_TEMPLATE_BODY);
} catch { /* raced / already exists — fine */ }
return path;
}
/** Folders that "use OKF" = folders whose assigned note template IS the OKF
* template (template-driven enablement, per the user's design). */
export function okfFolders(noteTemplates: Record<string, string> | undefined, templatePath: string): string[] {
if (!templatePath) return [];
const tp = templatePath.replace(/^\/+/, "");
return Object.entries(noteTemplates ?? {})
.filter(([, v]) => (v ?? "").replace(/^\/+/, "") === tp)
.map(([k]) => k.replace(/\/+$/, ""));
}
/** Write/refresh OKF frontmatter for every note directly in `folder`. Managed
* link fields (okfParent/okfChildren) are always reconciled; okfType/okfTitle/
* okfTimestamp are only filled when absent (user owns them after). Reads from
* DISK (cache lags after edits), skip-if-equal, paced writes. */
export async function rebuildOkfForFolder(app: App, folder: string): Promise<{ checked: number; written: number }> {
const cleaned = folder.replace(/\/+$/, "");
type E = { file: TFile; id: string; parent: string; modified: string; created: string };
const byId = new Map<string, E>();
const childrenByParent = new Map<string, string[]>();
for (const f of app.vault.getMarkdownFiles()) {
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
let fm: Record<string, unknown>;
try { fm = splitFrontmatter(await app.vault.read(f)).fm; } catch { continue; }
const id = typeof fm.id === "string" ? fm.id : null;
if (!id) continue;
const parent = typeof fm.parent === "string" ? fm.parent : ROOT_ID;
byId.set(id, { file: f, id, parent, modified: typeof fm.modified === "string" ? fm.modified : "", created: typeof fm.created === "string" ? fm.created : "" });
const arr = childrenByParent.get(parent) ?? []; arr.push(id); childrenByParent.set(parent, arr);
}
let checked = 0, written = 0;
for (const e of byId.values()) {
if (e.id === ROOT_ID) continue; // the home note is represented by index.md (Phase 3)
checked += 1;
const parentLink = (e.parent && e.parent !== ROOT_ID && byId.get(e.parent)) ? okfLink(byId.get(e.parent)!.file) : null;
const childLinks = (childrenByParent.get(e.id) ?? []).map((cid) => byId.get(cid)).filter((x): x is E => !!x).map((x) => okfLink(x.file));
try {
let changed = false;
await app.fileManager.processFrontMatter(e.file, (m) => {
// Managed relative-markdown links.
if (parentLink) { if (m.okfParent !== parentLink) { m.okfParent = parentLink; changed = true; } }
else if (m.okfParent !== undefined) { delete m.okfParent; changed = true; }
const cur = Array.isArray(m.okfChildren) ? (m.okfChildren as unknown[]) : [];
const eq = cur.length === childLinks.length && cur.every((v, i) => v === childLinks[i]);
if (childLinks.length) { if (!eq) { m.okfChildren = childLinks; changed = true; } }
else if (m.okfChildren !== undefined) { delete m.okfChildren; changed = true; }
// User-editable defaults — only fill when missing.
if (m.okfType === undefined) { m.okfType = "concept"; changed = true; }
if (m.okfTitle === undefined) { m.okfTitle = okfTitleFromFile(e.file); changed = true; }
if (m.okfTimestamp === undefined) { const ts = e.modified || e.created; if (ts) { m.okfTimestamp = ts; changed = true; } }
});
if (changed) { written += 1; await new Promise((r) => setTimeout(r, 50)); }
} catch (err) { console.warn("[Stashpad] OKF rebuild failed", e.file.path, err); }
}
// Regenerate the folder's index.md (OKF requires a per-dir index).
try { await app.vault.adapter.write(`${cleaned}/index.md`, await buildOkfIndex(app, cleaned)); }
catch (err) { console.warn("[Stashpad] OKF index.md write failed", cleaned, err); }
return { checked, written };
}
/** Build the OKF `index.md` for a folder: a `type: index` concept that lists every
* note (nested by hierarchy) with relative-markdown links, plus the field legend.
* `scopeIds` (optional) limits it to an exported subtree index reflects the
* EXPORT scope, not the whole folder. NOTE: never reads/writes `.claude/INDEX.md`
* (that's Claude's code map) this is a lowercase `index.md` in vault content. */
export async function buildOkfIndex(app: App, folder: string, scopeIds?: Set<string>): Promise<string> {
const cleaned = folder.replace(/\/+$/, "");
type N = { id: string; file: TFile; parent: string; title: string; type: string; position: number; created: string };
const byId = new Map<string, N>();
const kids = new Map<string, N[]>();
for (const f of app.vault.getMarkdownFiles()) {
if ((f.parent?.path?.replace(/\/+$/, "") ?? "") !== cleaned) continue;
if (f.name === "index.md") continue; // never index the index
let fm: Record<string, unknown>;
try { fm = splitFrontmatter(await app.vault.read(f)).fm; } catch { continue; }
const id = typeof fm.id === "string" ? fm.id : null;
if (!id || id === ROOT_ID) continue;
if (scopeIds && !scopeIds.has(id)) continue;
byId.set(id, {
id, file: f, parent: typeof fm.parent === "string" ? fm.parent : ROOT_ID,
title: typeof fm.okfTitle === "string" && fm.okfTitle ? fm.okfTitle : okfTitleFromFile(f),
type: typeof fm.okfType === "string" && fm.okfType ? fm.okfType : "concept",
position: typeof fm.position === "number" ? fm.position : Number.MAX_SAFE_INTEGER,
created: typeof fm.created === "string" ? fm.created : "",
});
}
for (const n of byId.values()) { const a = kids.get(n.parent) ?? []; a.push(n); kids.set(n.parent, a); }
for (const a of kids.values()) a.sort((x, y) => (x.position - y.position) || x.created.localeCompare(y.created));
const lines: string[] = [];
const walk = (n: N, depth: number, seen: Set<string>): void => {
if (seen.has(n.id)) return; seen.add(n.id);
lines.push(`${" ".repeat(depth)}- [${n.title}](${encodeURI(n.file.name)}) — ${n.type}`);
for (const c of kids.get(n.id) ?? []) walk(c, depth + 1, seen);
};
// Roots of the (possibly scoped) set: parent is ROOT or outside the set.
const seen = new Set<string>();
const roots = [...byId.values()].filter((n) => n.parent === ROOT_ID || !byId.has(n.parent))
.sort((x, y) => (x.position - y.position) || x.created.localeCompare(y.created));
for (const r of roots) walk(r, 0, seen);
const folderName = cleaned.split("/").pop() || cleaned;
return [
"---", "type: index", `title: ${folderName} (OKF index)`, "---", "",
`# ${folderName}`, "", OKF_LEGEND, "", "## Concepts",
lines.length ? lines.join("\n") : "_No concepts yet._", "",
].join("\n");
}

View file

@ -438,6 +438,13 @@ export interface StashpadSettings {
* before that and to step back to the terser confirm once the
* user has built once and presumably knows what they're doing. */
jdIndexHasBuilt: boolean;
/** OKF (Open Knowledge Format) support master toggle. When on, folders using
* the OKF template get OKF frontmatter + a generated index.md (see
* docs/branches/okf.md). */
okfEnabled: boolean;
/** Vault path of the auto-created OKF template note (assigned per-folder via the
* Templates section). Empty until OKF is first enabled. */
okfTemplatePath: string;
/** Per-folder composer draft text. Stored in the plugin's data.json. */
drafts: Record<string, string>;
/** Per-folder: the text most recently sent via Enter, used to suppress
@ -521,6 +528,8 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
jdIndexIncludeStashpadFolders: false,
jdIndexSort: "natural",
jdIndexHasBuilt: false,
okfEnabled: false,
okfTemplatePath: "",
drafts: {},
lastSubmitted: {},
bindings: buildDefaultBindings(),
@ -554,7 +563,7 @@ export function getTemplatesFormats(app: App): { dateFormat: string; timeFormat:
/** 0.73.1: settings tab redesigned into a tabbed UI. SETTINGS_TABS
* is the source of truth for both the bar at the top and the
* search-mode group order. Order here = display order. */
export type SettingsTabId = "general" | "encryption" | "diagnostics" | "authorship" | "templates" | "jdindex" | "hotkeys";
export type SettingsTabId = "general" | "encryption" | "diagnostics" | "authorship" | "templates" | "jdindex" | "okf" | "hotkeys";
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
{ id: "general", label: "General" },
{ id: "encryption", label: "Encryption" },
@ -562,6 +571,7 @@ export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
{ id: "authorship", label: "Authorship" },
{ id: "templates", label: "Templates" },
{ id: "jdindex", label: "JD Index" },
{ id: "okf", label: "Open Knowledge Format (OKF)" },
{ id: "hotkeys", label: "Hotkeys" },
];
@ -667,6 +677,7 @@ export class StashpadSettingTab extends PluginSettingTab {
case "authorship": return this.authorshipItems();
case "templates": return this.templatesItems();
case "jdindex": return this.jdIndexItems();
case "okf": return this.okfItems();
default: return null;
}
}
@ -1023,6 +1034,9 @@ export class StashpadSettingTab extends PluginSettingTab {
const betaRow = host.createDiv({ cls: "stashpad-beta-row" });
betaRow.createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
betaRow.createEl("span", { cls: "stashpad-beta-note", text: "Encryption is in beta — keep your own backups of anything important." });
host.createEl("div", { cls: "stashpad-ai-disclaimer" }).setText(
"⚠️ AI-built, NOT human-audited. This encryption was written by an AI assistant — not designed, reviewed, or security-audited by a human, and not tested by any security professional. It may carry real security, privacy, and DATA-LOSS risks. Treat it as a best-effort nice-to-have that might buy a little time against a casual snoop — nothing is guaranteed. Do NOT rely on it for anything sensitive, and always keep your own unencrypted backups of anything important.",
);
host.createEl("p", { cls: "setting-item-description" }).setText(
"⚠️ Encryption protects what you lock in this vault. Each device unlocks with its own password (which never leaves the device); the vault key is shared with collaborators by approving their device — no shared password. If everyone with access loses their password, anything encrypted is gone for good. While encrypting, avoid a sync/cloud service writing the vault mid-operation — it can corrupt files.",
);
@ -1518,7 +1532,7 @@ export class StashpadSettingTab extends PluginSettingTab {
const stashpads = this.plugin.discoverStashpadFolders();
if (stashpads.length === 0) {
new Setting(parent)
.setName("Color Aliases per Stashpad")
.setName("Color aliases per Stashpad")
.setDesc("No Stashpads discovered yet — create one above first.");
return;
}
@ -1532,7 +1546,7 @@ export class StashpadSettingTab extends PluginSettingTab {
})();
new Setting(parent)
.setName("Color Aliases per Stashpad")
.setName("Color aliases per Stashpad")
.setDesc("Which Stashpad's colors to label.")
.addDropdown((dd) => {
for (const f of stashpads) dd.addOption(f, f);
@ -1669,6 +1683,121 @@ export class StashpadSettingTab extends PluginSettingTab {
];
}
/** OKF (Open Knowledge Format) tab. Phase 1: master toggle + docs + how-to.
* Frontmatter/index.md/export land in later phases (docs/branches/okf.md). */
private okfItems(): SettingDefinitionItem[] {
return [
this.sectionDef("Open Knowledge Format (OKF)",
"Turn a Stashpad folder into a browsable OKF bundle — markdown concept files with OKF frontmatter, a generated index.md, and relative-markdown cross-links — that LLMs/agents can read. Complements (never replaces) Stashpad's own frontmatter and links.",
(host) => this.renderOkfSection(host),
["okf", "open knowledge format", "knowledge", "catalog", "index", "export", "bundle", "tarball", "agent", "google"]),
];
}
/** Append `text` to an element/fragment, rendering `backtick` spans as <code>
* (monospace) via text nodes safe for interpolated values (no innerHTML). */
private appendCode(el: HTMLElement | DocumentFragment, text: string): void {
text.split(/`([^`]+)`/g).forEach((part, i) => {
if (i % 2 === 1) el.createEl("code", { text: part });
else if (part) el.appendText(part);
});
}
/** A setting-description fragment with `backtick` → <code>, for setDesc(). */
private codeDesc(text: string): DocumentFragment {
const f = document.createDocumentFragment();
this.appendCode(f, text);
return f;
}
private renderOkfSection(parent: HTMLElement): void {
parent.createDiv({ cls: "stashpad-beta-row" }).createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
new Setting(parent)
.setName("Enable OKF")
.setDesc(this.codeDesc("Master switch. When on, you choose which folders use OKF by assigning the OKF template to them in Settings → Templates (all / some / none — your call). Those folders then get OKF frontmatter and a maintained `index.md`. Turning this off leaves existing OKF files in place; it just stops maintaining them."))
.addToggle((t) => t.setValue(this.plugin.settings.okfEnabled).onChange(async (v) => {
this.plugin.settings.okfEnabled = v;
await this.plugin.saveSettings();
if (v) { try { await this.plugin.ensureOkfTemplate(); } catch (e) { console.warn("[Stashpad] OKF template create failed", e); } }
new Notice(v
? `OKF on. Next: assign the template "${this.plugin.okfTemplatePathOrDefault()}" to a folder — use “Create template + open Templates” below. Heads-up: OKF frontmatter + index.md refresh automatically but NOT instantly (a few seconds after changes); hit Rebuild for an immediate pass.`
: "OKF disabled.", v ? 0 : 4000); // persistent CTA on enable (stays until dismissed)
this.update?.();
}));
if (this.plugin.settings.okfEnabled) {
const okfPath = this.plugin.okfTemplatePathOrDefault();
const okfCount = this.plugin.okfActiveFolders().length;
const steps = parent.createEl("div", { cls: "setting-item-description stashpad-okf-howto" });
steps.createEl("p", { text: "How to use OKF in a folder:" });
const ol = steps.createEl("ol");
this.appendCode(ol.createEl("li"), `Open Templates and set a folder's template to \`${okfPath}\` (archive folders are skipped).`);
this.appendCode(ol.createEl("li"), "Hit Rebuild below to write OKF frontmatter (`okfParent`/`okfChildren` + `okfType`/`okfTitle`/`okfTimestamp`) and generate that folder's `index.md`.");
this.appendCode(ol.createEl("li"), "Right-click a note (or a selection) → “Export as OKF…” to save a `.zip` / `.tar.gz` bundle (or `.stash`).");
steps.createEl("p", { cls: "stashpad-okf-soon", text: "OKF frontmatter + index.md refresh automatically a few seconds after you add, move, or delete notes — NOT instantly. Use Rebuild for an immediate pass." });
if (okfCount === 0) {
const cta = parent.createEl("p", { cls: "stashpad-okf-cta" });
this.appendCode(cta, "👉 No folder is using OKF yet. Click “Create template + open Templates” below, then set a folder's template to `" + okfPath + "`.");
} else {
steps.createEl("p", { cls: "stashpad-okf-soon", text: `Currently ${okfCount} folder${okfCount === 1 ? "" : "s"} actively using OKF.` });
}
new Setting(parent)
.setName("Assign OKF to folders")
.setDesc(this.codeDesc(`Creates the OKF template if needed (never duplicates it), then opens Templates — set a folder's template to \`${okfPath}\` there.`))
.addButton((b) => { b.setButtonText("Create template + open Templates").setCta(); b.onClick(async () => {
let path: string;
try { path = await this.plugin.ensureOkfTemplate(); }
catch (e) { new Notice(`Couldn't create the OKF template: ${(e as Error).message}`); return; }
new Notice(`OKF template ready at "${path}" — set a folder's template to that path.`);
this.update?.();
this.openSettingsPage("Templates");
}); });
new Setting(parent)
.setName("Rebuild OKF frontmatter")
.setDesc(this.codeDesc("Write/refresh OKF fields for every folder using the OKF template — `okfParent`/`okfChildren` relative links (managed) plus `okfType`/`okfTitle`/`okfTimestamp` defaults (yours to edit after). Heads-up: adding, moving, or deleting notes already auto-refreshes the folder, but NOT instantly — it waits ~a few seconds after you stop. Use this button for an immediate rebuild (e.g. right after first assigning the template). Complements Stashpad's own links; nothing is removed."))
.addButton((b) => b.setButtonText("Rebuild now").onClick(async () => {
const r = await this.plugin.rebuildAllOkf();
new Notice(r.folders === 0
? "No folders use the OKF template yet — assign it in Templates first."
: `OKF: updated ${r.written} of ${r.checked} notes across ${r.folders} folder${r.folders === 1 ? "" : "s"}.`);
this.update?.();
}));
}
// Docs
const docs = new Setting(parent).setName("Learn about OKF").setDesc("Google's open, vendor-neutral spec for sharing curated knowledge with agents.");
docs.addButton((b) => b.setButtonText("Spec / repo").onClick(() => window.open("https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf")));
docs.addButton((b) => b.setButtonText("Announcement").onClick(() => window.open("https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing/")));
}
/** Best-effort jump to another Stashpad settings sub-page by its visible name.
* Obsidian exposes no public sub-page nav, so we reset to the Stashpad page
* list (openTabById) then click the matching entry; falls back to a hint. */
private openSettingsPage(pageName: string): void {
// Obsidian has no public API to open a plugin's own settings SUB-PAGE (see
// docs/obsidian-limitations.md). Best-effort: reset to the Stashpad page list,
// then click the matching entry — but ONLY inside the active tab's CONTENT
// pane, never the left sidebar (whose core/community plugin tabs, e.g. the core
// "Templates" plugin, would otherwise match by name and mis-navigate). If we
// can't find it in-content, we DON'T guess — we just point the way.
const hint = () => new Notice(`Open Settings → Stashpad → ${pageName}.`);
try {
const setting = (this.app as App & { setting?: { openTabById?: (id: string) => void; modalEl?: HTMLElement } }).setting;
if (!setting?.openTabById) { hint(); return; }
setting.openTabById("stashpad");
window.setTimeout(() => {
const content = setting.modalEl?.querySelector<HTMLElement>(".vertical-tab-content");
if (!content) { hint(); return; }
const hit = Array.from(content.querySelectorAll<HTMLElement>("*"))
.find((e) => e.childElementCount === 0 && e.textContent?.trim() === pageName && !e.closest(".vertical-tab-header"));
const link = hit?.closest<HTMLElement>("[class*='nav'], .setting-item, button, a");
if (link && !link.closest(".vertical-tab-header")) link.click(); else hint();
}, 60);
} catch { hint(); }
}
private renderAuthorshipSection(parent: HTMLElement): void {
parent.createEl("h3", { text: "Authorship" });
parent.createEl("p", {
@ -1827,6 +1956,13 @@ export class StashpadSettingTab extends PluginSettingTab {
.setName("Note templates per Stashpad")
.setDesc("Pick a markdown file to use as the default template for new notes in each Stashpad. The template's frontmatter becomes the new note's frontmatter (id/parent/created/attachments are always set by Stashpad). If the body contains {{body}}, that's where the user-typed body goes; otherwise the user body is followed by the template body.");
if (this.plugin.settings.okfEnabled) {
const okfPath = this.plugin.okfTemplatePathOrDefault();
this.appendCode(parent.createEl("p", { cls: "setting-item-description" }),
`💡 OKF tip: type \`${okfPath}\` into a folder's template field below to turn that folder into an OKF bundle (OKF frontmatter + a maintained \`index.md\`). Assign it to all, some, or none of your folders — it's per-folder. Manage OKF itself in Settings → OKF.`,
);
}
const list = parent.createDiv({ cls: "stashpad-note-templates-list" });
const renderRow = (folder: string): void => {
@ -1849,6 +1985,17 @@ export class StashpadSettingTab extends PluginSettingTab {
// Obsidian version that ships with the plugin.
const sugg = inputWrap.createDiv({ cls: "stashpad-note-template-suggest" });
sugg.style.display = "none";
let currentMatches: string[] = [];
let itemEls: HTMLElement[] = [];
let activeIdx = -1;
const isOpen = (): boolean => sugg.style.display !== "none" && currentMatches.length > 0;
const highlight = (i: number): void => {
activeIdx = i;
itemEls.forEach((el, idx) => el.toggleClass("is-active", idx === i));
if (i >= 0 && itemEls[i]) itemEls[i].scrollIntoView({ block: "nearest" });
};
const closeSugg = (): void => { sugg.style.display = "none"; activeIdx = -1; };
const choose = async (m: string): Promise<void> => { input.value = m; await save(); closeSugg(); };
// Inline warning area — surfaces overlap with Stashpad's
// auto-managed frontmatter so the user can fix the template before
@ -1867,28 +2014,26 @@ export class StashpadSettingTab extends PluginSettingTab {
const renderSuggestions = (): void => {
sugg.empty();
itemEls = [];
// 0.76.26: Sift — all-tokens, any-order match (see docs/sift.md).
const tokens = input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const sift = (p: string): boolean => {
const h = p.toLowerCase();
return tokens.every((t) => h.includes(t));
};
const matches = allMd()
.filter((p) => sift(p))
.slice(0, 12);
if (matches.length === 0) { sugg.style.display = "none"; return; }
currentMatches = allMd().filter((p) => sift(p)).slice(0, 12);
if (currentMatches.length === 0) { closeSugg(); return; }
sugg.style.display = "";
for (const m of matches) {
currentMatches.forEach((m, idx) => {
const item = sugg.createDiv({ cls: "stashpad-note-template-suggest-item", text: m });
itemEls.push(item);
item.addEventListener("mousemove", () => highlight(idx));
// mousedown (not click) so the input's blur doesn't close the
// popover before the click registers.
item.addEventListener("mousedown", async (ev) => {
ev.preventDefault();
input.value = m;
await save();
sugg.style.display = "none";
});
}
item.addEventListener("mousedown", async (ev) => { ev.preventDefault(); await choose(m); });
});
activeIdx = activeIdx >= 0 && activeIdx < currentMatches.length ? activeIdx : -1;
if (activeIdx >= 0) highlight(activeIdx);
};
const save = async (): Promise<void> => {
@ -1935,9 +2080,46 @@ export class StashpadSettingTab extends PluginSettingTab {
};
input.addEventListener("focus", renderSuggestions);
input.addEventListener("input", renderSuggestions);
input.addEventListener("blur", () => { setTimeout(() => { sugg.style.display = "none"; }, 150); });
input.addEventListener("input", () => { activeIdx = -1; renderSuggestions(); });
input.addEventListener("blur", () => { setTimeout(closeSugg, 150); });
input.addEventListener("change", () => { void save(); });
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen()) { renderSuggestions(); if (currentMatches.length) highlight(0); }
else highlight((activeIdx + 1) % currentMatches.length);
} else if (e.key === "ArrowUp") {
if (!isOpen()) return;
e.preventDefault();
highlight((activeIdx - 1 + currentMatches.length) % currentMatches.length);
} else if (e.key === "Enter") {
if (isOpen() && activeIdx >= 0) { e.preventDefault(); void choose(currentMatches[activeIdx]); }
} else if (e.key === "Escape") {
if (isOpen()) { e.preventDefault(); closeSugg(); }
} else if (e.key === "Tab" && !e.shiftKey) {
// Per-segment ("per word") completion: extend the input toward the
// active (or first) match by one path segment, narrowing the list.
// Only swallow Tab when we actually complete — otherwise let it move
// focus as usual.
if (!isOpen()) return;
const target = currentMatches[activeIdx >= 0 ? activeIdx : 0];
const cur = input.value;
let next: string;
if (target.toLowerCase().startsWith(cur.toLowerCase())) {
const slash = target.indexOf("/", cur.length);
next = slash >= 0 ? target.slice(0, slash + 1) : target;
} else {
next = target; // token (non-prefix) match — complete it fully
}
if (next && next !== cur) {
e.preventDefault();
input.value = next;
activeIdx = -1;
renderSuggestions();
if (currentMatches.length === 1) highlight(0);
}
}
});
// Initial validation on render so existing saved templates show
// warnings without requiring a re-edit.
validateTemplate();

View file

@ -110,6 +110,10 @@ export const RESERVED_FRONTMATTER: readonly string[] = [
// 0.88.0: marks a note that came in via import (used by the "imported only"
// view filter). Stashpad-managed; a clone of an imported note isn't imported.
"imported",
// 0.101.x: OKF relative-markdown cross-links, derived from the tree and
// Stashpad-managed (rebuilt by the OKF pass). The user-editable OKF fields
// (okfType/okfTitle/okfTimestamp) are intentionally NOT reserved.
"okfParent", "okfChildren",
] as const;
/** Reserved Stashpad subfolder names (machine-managed; not user notes).

View file

@ -9114,6 +9114,7 @@ export class StashpadView extends ItemView {
// Implementations live in commands/io-cmds.ts; these thin delegators keep
// the public method names stable for the keydown dispatcher + main.ts.
cmdExportStash(rootNode?: TreeNode): Promise<void> { return ioCmds.cmdExportStash(this, rootNode); }
cmdExportOkf(rootNode?: TreeNode): Promise<void> { return ioCmds.cmdExportOkf(this, rootNode); }
cmdImportStash(): Promise<void> { return ioCmds.cmdImportStash(this); }
processStashFile(file: TFile): Promise<void> { return ioCmds.processStashFile(this, file); }
@ -9686,6 +9687,12 @@ export class StashpadView extends ItemView {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
void this.cmdExportStash();
}));
if (this.plugin.settings.okfEnabled) {
menu.addItem((it: any) => it.setTitle("Export as OKF…").setIcon("book-marked").onClick(() => {
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
void this.cmdExportOkf();
}));
}
// 0.98.1: encrypt (lock) this note + its whole subtree into one .stashenc
// bundle, in place. Only shown once a vault encryption password is set up.
if (this.plugin.encryption?.isConfigured?.()) {

View file

@ -131,7 +131,7 @@
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0 12px;
padding: 4px 14px 12px;
}
.stashpad-color-alias-row {
display: flex;
@ -191,7 +191,7 @@
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0 12px;
padding: 4px 14px 12px;
}
.stashpad-note-template-row {
display: flex;
@ -243,9 +243,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.stashpad-note-template-suggest-item:hover {
.stashpad-note-template-suggest-item:hover,
.stashpad-note-template-suggest-item.is-active {
background: var(--background-modifier-hover);
}
.stashpad-note-template-suggest-item.is-active {
background: var(--background-modifier-active-hover, var(--background-modifier-hover));
}
.stashpad-note-template-warn {
flex: 1 0 100%;
font-size: var(--font-ui-smaller);
@ -2300,9 +2304,43 @@
/* 0.97.2/0.97.4: keep the Encryption section's intro text off the left edge and
below the page's top fade (it was clipping the first line). */
/* PROVISION text flushing in custom-rendered settings sections.
sectionDef() removes `.setting-item` from its host, so raw createEl text /
headings / lists rendered directly into a `.stashpad-settings-section` lose the
horizontal inset that Obsidian's Setting rows have and sit flush against the
modal edge. This rule re-insets all such DIRECT children to 14px (matching the
Setting rows). GOING FORWARD: render plain text/headings into a
`.stashpad-settings-section` host and they align automatically don't hand-pad
per element, and don't render naked text outside a section host. (Setting rows
are untouched they're `.setting-item`, not selected here.) */
.stashpad-settings-section > p,
.stashpad-settings-section > h2,
.stashpad-settings-section > h3,
.stashpad-settings-section > h4,
.stashpad-settings-section > ol,
.stashpad-settings-section > ul,
.stashpad-settings-section > .setting-item-description {
padding-left: 14px;
padding-right: 14px;
}
.stashpad-ai-disclaimer {
margin: 4px 14px 12px; padding: 10px 12px; border-radius: var(--radius-m, 6px);
border: 1px solid var(--color-red, #e24b4a);
background: var(--background-modifier-error, rgba(226, 75, 74, 0.1));
color: var(--text-normal); font-size: 13px; line-height: 1.5;
}
.stashpad-encryption-section { padding: 12px 2px 0; }
.stashpad-encryption-section > .setting-item-description { padding: 4px 14px 10px; line-height: 1.4; }
.stashpad-beta-row { display: flex; align-items: center; gap: 8px; padding: 2px 14px 6px; }
.stashpad-okf-howto { padding: 4px 14px 8px; }
.stashpad-okf-fmt { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
.stashpad-okf-cta {
margin: 2px 14px 12px; padding: 9px 12px; border-radius: var(--radius-m, 6px);
border: 1px solid var(--interactive-accent); background: var(--background-modifier-hover);
font-size: 13px; line-height: 1.5;
}
.stashpad-okf-fmt label { cursor: pointer; }
.stashpad-okf-howto ol { margin: 4px 0 0; padding-left: 1.4em; }
.stashpad-beta-badge {
display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
line-height: 1; padding: 3px 6px; border-radius: 4px; text-transform: uppercase;