mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.102.12: Open Knowledge Format (OKF) support + folder-panel/templates polish
This commit is contained in:
parent
8e772a22df
commit
d2378ca5fc
16 changed files with 1025 additions and 137 deletions
113
docs/okf-guide.md
Normal file
113
docs/okf-guide.md
Normal 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
173
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
51
release-notes/0.102.12.md
Normal 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>
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
128
src/main.ts
128
src/main.ts
|
|
@ -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 write→event→rebuild 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). */
|
||||
|
|
|
|||
|
|
@ -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
121
src/okf-export.ts
Normal 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
157
src/okf.ts
Normal 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");
|
||||
}
|
||||
216
src/settings.ts
216
src/settings.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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?.()) {
|
||||
|
|
|
|||
44
styles.css
44
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue