0.157.0: unified action menus (Share & export everywhere), unified due-date dialog, note-picker search ranking, aggregate-view menus + hide-filename

This commit is contained in:
Human 2026-07-09 13:12:12 -07:00
parent 0b5e0e6ed8
commit bd4b50576a
12 changed files with 675 additions and 296 deletions

22
.gitattributes vendored
View file

@ -1,2 +1,20 @@
# Auto detect text files and perform LF normalization
* text=auto
# Line endings: store LF in the repo AND check out LF on every platform
# (macOS + Windows), so there's no CRLF churn, noisy diffs, or broken shell
# scripts when the repo is used cross-platform. Git auto-detects text vs binary;
# the explicit `binary` rules below are belt-and-suspenders so EOL conversion is
# never applied to assets (a mangled .stash fixture or PNG would be bad).
* text=auto eol=lf
# Binary assets — no EOL conversion, no diffing.
*.stash binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.zip binary
*.wasm binary

179
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.152.0",
"version": "0.157.0",
"minAppVersion": "1.13.0",
"description": "A chat-style, nested-notes workspace: rapid capture, outliner navigation, fast search, tasks, and per-folder templates, with one-click Open Knowledge Format (OKF) export for LLMs and agents.",
"author": "Human",

View file

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

57
release-notes/0.157.0.md Normal file
View file

@ -0,0 +1,57 @@
# 0.157.0
A menu-consistency and search pass: the note action menu, the encrypted/locked
row menu, the aggregate views, and the mobile ⚡ menu now share one set of
actions; the due-date dialog is the same from every entry point; and the note
picker finally ranks its results.
## Search ranking (0.157.0)
- The note picker — which backs Find, Search, Move, and the destination picker —
now **ranks** its matches instead of showing them in raw tree order. Results are
scored by a relevance band (exact title match > title starts-with > exact phrase
in title > all words in title > exact phrase in body > words in body) and, within
a band, by **most recently edited**.
- With an **empty** query the list is ordered purely by most-recently-edited.
- In the **Move / destination** picker, each folder's Home note is floated to the
top of its results — the common "move it here" target.
## Due-date dialog unified (0.156.0)
- The quick relative **+/- time-adjust row** (and the repeat / reminders section)
now appears no matter how you open the due-date dialog — the `D` hotkey, the
command palette, or the "Assign / schedule…" context-menu item. Previously the
Assign path opened a stripped-down dialog with the quick-adjust row missing.
- The quick-adjust presets now fall back to a sensible default when a caller
doesn't specify them, so no entry point can silently drop the row.
## Menus unified (0.155.0 0.155.3)
- **Shared menu for locked notes (0.155.0).** The encrypted/locked row menu and
the normal note context menu are now built from one implementation, so they stay
in sync. Copy Stashpad link, Export to .stash, and Export as OKF are grouped
under a single **"Share & export ▸"** submenu to keep the top level short.
- **Mobile ⚡ menu parity (0.155.1).** The mobile ⚡ actions menu gains the same
"Share & export ▸" submenu — it previously had no copy-link or export actions at
all.
- **Multi-select Copy Stashpad link (0.155.2).** Copying a link with several notes
selected now copies **one deep link per note** (newline-separated) instead of
only the first. A right-click still links just the clicked note.
- **Multi-link "Open Stashpad link" (0.155.3).** The Open-link dialog is now a
text area and accepts multiple links (one per line). A single link opens as
before; multiple links each open in their **own** tab, with a notice tallying how
many opened.
## Aggregate-view menus + hide-filename (0.153.0)
- **Context menus on every aggregate row.** In "All encrypted" and "All archived",
each row now has a ⋯ button and right-click menu: locked rows offer unlock /
restore, copy link, export, reveal in the file manager, copy path, and a
delete-with-undo; plain archived rows offer open, restore, reveal, and copy path.
- **New command "Encrypt (lock) selection + hide filename".** Locks the selection
with the encrypted file's name hidden, regardless of the folder's
encrypt-filenames setting.
## Internal
- Dev test-harness reload made more resilient (no user-facing effect).

View file

@ -210,8 +210,32 @@ async function screenshot(path) {
async function reload() {
await verifyVault();
await cdp("Page.reload", { ignoreCache: true });
// Reload from inside the page (Obsidian's own "app:reload", falling back to
// location.reload) rather than a raw CDP Page.reload. Page.reload tears the
// target down while we're still awaiting its response, which wedges the
// socket and has coincided with the dev instance dying outright. Fire it on a
// short timer so this eval returns before the navigation starts.
try {
await rawEval(wrapBody(`
const reloadNow = () => {
try { if (app.commands?.commands?.["app:reload"]) { app.commands.executeCommandById("app:reload"); return; } } catch {}
location.reload();
};
setTimeout(reloadNow, 50);
return "reloading";
`));
} catch { /* the socket may drop as the page navigates; that's expected */ }
console.log("reloading dev instance renderer (picks up the latest deploy)…");
// Poll the port + re-verify like start(), so reload only returns once the
// instance is actually back — instead of leaving the next command to trip
// over a renderer that's still mid-reload (or gone).
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 700));
if (!(await portReady())) continue;
try { await verifyVault(); console.log("reload complete — instance healthy."); return; } catch { /* still booting */ }
}
throw new Error("dev instance did not come back after reload within 30s — run `obs-dev start` to relaunch.");
}
// ---- dispatch -------------------------------------------------------------

View file

@ -1,6 +1,10 @@
import { ItemView, TFile, WorkspaceLeaf, moment, setIcon, type ViewStateResult } from "obsidian";
import { ItemView, Menu, Notice, Platform, TFile, WorkspaceLeaf, moment, setIcon, type ViewStateResult } from "obsidian";
import type StashpadPlugin from "./main";
import { populateLockedMenu } from "./locked-menu";
import { STASHPAD_AGGREGATE_VIEW_TYPE, archiveSubfolderOf } from "./types";
/** One row of the locked-subtree registry (`settings.lockedSubtrees`). */
type LockedEntry = { folder: string; blob: string; parentId?: string | null; title?: string; count?: number; created?: string; rootId?: string; prevSibling?: string | null };
import { renderTaskTriage, defaultTaskTriageState, type TaskTriageState } from "./task-render";
import { renderAggModeBar, type AggMode } from "./agg-modes";
import { returnToOriginOnClose } from "./leaf-return";
@ -143,6 +147,7 @@ export class StashpadAggregateView extends ItemView {
exp.onclick = () => void this.plugin.exportLockedSubtree(e.blob);
const lock = row.createSpan({ cls: "stashpad-aggregate-lockbadge" });
setIcon(lock, "lock");
this.attachLockedRowMenu(row, e, { archived: false });
}
}
}
@ -244,10 +249,46 @@ export class StashpadAggregateView extends ItemView {
const restore = row.createEl("button", { cls: "stashpad-trash-restore", text: "Restore" });
setIcon(restore.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
restore.onclick = async () => { restore.disabled = true; const ok = await this.plugin.unarchiveNote(f); if (ok) void this.render(); else restore.disabled = false; };
this.attachPlainRowMenu(row, f, restore);
}
/** + right-click menu for a plain (unencrypted) archived note. Lighter than
* the locked menu the full note menu lives in the folder view; here we just
* open, restore, and surface the file. */
private attachPlainRowMenu(row: HTMLElement, f: TFile, restoreBtn: HTMLButtonElement): void {
const buildMenu = (): Menu => {
const menu = new Menu();
menu.addItem((i) => i.setTitle("Open note").setIcon("file-text").onClick(() => void this.app.workspace.getLeaf(false).openFile(f)));
menu.addItem((i) => i.setTitle("Restore (un-archive)").setIcon("rotate-ccw").onClick(() => restoreBtn.click()));
menu.addSeparator();
if (!Platform.isMobile) {
menu.addItem((i) => i.setTitle("Show in Finder").setIcon("folder-open").onClick(() => {
try {
const shell = (window as unknown as { require?: (m: string) => { shell?: { showItemInFolder?: (p: string) => void } } }).require?.("electron")?.shell;
const fullPath = (this.app.vault.adapter as unknown as { getFullPath?: (p: string) => string })?.getFullPath?.(f.path);
if (fullPath && shell?.showItemInFolder) shell.showItemInFolder(fullPath);
} catch (err) { console.warn("[Stashpad] showItemInFolder failed", err); }
}));
}
menu.addItem((i) => i.setTitle("Copy file path").setIcon("copy").onClick(() => {
let path = f.path;
if (!Platform.isMobile) {
try { path = (this.app.vault.adapter as unknown as { getFullPath?: (p: string) => string })?.getFullPath?.(f.path) || f.path; } catch { /* keep relative */ }
}
void navigator.clipboard.writeText(path);
new Notice("Path copied.");
}));
return menu;
};
row.oncontextmenu = (evt: MouseEvent) => { evt.preventDefault(); buildMenu().showAtMouseEvent(evt); };
const more = row.createEl("button", { cls: "stashpad-trash-iconbtn stashpad-agg-more" });
setIcon(more, "ellipsis-vertical");
more.setAttr("aria-label", "More actions");
more.onclick = (evt) => { evt.stopPropagation(); buildMenu().showAtMouseEvent(evt as MouseEvent); };
}
/** One locked archived subtree — Restore (unlock, needs the password) + Export. */
private archiveLockedRow(container: HTMLElement, e: { blob: string; title?: string; count?: number; created?: string }): void {
private archiveLockedRow(container: HTMLElement, e: LockedEntry): void {
const row = container.createDiv({ cls: "stashpad-trash-row" });
const main = row.createDiv({ cls: "stashpad-trash-row-main" });
main.createSpan({ cls: "stashpad-trash-title", text: e.title || "Locked note" });
@ -278,6 +319,42 @@ export class StashpadAggregateView extends ItemView {
exp.onclick = () => void this.plugin.exportLockedSubtree(e.blob);
const lock = row.createSpan({ cls: "stashpad-aggregate-lockbadge" });
setIcon(lock, "lock");
this.attachLockedRowMenu(row, e, { archived: true });
}
/** Attach a button + right-click menu to a locked-bundle row. Delegates to
* the shared `populateLockedMenu` builder (locked-menu.ts) same source of
* truth as the folder-view locked menu. `fullParity: false` gives the trimmed
* aggregate item set; archived rows unlock-and-restore to the archive parent. */
private attachLockedRowMenu(row: HTMLElement, e: LockedEntry, opts: { archived: boolean }): void {
const buildMenu = (): Menu => {
const menu = new Menu();
populateLockedMenu(menu, {
app: this.app,
plugin: this.plugin,
descriptor: { blob: e.blob, rootId: e.rootId, count: e.count ?? 1, folder: this.cleanFolder(e.folder) },
unlockLabel: opts.archived ? "Unlock & restore" : "Decrypt (unlock)",
onUnlock: async () => {
let dest: string | undefined;
if (opts.archived) {
const segs = e.blob.split("/");
const archIdx = segs.lastIndexOf("archive");
dest = archIdx > 0 ? segs.slice(0, archIdx).join("/") : undefined;
}
const ok = await this.plugin.unlockBundleAt(e.blob, dest ? { destFolder: dest } : {});
if (ok) void this.render();
},
onChange: () => void this.render(),
fullParity: false,
confirmDelete: false,
});
return menu;
};
row.oncontextmenu = (evt: MouseEvent) => { evt.preventDefault(); buildMenu().showAtMouseEvent(evt); };
const more = row.createEl("button", { cls: "stashpad-trash-iconbtn stashpad-agg-more" });
setIcon(more, "ellipsis-vertical");
more.setAttr("aria-label", "More actions");
more.onclick = (evt) => { evt.stopPropagation(); buildMenu().showAtMouseEvent(evt as MouseEvent); };
}
/** 0.138.0 "Previously encrypted": subtrees that WERE encrypted and are now

195
src/locked-menu.ts Normal file
View file

@ -0,0 +1,195 @@
import { App, Menu, Notice, Platform } from "obsidian";
import type StashpadPlugin from "./main";
import { buildStashpadLink } from "./deep-link";
import { ConfirmModal } from "./modals";
/** Normalised descriptor for a locked (`.stashenc`) bundle, shared by every
* locked-menu site (the folder-view `openLockedMenu` and the two aggregate
* locked-row menus). Collapses the old `lk` / `LockedEntry` shapes. */
export interface LockedDescriptor {
blob: string;
rootId?: string | null;
count: number;
/** Folder used for the deep-link target AND for undo-stack ownership. */
folder: string;
}
/** Delete a locked bundle the `.stashenc` ciphertext + its `.stashmeta`
* sidecar to Obsidian's trash, recoverable via the folder's undo stack.
* Single source of truth for all three locked-menu sites (was triplicated).
*
* Captures the bytes + sidecar + registry entry BEFORE removing anything so
* undo can fully restore. `onChange` repaints the caller's view. */
export async function deleteLockedBundleWithUndo(
app: App,
plugin: StashpadPlugin,
d: LockedDescriptor,
onChange: () => void,
): Promise<void> {
const blobPath = d.blob;
const metaPath = blobPath.replace(/\.stashenc$/, ".stashmeta");
const adapter = app.vault.adapter;
let blobData: ArrayBuffer | null = null;
let metaData: string | null = null;
try { blobData = await adapter.readBinary(blobPath); } catch { blobData = null; }
if (!blobData) { new Notice("Couldn't read the encrypted file to delete it."); return; }
try { if (await adapter.exists(metaPath)) metaData = await adapter.read(metaPath); } catch { metaData = null; }
const prevEntry = (plugin.settings.lockedSubtrees ?? []).find((x) => x.blob === blobPath) ?? null;
// Trash both files: Obsidian trash when the TFile is indexed (recoverable
// there), adapter.remove as a fallback when the index lags. Contents are
// captured above, so the undo entry can restore either way.
const trashByPath = async (path: string): Promise<void> => {
const tf = app.vault.getAbstractFileByPath(path);
if (tf) { try { await app.fileManager.trashFile(tf); return; } catch { /* fall through */ } }
try { if (await adapter.exists(path)) await adapter.remove(path); } catch { /* non-fatal */ }
};
const doDelete = async (): Promise<void> => {
await trashByPath(blobPath);
await trashByPath(metaPath);
plugin.settings.lockedSubtrees = (plugin.settings.lockedSubtrees ?? []).filter((x) => x.blob !== blobPath);
await plugin.saveSettings();
onChange();
};
const doRestore = async (): Promise<void> => {
await adapter.writeBinary(blobPath, blobData!);
if (metaData != null) { try { await adapter.write(metaPath, metaData); } catch { /* non-fatal */ } }
if (prevEntry && !(plugin.settings.lockedSubtrees ?? []).some((x) => x.blob === blobPath)) {
plugin.settings.lockedSubtrees = [...(plugin.settings.lockedSubtrees ?? []), prevEntry];
await plugin.saveSettings();
}
onChange();
};
await doDelete();
plugin.getUndoStack(d.folder).push({
label: d.count > 1 ? "Delete encrypted notes" : "Delete encrypted note",
undo: doRestore,
redo: doDelete,
});
new Notice(d.count > 1 ? "Encrypted notes deleted — undo to restore." : "Encrypted note deleted — undo to restore.");
}
export interface LockedMenuConfig {
app: App;
plugin: StashpadPlugin;
descriptor: LockedDescriptor;
/** Unlock item label — "Decrypt (unlock)" (in place) or "Unlock & restore". */
unlockLabel: string;
/** What the unlock item does (in-place decrypt vs restore-to-parent). */
onUnlock: () => void;
/** Repaint after a delete / change. */
onChange: () => void;
/** Folder view (true) renders the full openNoteMenu-parity item set including
* the "unlock first" notice mirrors; the aggregate rows (false) render the
* trimmed set. This flag selects between the two exact layouts. */
fullParity: boolean;
/** Folder view confirms before delete; aggregate relies on undo. */
confirmDelete: boolean;
}
/** Populate `menu` with the locked-bundle action set. The single builder behind
* `openLockedMenu` (view.ts) and `attachLockedRowMenu` (aggregate-view.ts).
* Copy-link + exports are always grouped under a "Share & export ▸" submenu. */
export function populateLockedMenu(menu: Menu, cfg: LockedMenuConfig): void {
const { app, plugin, descriptor: d } = cfg;
const unlockFirst = (verb: string): Notice => new Notice(`Unlock this note first to ${verb}.`);
const fullPathOf = (p: string): string => {
if (Platform.isMobile) return p;
try { return (app.vault.adapter as unknown as { getFullPath?: (x: string) => string })?.getFullPath?.(p) || p; } catch { return p; }
};
const copyLink = (): void => {
const link = buildStashpadLink({ vault: app.vault.getName(), folder: d.folder, note: d.rootId!, run: ["reveal"] });
void navigator.clipboard.writeText(link).then(() => new Notice("Stashpad link copied."), () => new Notice("Couldn't copy the link."));
};
// Shared item factories -----------------------------------------------------
const addShareSubmenu = (): void => {
menu.addItem((i: any) => {
i.setTitle("Share & export").setIcon("share-2");
const sub = typeof i.setSubmenu === "function" ? i.setSubmenu() : null;
if (sub && typeof sub.addItem === "function") {
sub.addItem((s: any) => { s.setTitle("Copy Stashpad link").setIcon("link"); if (d.rootId) s.onClick(() => copyLink()); else s.setDisabled(true); });
sub.addItem((s: any) => s.setTitle("Export to .stash…").setIcon("package").onClick(() => { void plugin.exportLockedSubtree(d.blob); }));
if (plugin.settings.okfEnabled) sub.addItem((s: any) => s.setTitle("Export as OKF…").setIcon("book-marked").onClick(() => unlockFirst("export it as OKF")));
} else if (d.rootId) {
i.onClick(() => copyLink()); // degraded (no setSubmenu — never on the 1.13 floor)
} else {
i.setDisabled(true);
}
});
};
const addUnlock = (): void => { menu.addItem((i: any) => i.setTitle(cfg.unlockLabel).setIcon("unlock").onClick(() => cfg.onUnlock())); };
const addShowInFinder = (): void => {
if (Platform.isMobile) return;
const osManager = Platform.isMacOS ? "Finder" : Platform.isWin ? "File Explorer" : "file manager";
menu.addItem((i: any) => i.setTitle(`Show in ${osManager}`).setIcon("folder-search").onClick(() => {
try {
const shell = (window as unknown as { require?: (m: string) => { shell?: { showItemInFolder?: (p: string) => void } } }).require?.("electron")?.shell;
const fullPath = (app.vault.adapter as unknown as { getFullPath?: (x: string) => string })?.getFullPath?.(d.blob);
if (fullPath && shell?.showItemInFolder) shell.showItemInFolder(fullPath);
} catch (err) { console.warn("[Stashpad] showItemInFolder failed", err); }
}));
};
const addCopyPath = (): void => {
menu.addItem((i: any) => i.setTitle("Copy encrypted file path").setIcon("copy").onClick(() => {
void navigator.clipboard.writeText(fullPathOf(d.blob));
new Notice("Path copied.");
}));
};
const addDelete = (): void => {
menu.addItem((i: any) => {
i.setTitle(d.count > 1 ? "Delete encrypted notes…" : "Delete encrypted note…").setIcon("trash-2");
i.setWarning?.(true);
i.onClick(() => {
if (cfg.confirmDelete) {
new ConfirmModal(
app,
d.count > 1 ? `Delete ${d.count} encrypted notes?` : "Delete encrypted note?",
"The encrypted file moves to Obsidian's trash (recoverable there). You'll still need your password to read it if you restore it.",
"Delete",
async (ok: boolean) => { if (ok) await deleteLockedBundleWithUndo(app, plugin, d, cfg.onChange); },
).open();
} else {
void deleteLockedBundleWithUndo(app, plugin, d, cfg.onChange);
}
});
});
};
// Two exact layouts ---------------------------------------------------------
if (cfg.fullParity) {
// Folder-view locked row: full openNoteMenu parity. Actions that need the
// decrypted note show an "unlock first" notice; the rest act on ciphertext.
menu.addItem((i: any) => i.setTitle("Open in new Stashpad tab").setIcon("layout-grid").onClick(() => unlockFirst("open it in a tab")));
menu.addItem((i: any) => i.setTitle("Open in editor").setIcon("file-text").onClick(() => unlockFirst("open it in the editor")));
menu.addItem((i: any) => i.setTitle("Focus in Stashpad").setIcon("arrow-right").onClick(() => unlockFirst("focus it")));
menu.addSeparator();
menu.addItem((i: any) => i.setTitle("Split note…").setIcon("split").onClick(() => unlockFirst("split it")));
menu.addItem((i: any) => i.setTitle("Copy text").setIcon("copy").onClick(() => unlockFirst("copy its text")));
menu.addItem((i: any) => i.setTitle("Clone (duplicate / copy)").setIcon("files").onClick(() => unlockFirst("clone it")));
menu.addItem((i: any) => i.setTitle("Fork into a separate note…").setIcon("git-branch").onClick(() => unlockFirst("fork it")));
addShareSubmenu();
addUnlock();
addShowInFinder();
addCopyPath();
menu.addSeparator();
menu.addItem((i: any) => i.setTitle("Move to…").setIcon("move").onClick(() => unlockFirst("move it")));
menu.addItem((i: any) => i.setTitle("Move to Home").setIcon("home").onClick(() => unlockFirst("move it")));
menu.addItem((i: any) => i.setTitle("Pin to sidebar").setIcon("pin").onClick(() => unlockFirst("pin it")));
menu.addItem((i: any) => i.setTitle("Pin to top of list").setIcon("arrow-up-to-line").onClick(() => unlockFirst("pin it")));
menu.addItem((i: any) => i.setTitle("Set color…").setIcon("palette").onClick(() => unlockFirst("set its color")));
menu.addItem((i: any) => i.setTitle("Task").setIcon("check-circle-2").onClick(() => unlockFirst("change its task state")));
menu.addSeparator();
addDelete();
} else {
// Aggregate locked row: trimmed set.
addUnlock();
addShareSubmenu();
menu.addSeparator();
addShowInFinder();
addCopyPath();
menu.addSeparator();
addDelete();
}
}

View file

@ -1260,6 +1260,11 @@ export default class StashpadPlugin extends Plugin {
name: "Encrypt (lock) selection (notes + children)",
callback: () => call("cmdLockSelection"),
});
this.addCommand({
id: "stashpad-lock-selection-hide-name",
name: "Encrypt (lock) selection + hide filename (override folder setting)",
callback: () => call("cmdLockSelectionHideName"),
});
this.addCommand({
id: "stashpad-unlock-all",
name: "Decrypt (unlock) locked notes in view",
@ -3738,7 +3743,7 @@ export default class StashpadPlugin extends Plugin {
}).open();
}
async lockNoteSubtree(folder: string, rootId: StashpadId, prevSibling: StashpadId | null = null, opts: { silent?: boolean; blobFolder?: string } = {}): Promise<LockResult | null> {
async lockNoteSubtree(folder: string, rootId: StashpadId, prevSibling: StashpadId | null = null, opts: { silent?: boolean; blobFolder?: string; hideTitle?: boolean } = {}): Promise<LockResult | null> {
// Encrypt under the key of the folder the BLOB will live in (archive moves put
// the blob in opts.blobFolder, not the source folder), so opening that folder
// later decrypts it. Falls back to the vault DEK when the folder has no own key.
@ -3751,7 +3756,8 @@ export default class StashpadPlugin extends Plugin {
// else its live-notes pref — falling back to the global hide-titles setting.
const isArchiveLock = !!opts.blobFolder && opts.blobFolder.replace(/\/+$/, "") !== folder.replace(/\/+$/, "");
const fp = (this.settings.folderEncPrefs ?? {})[keyFolder] ?? {};
const hideTitle = (isArchiveLock ? fp.archiveEncryptFilenames : fp.encryptFilenames) ?? false; // 0.137.1: per-folder only
// A per-note override (the "hide filename" command) wins over the folder pref.
const hideTitle = opts.hideTitle ?? ((isArchiveLock ? fp.archiveEncryptFilenames : fp.encryptFilenames) ?? false); // 0.137.1: per-folder only
const r = await lockSubtree(this.app, folder, rootId, dek, prevSibling, hideTitle, opts.blobFolder);
this.pendingEncBlobs.add(r.blobPath); // fast-state index: cover the pre-vault-index window
// Record a placeholder registry entry so the list shows a 🔒 stub where
@ -5251,15 +5257,16 @@ export default class StashpadPlugin extends Plugin {
/** Handle an `obsidian://stashpad?` deep link. Resolve → activate → reveal →
* run macro. Any unresolved target is a LOUD failure (Notice), never a silent
* no-op. See `docs/deep-links-plan.md`. */
async handleDeepLink(params: { folder?: string; note?: string; run?: string; action?: string; vault?: string }): Promise<void> {
async handleDeepLink(params: { folder?: string; note?: string; run?: string; action?: string; vault?: string }, opts: { forceNewTab?: boolean } = {}): Promise<boolean> {
const folder = (params.folder || "").replace(/^\/+|\/+$/g, "");
const noteId = (params.note || "").trim();
const actions = parseRunActions(params);
// 1. Guard + resolve.
if (!folder) { new Notice("Stashpad link: missing “folder”."); return; }
// 1. Guard + resolve. Returns false (not thrown) on a bad link so a batch
// caller can tally how many actually opened.
if (!folder) { new Notice("Stashpad link: missing “folder”."); return false; }
const dir = this.app.vault.getAbstractFileByPath(folder);
if (!(dir instanceof TFolder)) { new Notice(`Stashpad link: folder “${folder}” not found.`); return; }
if (!(dir instanceof TFolder)) { new Notice(`Stashpad link: folder “${folder}” not found.`); return false; }
// 2. Wait for the workspace to settle. On a cross-vault jump Obsidian may
// still be laying out when the handler fires, so activate/reveal would find
@ -5277,13 +5284,14 @@ export default class StashpadPlugin extends Plugin {
file = this.resolveNoteFileInFolder(folder, noteId);
if (!file) await new Promise((r) => window.setTimeout(r, 150));
}
if (!file) { new Notice(`Stashpad link: note “${noteId}” not found in ${folder}.`); return; }
if (!file) { new Notice(`Stashpad link: note “${noteId}” not found in ${folder}.`); return false; }
}
// 3. Open the target WITHOUT hijacking the current tab. A deep link should
// land in its own tab (focusing an existing background tab on that folder
// if there is one), never overwrite whatever the user is currently viewing.
await this.openDeepLinkTarget(folder, noteId);
// `forceNewTab` (batch/multi-link) always opens a fresh tab per link.
await this.openDeepLinkTarget(folder, noteId, opts);
// 4. Run the macro, in order. `reveal` is already satisfied by step 3.
// Unknown tokens are skipped with a warning — one bad token never aborts.
@ -5295,6 +5303,7 @@ export default class StashpadPlugin extends Plugin {
}
console.warn(`[stashpad] deep link: unknown action “${token}” — skipped.`);
}
return true;
}
/** Open a deep-link target without hijacking the currently-active tab.
@ -5302,17 +5311,22 @@ export default class StashpadPlugin extends Plugin {
* one included), a deep link should never overwrite what the user is looking
* at: focus an existing BACKGROUND tab on that folder if there is one, else
* open a brand-new tab. `noteId` is optional (folder-only links). */
async openDeepLinkTarget(folder: string, noteId: string): Promise<void> {
async openDeepLinkTarget(folder: string, noteId: string, opts: { forceNewTab?: boolean } = {}): Promise<void> {
const clean = folder.replace(/\/+$/, "");
const active = this.app.workspace.activeLeaf;
const existing = await this.findStashpadLeafForFolder(clean);
// Reuse an existing tab only if it isn't the one currently in front —
// reusing the active leaf is exactly the "opens inside it" overwrite.
if (existing && existing !== active) {
this.app.workspace.revealLeaf(existing);
this.app.workspace.setActiveLeaf(existing, { focus: true });
if (noteId) this.navigateLeafTo(existing, clean, noteId);
return;
// 0.155.3: `forceNewTab` skips the reuse-existing-tab path so a BATCH of
// links (multi-link paste) opens each in its own tab — even several links
// into the same folder, which would otherwise collapse onto one reused tab.
if (!opts.forceNewTab) {
const active = this.app.workspace.activeLeaf;
const existing = await this.findStashpadLeafForFolder(clean);
// Reuse an existing tab only if it isn't the one currently in front —
// reusing the active leaf is exactly the "opens inside it" overwrite.
if (existing && existing !== active) {
this.app.workspace.revealLeaf(existing);
this.app.workspace.setActiveLeaf(existing, { focus: true });
if (noteId) this.navigateLeafTo(existing, clean, noteId);
return;
}
}
const leaf = await this.activateViewForFolder(clean); // getLeaf("tab") → new tab
if (noteId) this.navigateLeafTo(leaf, clean, noteId);
@ -5324,9 +5338,24 @@ export default class StashpadPlugin extends Plugin {
* are shared. */
openDeepLinkModal(): void {
new OpenDeepLinkModal(this.app, (raw) => {
const parsed = parseStashpadLink(raw);
if (!parsed) { new Notice("That doesn't look like a Stashpad link."); return; }
void this.handleDeepLink(parsed);
// 0.155.3: accept a MULTI-link paste (e.g. from a multi-select "Copy
// Stashpad link"). Links are whitespace/newline-separated and never
// contain literal spaces (URLs are percent-encoded), so split on \s+.
const candidates = raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
const parsed = candidates
.map((c) => parseStashpadLink(c))
.filter((p): p is NonNullable<typeof p> => !!p);
if (parsed.length === 0) { new Notice("That doesn't look like a Stashpad link."); return; }
if (parsed.length === 1) { void this.handleDeepLink(parsed[0]); return; }
// Multiple → open each in its OWN new tab, then report the tally.
void (async () => {
let opened = 0;
for (const p of parsed) { if (await this.handleDeepLink(p, { forceNewTab: true })) opened++; }
const failed = parsed.length - opened;
new Notice(failed === 0
? `Opened ${opened} Stashpad links in new tabs.`
: `Opened ${opened} of ${parsed.length} Stashpad links (${failed} couldn't be found).`);
})();
}).open();
}

View file

@ -38,6 +38,12 @@ export interface DuePickerOptions {
quickAdjusts?: string[];
}
/** 0.155.0: fallback quick-adjust presets. Mirrors DEFAULT_SETTINGS.dueQuickAdjusts.
* Used when a caller passes no `quickAdjusts` so the +/- row shows from EVERY
* entry point (the row used to silently vanish when a call site e.g. Assign
* forgot to pass the option). A caller can still explicitly pass `[]` to hide it. */
export const DEFAULT_QUICK_ADJUSTS = ["5m", "15m", "30m", "1h", "1d", "1w"];
/** 0.125.1: parse a compact duration token ("5m", "15m", "1h", "2d", "1w") into
* minutes. Returns null when unparseable so callers can skip bad presets. */
export function parseAdjustMinutes(raw: string): number | null {
@ -1206,18 +1212,19 @@ export class OpenDeepLinkModal extends Modal {
this.contentEl.empty();
this.modalEl.addClass("stashpad-export-modal");
this.titleEl.setText("Open Stashpad link");
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: "Paste an obsidian://stashpad link to jump to the note it points to." });
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: "Paste one or more obsidian://stashpad links (one per line) to jump to the notes they point to. Multiple links each open in their own tab." });
// Input + an explicit Paste button on one row. The button matters on mobile
// (and anywhere clipboard auto-read is blocked) where the auto-prefill below
// can't run — one tap fills the field.
// can't run — one tap fills the field. 0.155.3: a textarea (not a single-line
// input) so a multi-link paste keeps its newlines.
const row = this.contentEl.createDiv({ cls: "stashpad-open-link-row" });
// Paste on the LEFT, input fills the rest of the row.
const pasteBtn = row.createEl("button", { cls: "stashpad-open-link-paste" });
setIcon(pasteBtn.createSpan({ cls: "stashpad-open-link-paste-icon" }), "clipboard-paste");
pasteBtn.createSpan({ text: "Paste" });
pasteBtn.title = "Paste from clipboard";
const input = row.createEl("input", { type: "text" });
const input = row.createEl("textarea", { attr: { rows: "3" } });
input.addClass("stashpad-export-name");
input.placeholder = "obsidian://stashpad?folder=…&note=…";
pasteBtn.onclick = async () => {
@ -1783,7 +1790,7 @@ export class DueDatePickerModal extends Modal {
// configured preset. Clicking nudges the entered date+time by ±amount; if no
// date/time is entered yet, it bases off "now" so a single tap schedules
// e.g. "+1h from now". Reschedule-friendly for Snooze.
const adjusts = (this.opts.quickAdjusts ?? [])
const adjusts = (this.opts.quickAdjusts ?? DEFAULT_QUICK_ADJUSTS)
.map((s) => ({ raw: s, min: parseAdjustMinutes(s) }))
.filter((a): a is { raw: string; min: number } => a.min != null);
if (adjusts.length > 0) {

View file

@ -519,43 +519,78 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
locked: n.locked,
});
// 0.155.0: relevance + recency ranking. Sift alone is a boolean filter (see
// docs/sift.md) — matches used to render in raw tree order, so an exact-phrase
// title hit could sit below shallower token hits, and nothing was ordered by
// last-edited. Now each match gets a relevance BAND (exact title > title
// prefix > phrase-in-title > all-tokens-in-title > phrase-in-body >
// token-in-body) and, within a band, sorts by mtime desc. An EMPTY query
// sorts purely by most-recently-edited. In pick mode (the Move / destination
// picker) each folder's HOME note gets a small boost so it floats to the top
// of its results — homes are the common "move here" target.
const mtimeFor = (n: NoteBody): number =>
n.node?.file?.stat?.mtime ?? n.cross?.file?.stat?.mtime ?? 0;
const isHome = (n: NoteBody): boolean => n.node?.id === ROOT_ID || n.cross?.id === ROOT_ID;
const pinHomes = this.opts.mode === "pick";
const relevanceBand = (n: NoteBody): number => {
let band: number;
if (!q) {
band = 0; // empty query → recency only
} else {
const t = n.title.toLowerCase();
const b = n.body.toLowerCase();
if (t === q) band = 6;
else if (t.startsWith(q)) band = 5;
else if (t.includes(q)) band = 4; // exact phrase in title
else if (tokens.every((x) => t.includes(x))) band = 3; // all tokens in title
else if (b.includes(q)) band = 2; // exact phrase in body
else band = 1; // token(s) in body only
}
if (pinHomes && isHome(n)) band += 0.5; // destination picker: float homes up
return band;
};
const matchTier = (tier: NoteBody[]): PickerItem[] => {
const out: PickerItem[] = [];
// 1. Collect the notes that match (+ their per-line clusters for search).
const matched: { n: NoteBody; matchLines: number[] }[] = [];
for (const n of tier) {
// 0.64.0: structured filters always apply, even when there's no
// free text — `in:work` alone should narrow to that folder.
if (!passesFilters(n)) continue;
if (this.opts.mode === "search") {
if (!q) { out.push(buildItem(n, -1)); continue; }
if (!q) { matched.push({ n, matchLines: [] }); continue; }
const titleHit = matchesAll(n.title.toLowerCase());
const lines = n.body.split(/\r?\n/);
// 0.69.12: collect ALL per-line matches, then cluster nearby
// matches together (within 5 lines) and emit one PickerItem
// per cluster — so a long note with multiple distinct
// matches surfaces each as its own result with surrounding
// context. Capped at 5 cluster rows per note to avoid spam.
// 0.69.12: collect ALL per-line matches; clustered into rows below.
const matchLines: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (lineMatchesAll(lines[i])) matchLines.push(i);
}
const bodyHit = matchLines.length > 0 || matchesAll(n.body.toLowerCase());
if (!titleHit && !bodyHit) continue;
if (matchLines.length === 0) {
// Title-only / body-anywhere hit — no per-line preview.
out.push(buildItem(n, -1));
} else {
const clusterHeads: number[] = [];
let last = -100;
for (const ln of matchLines) {
if (ln - last > 5) clusterHeads.push(ln);
last = ln;
}
const CAP = 5;
for (const ml of clusterHeads.slice(0, CAP)) out.push(buildItem(n, ml));
}
matched.push({ n, matchLines });
} else {
// pick mode — tokens must all appear in title OR body.
if (q && !matchesAll(n.title.toLowerCase()) && !matchesAll(n.body.toLowerCase())) continue;
matched.push({ n, matchLines: [] });
}
}
// 2. Rank: relevance band desc, then most-recently-edited. Precompute the
// keys (band does substring work) so the comparator stays cheap. Array
// sort is stable, so same-band/same-mtime ties keep tree order.
const scored = matched.map((m) => ({ ...m, band: relevanceBand(m.n), mtime: mtimeFor(m.n) }));
scored.sort((a, b) => (b.band - a.band) || (b.mtime - a.mtime));
// 3. Emit rows — one per note (title/body hit) or one per match cluster
// (search mode, capped at 5) so a long note surfaces each hit in context.
const out: PickerItem[] = [];
for (const { n, matchLines } of scored) {
if (this.opts.mode === "search" && q && matchLines.length > 0) {
const clusterHeads: number[] = [];
let last = -100;
for (const ln of matchLines) { if (ln - last > 5) clusterHeads.push(ln); last = ln; }
const CAP = 5;
for (const ml of clusterHeads.slice(0, CAP)) out.push(buildItem(n, ml));
} else {
out.push(buildItem(n, -1));
}
}

View file

@ -23,6 +23,7 @@ import { IntegrityWatcher } from "./integrity-watcher";
import { getSettings, getTemplatesFormats, onSettingsChange } from "./settings";
import { StashpadSuggest } from "./note-picker";
import { buildStashpadLink } from "./deep-link";
import { populateLockedMenu } from "./locked-menu";
import { StashpadCommandPalette } from "./command-palette";
import { setActiveView, clearActiveView } from "./active-view";
import { BreadcrumbLevelsModal, type BreadcrumbLevel, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
@ -1938,135 +1939,21 @@ export class StashpadView extends ItemView {
// note menu (openNoteMenu) can't serve them; this is their own small menu.
const openLockedMenu = (e: MouseEvent) => {
e.preventDefault(); e.stopPropagation();
// 0.155.0: locked rows present the SAME menu as normal notes (agreed
// design). Built by the shared `populateLockedMenu` builder (locked-menu.ts)
// — one source of truth for this menu + the two aggregate-view locked-row
// menus. `fullParity` renders the full openNoteMenu-mirroring item set
// (actions needing the decrypted note show an "unlock first" notice).
const menu = new Menu();
// 0.152.0: locked rows present the SAME menu as normal notes (agreed
// design). Actions that need the DECRYPTED note show an "unlock first"
// notice instead of doing nothing; the rest act on the ciphertext bundle
// directly. Items mirror openNoteMenu; kept a flat verbatim copy for now —
// DRY-ing into a shared builder is a later cleanup.
const unlockFirst = (verb: string) => new Notice(`Unlock this note first to ${verb}.`);
menu.addItem((i: any) => i.setTitle("Open in new Stashpad tab").setIcon("layout-grid").onClick(() => unlockFirst("open it in a tab")));
menu.addItem((i: any) => i.setTitle("Open in editor").setIcon("file-text").onClick(() => unlockFirst("open it in the editor")));
menu.addItem((i: any) => i.setTitle("Focus in Stashpad").setIcon("arrow-right").onClick(() => unlockFirst("focus it")));
// Copy deep link works without decrypting (the id is known); greyed when
// this bundle has no recorded rootId.
menu.addItem((i: any) => {
i.setTitle("Copy Stashpad link").setIcon("link");
if (lk.rootId) {
i.onClick(() => {
const link = buildStashpadLink({ vault: this.app.vault.getName(), folder: this.noteFolder, note: lk.rootId!, run: ["reveal"] });
void navigator.clipboard.writeText(link).then(() => new Notice("Stashpad link copied."), () => new Notice("Couldn't copy the link."));
});
} else {
i.setDisabled(true);
}
});
menu.addSeparator();
menu.addItem((i: any) => i.setTitle("Split note…").setIcon("split").onClick(() => unlockFirst("split it")));
menu.addItem((i: any) => i.setTitle("Copy text").setIcon("copy").onClick(() => unlockFirst("copy its text")));
menu.addItem((i: any) => i.setTitle("Clone (duplicate / copy)").setIcon("files").onClick(() => unlockFirst("clone it")));
menu.addItem((i: any) => i.setTitle("Fork into a separate note…").setIcon("git-branch").onClick(() => unlockFirst("fork it")));
// Export the ENCRYPTED bundle as-is — no decryption needed.
menu.addItem((i: any) => i.setTitle("Export to .stash…").setIcon("package").onClick(() => { void this.plugin.exportLockedSubtree(lk.blob); }));
if (this.plugin.settings.okfEnabled) {
menu.addItem((i: any) => i.setTitle("Export as OKF…").setIcon("book-marked").onClick(() => unlockFirst("export it as OKF")));
}
// Decrypt (in place of the normal menu's "Encrypt" — this is already locked).
menu.addItem((i: any) => i.setTitle("Decrypt (unlock)").setIcon("unlock").onClick(() => void doUnlock(e)));
if (!Platform.isMobile) {
const osManager = Platform.isMacOS ? "Finder" : Platform.isWin ? "File Explorer" : "file manager";
menu.addItem((i: any) => i.setTitle(`Show in ${osManager}`).setIcon("folder-search").onClick(() => {
try {
const shell = (window as any).require?.("electron")?.shell;
const fullPath = (this.app.vault.adapter as any)?.getFullPath?.(lk.blob);
if (fullPath && shell?.showItemInFolder) shell.showItemInFolder(fullPath);
} catch (err) { console.warn("[Stashpad] showItemInFolder failed", err); }
}));
}
menu.addItem((i: any) => i.setTitle("Copy encrypted file path").setIcon("copy").onClick(() => {
// 0.98.32: full absolute path on desktop (pasteable into Finder/Explorer);
// mobile has no usable filesystem path, so fall back to the vault-relative one.
let path = lk.blob;
if (!Platform.isMobile) {
try { path = (this.app.vault.adapter as any)?.getFullPath?.(lk.blob) || lk.blob; } catch { /* keep relative */ }
}
void navigator.clipboard.writeText(path);
new Notice("Path copied.");
}));
menu.addSeparator();
menu.addItem((i: any) => i.setTitle("Move to…").setIcon("move").onClick(() => unlockFirst("move it")));
menu.addItem((i: any) => i.setTitle("Move to Home").setIcon("home").onClick(() => unlockFirst("move it")));
menu.addItem((i: any) => i.setTitle("Pin to sidebar").setIcon("pin").onClick(() => unlockFirst("pin it")));
menu.addItem((i: any) => i.setTitle("Pin to top of list").setIcon("arrow-up-to-line").onClick(() => unlockFirst("pin it")));
menu.addItem((i: any) => i.setTitle("Set color…").setIcon("palette").onClick(() => unlockFirst("set its color")));
menu.addItem((i: any) => i.setTitle("Task").setIcon("check-circle-2").onClick(() => unlockFirst("change its task state")));
// Delete the encrypted bundle. Removing the ciphertext needs no password;
// routed through Obsidian's trash so it's recoverable (the password is
// still required to READ it if restored). Confirm first — it may hold
// several notes.
menu.addItem((i: any) => {
i.setTitle(lk.count > 1 ? "Delete encrypted notes…" : "Delete encrypted note…").setIcon("trash-2");
i.setWarning?.(true);
i.onClick(() => {
new ConfirmModal(
this.app,
lk.count > 1 ? `Delete ${lk.count} encrypted notes?` : "Delete encrypted note?",
"The encrypted file moves to Obsidian's trash (recoverable there). You'll still need your password to read it if you restore it.",
"Delete",
async (ok: boolean) => {
if (!ok) return;
const blobPath = lk.blob;
const metaPath = blobPath.replace(/\.stashenc$/, ".stashmeta");
const adapter = this.app.vault.adapter;
// Capture everything needed to restore BEFORE removing anything:
// the ciphertext bytes, the sidecar JSON, and the registry entry.
let blobData: ArrayBuffer | null = null;
let metaData: string | null = null;
try { blobData = await adapter.readBinary(blobPath); } catch { blobData = null; }
if (!blobData) { new Notice("Couldn't read the encrypted file to delete it."); return; }
try { if (await adapter.exists(metaPath)) metaData = await adapter.read(metaPath); } catch { metaData = null; }
const prevEntry = (this.plugin.settings.lockedSubtrees ?? []).find((x) => x.blob === blobPath) ?? null;
// Trash both files (Obsidian trash when the TFile is indexed —
// recoverable there; adapter.remove as a fallback when the index
// lags, e.g. right after a redo-restore). Contents are captured
// above, so the undo entry can restore either way.
const removeBoth = async (): Promise<boolean> => {
const trashByPath = async (path: string) => {
const tf = this.app.vault.getAbstractFileByPath(path);
if (tf) { try { await this.app.fileManager.trashFile(tf); return; } catch { /* fall through */ } }
try { if (await adapter.exists(path)) await adapter.remove(path); } catch { /* non-fatal */ }
};
await trashByPath(blobPath);
await trashByPath(metaPath);
return true;
};
const doDelete = async () => {
await removeBoth();
this.plugin.settings.lockedSubtrees = (this.plugin.settings.lockedSubtrees ?? []).filter((x) => x.blob !== blobPath);
await this.plugin.saveSettings();
this.render();
};
const doRestore = async () => {
await adapter.writeBinary(blobPath, blobData!);
if (metaData != null) { try { await adapter.write(metaPath, metaData); } catch { /* non-fatal */ } }
if (prevEntry && !(this.plugin.settings.lockedSubtrees ?? []).some((x) => x.blob === blobPath)) {
this.plugin.settings.lockedSubtrees = [...(this.plugin.settings.lockedSubtrees ?? []), prevEntry];
await this.plugin.saveSettings();
}
this.render();
};
await doDelete();
this.plugin.getUndoStack(this.noteFolder).push({
label: lk.count > 1 ? "Delete encrypted notes" : "Delete encrypted note",
undo: doRestore,
redo: doDelete,
});
new Notice(lk.count > 1 ? "Encrypted notes deleted — undo to restore." : "Encrypted note deleted — undo to restore.");
},
).open();
});
populateLockedMenu(menu, {
app: this.app,
plugin: this.plugin,
descriptor: { blob: lk.blob, rootId: lk.rootId, count: lk.count, folder: this.noteFolder },
unlockLabel: "Decrypt (unlock)",
onUnlock: () => void doUnlock(e),
onChange: () => this.render(),
fullParity: true,
confirmDelete: true,
});
menu.showAtMouseEvent(e);
};
@ -3441,6 +3328,10 @@ export class StashpadView extends ItemView {
menu.addItem((it: any) => it.setTitle("Copy").setIcon("copy").setDisabled(!hasTargets).onClick(() => void this.cmdCopy()));
menu.addItem((it: any) => it.setTitle("Copy tree").setIcon("copy-plus").setDisabled(!hasTargets).onClick(() => void this.cmdCopyTree()));
menu.addItem((it: any) => it.setTitle("Clone (duplicate / copy)").setIcon("files").setDisabled(!hasTargets).onClick(() => void this.cmdClone()));
// 0.155.1: Share & export ▸ — same submenu as the desktop context menu (the
// ⚡ menu previously had no copy-link/export). Copy-link targets the primary
// selected note; exports act on the whole selection.
this.addShareExportSubmenu(menu, this.getActionTargets()[0] ?? null, { normalizeToNode: false });
if (this.plugin.settings.enableSheetVersions) {
menu.addItem((it: any) => it.setTitle("Fork as a version (draft)").setIcon("git-fork").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdForkVersion()));
menu.addItem((it: any) => it.setTitle("Mark version as final").setIcon("star").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdMarkVersionFinal()));
@ -6828,7 +6719,11 @@ export class StashpadView extends ItemView {
* keybind counterpart of the context-menu "Encrypt (lock) note + children".
* If a parent AND one of its descendants are both selected, only the parent
* is locked (its subtree already subsumes the descendant). */
async cmdLockSelection(): Promise<void> {
/** Lock (encrypt) the selection AND hide each note's filename, regardless of
* the folder's encrypt-filenames pref. Backs the "hide filename" command. */
cmdLockSelectionHideName(): Promise<void> { return this.cmdLockSelection({ hideName: true }); }
async cmdLockSelection(opts: { hideName?: boolean } = {}): Promise<void> {
if (!this.plugin.encryption?.isConfigured?.()) {
new Notice("Set up encryption first (Settings → Stashpad → Encryption).");
return;
@ -6856,7 +6751,7 @@ export class StashpadView extends ItemView {
const idx = order.indexOf(t.id);
const prevSibling = idx > 0 ? order[idx - 1] : null;
// Silent per-item; one summary toast below (a batch shouldn't spam).
const r = await this.plugin.lockNoteSubtree(this.noteFolder, t.id, prevSibling, { silent: true });
const r = await this.plugin.lockNoteSubtree(this.noteFolder, t.id, prevSibling, { silent: true, hideTitle: opts.hideName });
if (r) { locked++; lockedItems.push({ rootId: t.id, prevSibling, blob: r.blobPath }); await this.log.append({ type: "lock", id: t.id }); }
}
if (locked > 0) {
@ -6869,7 +6764,7 @@ export class StashpadView extends ItemView {
this.plugin.getUndoStack(folder).push({
label: `Lock ${locked} stash${locked === 1 ? "" : "es"}`,
undo: async () => { for (const it of lockedItems) { try { await this.plugin.unlockBundleAt(it.blob, { silent: true }); } catch { /* leave the rest */ } } this.render(); },
redo: async () => { for (const it of lockedItems) { const rr = await this.plugin.lockNoteSubtree(folder, it.rootId, it.prevSibling, { silent: true }); if (rr) it.blob = rr.blobPath; } this.render(); },
redo: async () => { for (const it of lockedItems) { const rr = await this.plugin.lockNoteSubtree(folder, it.rootId, it.prevSibling, { silent: true, hideTitle: opts.hideName }); if (rr) it.blob = rr.blobPath; } this.render(); },
});
this.plugin.notifications.show({ message: `Locked ${locked} stash${locked === 1 ? "" : "es"} — undo to unlock.`, kind: "success", category: "system", folder: this.noteFolder, actions: [{ label: "All encrypted", onClick: () => void openAggregateView(this.plugin, "encrypted") }] });
}
@ -9441,17 +9336,21 @@ export class StashpadView extends ItemView {
* selected note). Paste it anywhere clicking it lands back on this exact
* note. Uses the note's stable frontmatter `id`, so it survives renames. */
async cmdCopyStashpadLink(node?: TreeNode): Promise<void> {
const target = node ?? this.getActionTargets()[0];
if (!target?.id) { new Notice("No note selected to link to."); return; }
const link = buildStashpadLink({
// A specific node (right-click) links just that note; otherwise link EVERY
// action target — so a multi-selection (⚡ menu / hotkey) copies one deep
// link per selected note, newline-separated, rather than only the first.
const targets = node ? [node] : this.getActionTargets();
const valid = targets.filter((t) => !!t?.id);
if (valid.length === 0) { new Notice("No note selected to link to."); return; }
const links = valid.map((t) => buildStashpadLink({
vault: this.app.vault.getName(),
folder: this.noteFolder,
note: target.id,
note: t.id,
run: ["reveal"],
});
}));
try {
await navigator.clipboard.writeText(link);
new Notice("Stashpad link copied.");
await navigator.clipboard.writeText(links.join("\n"));
new Notice(links.length > 1 ? `${links.length} Stashpad links copied.` : "Stashpad link copied.");
} catch {
new Notice("Couldn't copy the link to the clipboard.");
}
@ -9963,8 +9862,20 @@ export class StashpadView extends ItemView {
const knownAuthors = this.plugin.collectKnownAuthors();
const currentAssignees = parseAssignees(curFm ?? {});
new DueDatePickerModal(this.app, current, (result) => {
void this.applyDue(targets, result.iso, result.assignees);
}, { knownAuthors, currentAssignees, title: "Assign / schedule task" }).open();
void this.applyDue(targets, result.iso, result.assignees, false, {
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery,
});
}, { knownAuthors, currentAssignees, title: "Assign / schedule task",
// 0.155.0: Assign opens the SAME picker as "Set due date" with the full
// control set — the quick +/- adjust row (was silently missing here) plus
// recurrence (single-target only; see cmdSetDue). Unifies the two entry
// points so the modal is identical regardless of how it's opened.
quickAdjusts: this.plugin.settings.dueQuickAdjusts,
showRecurrence: targets.length === 1,
currentRepeat: typeof curFm?.repeat === "string" ? curFm.repeat : "",
currentAutoDoneAfter: typeof curFm?.autoDoneAfter === "string" ? curFm.autoDoneAfter : "",
currentRemindEvery: typeof curFm?.remindEvery === "string" ? curFm.remindEvery : "",
}).open();
}
/** 0.76.3: a note is a task when it carries the `task` tag in
@ -11725,6 +11636,40 @@ export class StashpadView extends ItemView {
requestAnimationFrame(watchdog);
}
/** 0.155.1: shared "Share & export " submenu used by BOTH the desktop note
* context menu (`openNoteMenu`) and the mobile actions menu, so the two
* entry points stay in sync (the menu previously had no copy-link/export at
* all). Copy Stashpad link acts on `node` (disabled when null); the exports
* act on `node` (right-click semantics, `normalizeToNode: true`) or on the
* current selection ( menu, `false`). Degrades to the command palette when
* the running Obsidian lacks setSubmenu (never on the 1.13 floor). */
private addShareExportSubmenu(menu: Menu, node: TreeNode | null, opts: { normalizeToNode: boolean }): void {
const norm = (): void => {
if (opts.normalizeToNode && node && !this.selection.has(node.id)) {
this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id;
}
};
const add = (target: { addItem: (cb: (it: any) => unknown) => unknown }): void => {
target.addItem((it: any) => {
it.setTitle("Copy Stashpad link").setIcon("link");
// Right-click (normalizeToNode) links the clicked note; the ⚡ menu links
// the WHOLE selection (multi-link) via the no-arg selection path.
if (node) it.onClick(() => void this.cmdCopyStashpadLink(opts.normalizeToNode ? node : undefined));
else it.setDisabled(true);
});
target.addItem((it: any) => it.setTitle("Export to .stash…").setIcon("package").onClick(() => { norm(); void this.cmdExportStash(); }));
if (this.plugin.settings.okfEnabled) {
target.addItem((it: any) => it.setTitle("Export as OKF…").setIcon("book-marked").onClick(() => { norm(); void this.cmdExportOkf(); }));
}
};
menu.addItem((it: any) => {
it.setTitle("Share & export").setIcon("share-2");
const sub = typeof it.setSubmenu === "function" ? it.setSubmenu() : null;
if (sub && typeof sub.addItem === "function") add(sub);
else it.onClick(() => this.openCommandPalette());
});
}
private openNoteMenu(evt: MouseEvent, node: TreeNode): void {
if (!node.file) return;
const file = node.file;
@ -11736,7 +11681,6 @@ export class StashpadView extends ItemView {
void this.openFileAtEnd(file);
}));
menu.addItem((it: any) => it.setTitle("Focus in Stashpad").setIcon("arrow-right").onClick(() => this.navigateTo(node.id)));
menu.addItem((it: any) => it.setTitle("Copy Stashpad link").setIcon("link").onClick(() => void this.cmdCopyStashpadLink(node)));
menu.addSeparator();
menu.addItem((it: any) => it.setTitle("Split note…").setIcon("split").onClick(() => void this.cmdSplit(node)));
// 0.122.2 (#9): copy the note's text. `focusClicked` (defined below)
@ -11757,20 +11701,12 @@ export class StashpadView extends ItemView {
}));
// 0.122.2 (#9): "Insert template…" removed from the right-click menu to keep
// it compact — still available via command palette + its hotkey.
menu.addItem((it: any) => it.setTitle("Export to .stash").setIcon("package").onClick(() => {
// Multi-select normalisation (matches Clone / Delete / Set color):
// if the right-clicked row isn't in the selection, treat the
// right-click as a single-target action. Otherwise honour the
// full selection.
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.155.0: Copy Stashpad link + both export flows grouped under a shared
// "Share & export ▸" submenu (mirrors the Task ▸ submenu + the locked-row
// menu) so the top level stays short. Multi-select normalisation matches
// Clone / Delete / Set color. Degrades to the command palette if the running
// Obsidian lacks setSubmenu (never on the 1.13 minAppVersion floor).
this.addShareExportSubmenu(menu, node, { normalizeToNode: true });
// 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?.()) {