mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
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:
parent
0b5e0e6ed8
commit
bd4b50576a
12 changed files with 675 additions and 296 deletions
22
.gitattributes
vendored
22
.gitattributes
vendored
|
|
@ -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
179
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
57
release-notes/0.157.0.md
Normal 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).
|
||||
|
|
@ -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 -------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
195
src/locked-menu.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
71
src/main.ts
71
src/main.ts
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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=…¬e=…";
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
246
src/view.ts
246
src/view.ts
|
|
@ -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?.()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue