0.140.8: per-folder archive/trash subfolders, encryption cleanup, smart re-encrypt, recurring reminders, deep review pass

This commit is contained in:
Human 2026-07-02 21:31:31 -07:00
parent b5afc87d38
commit fdf6f3c336
37 changed files with 2459 additions and 520 deletions

182
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.133.0",
"version": "0.140.8",
"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.133.0",
"version": "0.140.8",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

114
release-notes/0.140.8.md Normal file
View file

@ -0,0 +1,114 @@
# 0.140.8 — Per-folder archive/trash subfolders, encryption cleanup, smart re-encrypt, recurring reminders, and a deep review pass
A large release spanning several feature phases since 0.133.0: archives and
encrypted trash move to per-folder subfolders, the encryption model is
simplified and hardened, a "smart re-encrypt" workflow keeps previously-locked
notes protected, reminders gain recurrence, and a multi-wave correctness/security
review fixes a long tail of bugs.
## Archive & aggregate views (0.134.x)
- **0.134.0** — Aggregate command wording clarified; archives now default to
plaintext (opt-in encryption) and the encryption settings are aligned to match.
- **0.134.1** — Aggregate view command names simplified to "Open aggregated X
view" (no parenthetical).
- **0.134.2** — "Open aggregated Encrypted notes view" gains the noun so it's
findable by searching "notes"/"contents".
- **0.134.3** — The three separate encryption warnings are merged into one
callout, and the danger buttons are made quieter/less alarming.
- **0.134.4** — Archive bug fixes B1B6: default-folder mismatch, a stale copy
left behind, encryption-state desync with the store, and zombie-entry pruning.
## Encryption cleanup (0.135.x)
- **0.135.0** — Encryption cleanup: key backups are parked (not destroyed) on
removal, the "Lock now" and idle auto-lock controls are dropped, and the
Sharing UI is hidden.
- **0.135.1** — Keychain no-overwrite policy: any existing keychain entry is
parked before a new write, so an old key is never silently clobbered.
## Per-folder archive subfolders (0.136.0)
- Reserve per-folder `archive/` and `trash/` subfolder names.
- "Move to archive" now targets the current folder's own `archive/` subfolder.
- The Archived view scans each folder's `archive/` subfolder; un-archiving
restores a note to its parent folder.
- Imported originals are filed under `archive/_imported/<date>/`.
- A one-time migration moves legacy archives into the new layout and removes the
old global archive settings/UI.
- Migration hardening (from live testing): the home note is preserved, the sweep
is suppressed where it shouldn't run, home-note detection reads from disk,
moves are verified, a recovery journal is kept, and failed moves are retried.
## Per-folder encryption fixes & trash (0.136.1 0.137.x)
- **0.136.1** — Per-folder toggle fixes, keychain cycle-unlock, and a loading
state on the Remove button.
- **0.136.2** — A Restore button on locked rows in the aggregated Archived view.
- **0.137.0** — Phase 2: per-folder encrypted trash (`X/trash/`) with a
conservative migration of the legacy vault-level `_deleted/`.
- **0.137.1** — Remove the global "hide locked titles" and "follow Obsidian
trash" options — the per-folder settings now cover both.
- **0.137.2** — Retro-apply: enabling "Encrypt archived notes" offers to lock
whatever is already archived.
- **0.137.3** — Multi-writer settings-collision guard for dual instances,
network drives, and sync clients.
- **0.137.4** — A collapsible "How encryption works" concepts section in the
Encryption settings.
## Smart re-encrypt & timed peek (0.138.0 0.139.0)
- **0.138.0** — Smart re-encrypt sweep: a watchlist of previously-encrypted
notes, a "Previously encrypted" view, a one-modal batch re-lock, and a nudge
to re-protect notes left in plaintext.
- **0.139.0** — Timed "peek": temporarily decrypt a note and have it
automatically re-encrypt after a set interval — opt-in, cancellable, and
safeguarded against re-locking a note that's still being edited. A follow-up
fix strips two stray NUL bytes accidentally introduced in the scheduler.
## Recurring reminders (0.140.0)
- Recurring and persistent reminders — the deferred major feature — land: a
reminder can repeat on a schedule and survive restarts.
## Correctness & security review (0.140.1 0.140.8)
A multi-wave review (finding, then adversarially verifying each finding) fixed a
long tail of bugs. Grouped by the batch that shipped them:
- **0.140.1** — Fixes for the first review wave: a data-loss bug in per-folder
trash, recurrence undo, and assorted peek/reminder/crypto issues.
- **0.140.2** — Review deferrals batch 1: decompression-bomb caps on zip import,
DEK-length validation, a leaf-return listener leak, content-aware merge in the
guarded settings save, undo-on-failure retry, and peek-input coercion.
- **0.140.3** — Review wave 2: tree-index cycle guards (self-parent in the
incremental path plus a multi-node reachability sweep), a divider-drag listener
leak with proper teardown, folder-rename path-remap across all path-keyed
settings, and merge-before-write in the order/sort stores.
- **0.140.4** — Batch B: a drag-and-drop cycle guard for before/after drops (a
parent dropped adjacent to its own child could write a cycle to frontmatter),
an author-rename in-flight guard leak, an integrity-watcher sweep race, and a
cap on the import log.
- **0.140.5** — Batch C: the time picker treats a typed hour `0`/`00` as
midnight; filename id-parsing is tightened to the exact id shape; task titles
strip only the real id (no longer eating a trailing word like "review");
numeric `due` values parse; the tasks view uses calendar-day boundaries for
"today"; the render cache re-arms its dirty flag on a failed write; and
undo/redo push the action back on failure so it stays retryable.
- **0.140.6** — Batch D: untrusted `.stash` imports now strip reserved
frontmatter (preventing authorship/pin/due/assignee spoofing); the detail
panel validates a note's color on read (blocking a `url(...)` CSS request) and
guards its child recursion against a looping tree; destructive confirmation
dialogs focus Cancel so a stray Enter can't fire them; the custom-color dialog
no longer paints its default when dismissed untouched; and the color-picker
registers its keyboard handlers once.
- **0.140.7** — Batch E (views): the Trash permanent-delete confirmation focuses
Cancel so Enter can't irreversibly purge; the shift-range selection anchor is
tracked by item identity so it survives a restore/purge; "Restore selected"
surfaces partial failures; and the aggregate view resolves an archived blob's
restore destination to the nearest `archive/` folder.
- **0.140.8** — Batch E (crypto): the mid-lock "don't destroy a file edited
since it was bundled" guard now covers attachments, not just notes; and
encrypting-to-trash rolls back and aborts (keeping the note intact) if the
trash metadata sidecar can't be written, rather than deleting the plaintext
and stranding the content.

View file

@ -2,12 +2,60 @@
# Reset a Claude Dev Vault TEST folder to its canonical state — fast vault reset
# between tests. ONLY Alpha/Beta; NEVER the "Stashpad" folder (real data) or
# "_deleted". Usage: scripts/reset-test-folder {Alpha|Beta}
#
# 2026-07-02: routes through obs-dev when the dedicated dev instance is up (it
# OWNS the Claude Dev Vault, so the obsidian-cli can't reach it), else falls back
# to the obsidian-cli. Both calls are wrapped in a hard timeout so a driver that
# can't reach the vault fails FAST instead of hanging forever (the overnight bug:
# reset-test-folder blocked >2min because `obs` targeted the main instance while
# obs-dev held the vault, with no timeout).
DIR="$(cd "$(dirname "$0")" && pwd)"
OBS="$DIR/obs"
F="$1"
case "$F" in
Alpha|Beta) ;;
*) echo "usage: reset-test-folder {Alpha|Beta} (never touches Stashpad)" >&2; exit 1;;
esac
"$OBS" eval code="window.__resetFolder='$F'" >/dev/null 2>&1
"$OBS" eval-file "$DIR/reset-test-folder.js"
# Portable 45s timeout: use timeout/gtimeout if present (Linux / brew coreutils),
# else a pure-sh shim (macOS ships neither). Returns 124 on timeout, like GNU.
TIMEOUT_SECS=45
if command -v timeout >/dev/null 2>&1; then
run() { timeout "$TIMEOUT_SECS" "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then
run() { gtimeout "$TIMEOUT_SECS" "$@"; }
else
run() {
"$@" & cmd_pid=$!
( sleep "$TIMEOUT_SECS"; kill -TERM "$cmd_pid" 2>/dev/null ) & watch_pid=$!
wait "$cmd_pid" 2>/dev/null; rc=$?
# Discriminator: if the watcher is STILL alive (sleeping), the command
# finished first → keep its real rc and cancel the watcher. If the watcher
# is already gone, it fired the TERM → this was a timeout (124).
if kill -0 "$watch_pid" 2>/dev/null; then
kill "$watch_pid" 2>/dev/null; wait "$watch_pid" 2>/dev/null
else
rc=124
fi
return "$rc"
}
fi
# Which driver owns the vault? obs-dev listens on :9222 when running. Its eval
# takes JS positionally; the obsidian-cli takes it as `code=<js>`.
if curl -s --max-time 2 "http://localhost:9222/json/version" >/dev/null 2>&1; then
echo "reset-test-folder: using obs-dev (dedicated instance holds the vault)"
run "$DIR/obs-dev" eval "window.__resetFolder='$F'" >/dev/null 2>&1
run "$DIR/obs-dev" eval-file "$DIR/reset-test-folder.js"
rc=$?
else
echo "reset-test-folder: using obsidian-cli (obs-dev not running)"
run "$DIR/obs" eval code="window.__resetFolder='$F'" >/dev/null 2>&1
run "$DIR/obs" eval-file "$DIR/reset-test-folder.js"
rc=$?
fi
if [ "$rc" = 124 ]; then
echo "reset-test-folder: TIMED OUT after 45s — is the right instance holding the Claude Dev Vault?" >&2
echo " (obs-dev running? it's used automatically. Else put your main Obsidian on the Claude Dev Vault.)" >&2
fi
exit "$rc"

View file

@ -1,6 +1,6 @@
import { ItemView, TFile, WorkspaceLeaf, moment, setIcon, type ViewStateResult } from "obsidian";
import type StashpadPlugin from "./main";
import { STASHPAD_AGGREGATE_VIEW_TYPE } from "./types";
import { STASHPAD_AGGREGATE_VIEW_TYPE, archiveSubfolderOf } from "./types";
import { renderTaskTriage, defaultTaskTriageState, type TaskTriageState } from "./task-render";
import { renderAggModeBar, type AggMode } from "./agg-modes";
import { returnToOriginOnClose } from "./leaf-return";
@ -8,7 +8,7 @@ import { returnToOriginOnClose } from "./leaf-return";
// Obsidian types `moment` as a namespace (not callable); cast to a callable.
const momentFn = moment as unknown as (...args: unknown[]) => { fromNow: () => string };
export type AggregateMode = "encrypted" | "archived" | "tasks";
export type AggregateMode = "encrypted" | "archived" | "tasks" | "watch";
interface AggregateState { mode: AggregateMode }
@ -35,12 +35,12 @@ export class StashpadAggregateView extends ItemView {
constructor(leaf: WorkspaceLeaf, private plugin: StashpadPlugin) { super(leaf); }
getViewType(): string { return STASHPAD_AGGREGATE_VIEW_TYPE; }
getDisplayText(): string { return this.mode === "archived" ? "All archived" : this.mode === "tasks" ? "All tasks" : "All encrypted"; }
getIcon(): string { return this.mode === "archived" ? "archive" : this.mode === "tasks" ? "check-square" : "lock"; }
getDisplayText(): string { return this.mode === "archived" ? "All archived" : this.mode === "tasks" ? "All tasks" : this.mode === "watch" ? "Previously encrypted" : "All encrypted"; }
getIcon(): string { return this.mode === "archived" ? "archive" : this.mode === "tasks" ? "check-square" : this.mode === "watch" ? "history" : "lock"; }
getState(): Record<string, unknown> { return { ...super.getState(), mode: this.mode }; }
async setState(state: AggregateState, result: unknown): Promise<void> {
if (state?.mode === "archived" || state?.mode === "encrypted" || state?.mode === "tasks") this.mode = state.mode;
if (state?.mode === "archived" || state?.mode === "encrypted" || state?.mode === "tasks" || state?.mode === "watch") this.mode = state.mode;
await super.setState(state, result as ViewStateResult);
await this.render();
}
@ -63,9 +63,15 @@ export class StashpadAggregateView extends ItemView {
private cleanFolder(p: string): string { return (p || "").replace(/\/+$/, ""); }
/** All folders flagged as archive — legacy list new per-folder prefs. */
/** 0.136.0: every folder's `archive/` subfolder that exists on disk, plus any
* LEGACY dedicated archive folders still present (pre-migration vaults). */
private archiveFolders(): string[] {
const set = new Set<string>((this.plugin.settings.archiveFolders ?? []).map((f) => this.cleanFolder(f)));
const set = new Set<string>();
for (const f of this.plugin.discoverStashpadFolders()) {
const sub = archiveSubfolderOf(f);
if (this.plugin.app.vault.getAbstractFileByPath(sub)) set.add(sub);
}
for (const f of (this.plugin.settings.archiveFolders ?? [])) set.add(this.cleanFolder(f));
const prefs = this.plugin.settings.folderEncPrefs ?? {};
for (const [folder, p] of Object.entries(prefs)) if (p?.archive) set.add(this.cleanFolder(folder));
return [...set].sort((a, b) => a.localeCompare(b));
@ -90,6 +96,7 @@ export class StashpadAggregateView extends ItemView {
return;
}
if (this.mode === "archived") { this.renderArchived(root); return; }
if (this.mode === "watch") { this.renderWatch(root); return; }
this.renderEncrypted(root);
}
@ -232,7 +239,7 @@ export class StashpadAggregateView extends ItemView {
restore.onclick = async () => { restore.disabled = true; const ok = await this.plugin.unarchiveNote(f); if (ok) void this.render(); else restore.disabled = false; };
}
/** One locked archived subtree — Export (un-archiving encrypted needs a password). */
/** One locked archived subtree — Restore (unlock, needs the password) + Export. */
private archiveLockedRow(container: HTMLElement, e: { blob: string; title?: string; count?: number; created?: string }): void {
const row = container.createDiv({ cls: "stashpad-trash-row" });
const main = row.createDiv({ cls: "stashpad-trash-row-main" });
@ -240,6 +247,25 @@ export class StashpadAggregateView extends ItemView {
const when = e.created ? `archived ${momentFn(e.created).fromNow()}` : "archived";
const count = (e.count ?? 0) > 1 ? ` · ${e.count} notes` : "";
main.createSpan({ cls: "stashpad-trash-sub", text: when + count });
// 0.136.2: Restore on locked rows too (mirrors the trash view). Unlocks the
// bundle (prompts for the password if needed) and restores the notes to the
// archive's PARENT folder; a legacy dedicated-archive blob (no /archive/
// segment) unlocks in place, as the placeholder's Unlock always did.
const restore = row.createEl("button", { cls: "stashpad-trash-restore", text: "Restore" });
setIcon(restore.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
restore.setAttr("aria-label", "Unlock and restore (needs the password)");
restore.onclick = async () => {
restore.disabled = true;
const segs = e.blob.split("/");
// lastIndexOf, not indexOf: the archive dir holding the blob is the one
// NEAREST it. indexOf matched a higher-level folder literally named
// "archive" (an "archive" folder at the vault root, or
// Work/archive/Projects/archive/blob) → restored to the wrong parent. (0.140.7)
const archIdx = segs.lastIndexOf("archive");
const 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(); else restore.disabled = false;
};
const exp = row.createEl("button", { cls: "stashpad-trash-iconbtn", text: "Export" });
exp.setAttr("aria-label", "Export to .stash (encrypted with a password you choose)");
exp.onclick = () => void this.plugin.exportLockedSubtree(e.blob);
@ -247,6 +273,109 @@ export class StashpadAggregateView extends ItemView {
setIcon(lock, "lock");
}
/** 0.138.0 "Previously encrypted": subtrees that WERE encrypted and are now
* plaintext (unlocked or restored from trash). Watching + a recoverable
* "Removed from watchlist" section; per-SUBTREE rows. */
private watchFolderFilter = "";
private watchViaFilter: "" | "unlock" | "restore" = "";
private watchQuery = "";
private renderWatch(root: HTMLElement): void {
// 0.138.0: drop ghost entries (deleted / re-locked elsewhere) before showing.
if (this.plugin.pruneReEncryptWatch()) void this.plugin.saveSettings();
const all = this.plugin.settings.reEncryptWatch ?? [];
const watching = all.filter((w) => !w.removed);
const removed = all.filter((w) => w.removed);
if (all.length === 0) {
root.createDiv({ cls: "stashpad-trash-empty", text: "Nothing here yet. When you unlock an encrypted note (or restore one from the trash), it shows up here so you can re-encrypt it later." });
return;
}
// Filter bar: folder + how-it-got-here + Sift search.
const bar = root.createDiv({ cls: "stashpad-trash-modes" });
const folderSel = bar.createEl("select", { cls: "dropdown" });
folderSel.createEl("option", { value: "", text: "All folders" });
for (const f of [...new Set(watching.map((w) => w.folder))].sort()) folderSel.createEl("option", { value: f, text: f });
folderSel.value = this.watchFolderFilter;
folderSel.onchange = () => { this.watchFolderFilter = folderSel.value; void this.render(); };
const viaSel = bar.createEl("select", { cls: "dropdown" });
viaSel.createEl("option", { value: "", text: "Unlocked + restored" });
viaSel.createEl("option", { value: "unlock", text: "Unlocked" });
viaSel.createEl("option", { value: "restore", text: "Restored from trash" });
viaSel.value = this.watchViaFilter;
viaSel.onchange = () => { this.watchViaFilter = viaSel.value as typeof this.watchViaFilter; void this.render(); };
const search = bar.createEl("input", { type: "text", attr: { placeholder: "Search\u2026" } });
search.value = this.watchQuery;
search.oninput = () => { this.watchQuery = search.value; renderLists(); };
// Sift: all tokens, any order, case-insensitive substring.
const sift = (q: string, hay: string): boolean =>
q.toLowerCase().split(/\s+/).filter(Boolean).every((t) => hay.toLowerCase().includes(t));
const matches = (w: (typeof all)[number]): boolean =>
(!this.watchFolderFilter || w.folder === this.watchFolderFilter)
&& (!this.watchViaFilter || w.via === this.watchViaFilter)
&& (!this.watchQuery.trim() || sift(this.watchQuery, `${w.title} ${w.folder}`));
const save = async () => { await this.plugin.saveSettings(); void this.render(); };
const row = (host: HTMLElement, w: (typeof all)[number], inRemoved: boolean): void => {
const r = host.createDiv({ cls: "stashpad-trash-row" });
const main = r.createDiv({ cls: "stashpad-trash-row-main" });
main.createSpan({ cls: "stashpad-trash-title", text: w.title || "(untitled)" });
const when = momentFn(w.unlockedAt).fromNow();
const count = w.count > 1 ? ` \u00b7 ${w.count} notes` : "";
main.createSpan({ cls: "stashpad-trash-sub", text: `${w.via === "restore" ? "restored" : "unlocked"} ${when} \u00b7 ${w.folder.split("/").pop() || w.folder}${count}` });
if (!inRemoved) {
const relock = r.createEl("button", { cls: "stashpad-trash-restore", text: "Re-encrypt" });
setIcon(relock.createSpan({ cls: "stashpad-btn-icon" }), "lock");
relock.onclick = async () => {
relock.disabled = true;
const ok = await this.plugin.lockNoteSubtree(w.folder, w.rootId, null, {});
// Success clears the entry via the lock hook; either way, repaint truth.
if (!ok) relock.disabled = false;
void this.render();
};
// 0.139.0 per-note peek override: minutes until auto-re-encrypt. Blank =
// inherit folder/global; 0 = off for this note.
const peek = r.createEl("input", { type: "number", cls: "stashpad-watch-peek", attr: { placeholder: "auto", title: "Auto re-encrypt after N idle minutes (blank = folder/global default, 0 = off)", min: "0" } });
peek.value = w.peekMin == null ? "" : String(w.peekMin);
peek.onchange = () => { const s = peek.value.trim(); const n = Number(s); if (s === "") delete w.peekMin; else if (Number.isFinite(n) && n >= 0) w.peekMin = Math.floor(n); else { peek.value = w.peekMin == null ? "" : String(w.peekMin); return; } void this.plugin.saveSettings(); };
const rm = r.createEl("button", { cls: "stashpad-trash-iconbtn" });
setIcon(rm, "x");
rm.setAttr("aria-label", "Remove from watchlist (recoverable below)");
rm.onclick = () => { w.removed = true; w.removedAt = new Date().toISOString(); void save(); };
} else {
const un = r.createEl("button", { cls: "stashpad-trash-iconbtn", text: "Un-remove" });
un.onclick = () => { delete w.removed; delete w.removedAt; void save(); };
}
};
const listsHost = root.createDiv();
const renderLists = (): void => {
listsHost.empty();
const shown = watching.filter(matches).sort((a, b) => (b.unlockedAt || "").localeCompare(a.unlockedAt || ""));
const head = listsHost.createDiv({ cls: "stashpad-trash-group-head" });
setIcon(head.createSpan({ cls: "stashpad-trash-group-icon" }), "history");
head.createSpan({ text: "Watching" });
head.createSpan({ cls: "stashpad-trash-group-count", text: String(shown.length) });
if (watching.length > 0) {
const sweep = head.createEl("button", { cls: "stashpad-trash-iconbtn", text: "Re-encrypt all applicable" });
sweep.onclick = () => void this.plugin.encryptEverythingApplicable();
}
if (shown.length === 0) listsHost.createDiv({ cls: "stashpad-trash-sub", text: watching.length ? "No matches for the current filters." : "Nothing being watched." });
const group = listsHost.createDiv({ cls: "stashpad-trash-group" });
for (const w of shown) row(group, w, false);
if (removed.length > 0) {
const det = listsHost.createEl("details", { cls: "stashpad-watch-removed" });
det.createEl("summary", { text: `Removed from watchlist (${removed.length})` });
const rg = det.createDiv({ cls: "stashpad-trash-group" });
for (const w of removed.filter(matches)) row(rg, w, true);
}
};
renderLists();
}
async onClose(): Promise<void> { this.contentEl.empty(); }
}
@ -263,5 +392,5 @@ export async function openAggregateView(plugin: StashpadPlugin, mode: AggregateM
const leaf = workspace.getLeaf("tab");
await leaf.setViewState({ type: STASHPAD_AGGREGATE_VIEW_TYPE, active: true, state: { mode } });
workspace.revealLeaf(leaf);
returnToOriginOnClose(workspace, leaf, originLeaf);
returnToOriginOnClose(workspace, leaf, originLeaf, (ref) => plugin.registerEvent(ref));
}

View file

@ -227,7 +227,7 @@ export async function processStashFile(view: StashpadView, file: TFile): Promise
// 0.85.4: try a passphrase remembered for this filename first (silent).
const buf = await resolveStashBytes(view.app, raw, { secretId: secretIdForStashName(file.basename) });
if (!buf) return; // user cancelled the password prompt — leave the file as-is
const summary = await importStashZip(view.app, buf, view.noteFolder, collectExistingIds(view));
const summary = await importStashZip(view.app, buf, view.noteFolder, collectExistingIds(view), { stripReserved: true });
view.tree.rebuild(view.noteFolder);
view.render();
await view.log.append({

View file

@ -11,6 +11,13 @@ import {
type StashpadId,
} from "./types";
/** A valid #RGB / #RRGGBB color. Read-path guard so untrusted frontmatter
* (shared/imported notes) can't slip a `url(...)` or other CSS value into an
* inline style. The write path already enforces #RRGGBB. */
function isHexColor(s: string): boolean {
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(s);
}
/** 0.74.1: right-sidebar detail panel. Mirrors the cursored note in the
* active Stashpad view title + rendered body + metadata + children
* list. Counterpart to the left-side panels view (Pinned/Shared/Tasks):
@ -193,7 +200,10 @@ export class StashpadDetailView extends ItemView {
const metaRow = header.createDiv({ cls: "stashpad-detail-metarow" });
if (sel.folder) metaRow.createSpan({ cls: "stashpad-detail-meta-chip", text: sel.folder.split("/").pop() || sel.folder });
if (typeof fm.color === "string" && fm.color) {
if (typeof fm.color === "string" && isHexColor(fm.color)) {
// Validate on READ, not just write: a shared/imported note carrying
// `color: url(http://attacker/pixel)` would otherwise trigger an external
// fetch (CSS tracking/exfil) when this panel renders it. (0.140.6 review.)
const c = metaRow.createSpan({ cls: "stashpad-detail-meta-color" });
c.style.background = fm.color;
c.title = fm.color;
@ -351,10 +361,14 @@ export class StashpadDetailView extends ItemView {
folder: string,
node: { id: string; file: TFile | null },
depth: number,
seen: Set<string> = new Set(),
): void {
if (!node.file) return;
// Cycle guard: a corrupted tree whose parent chain loops would otherwise
// recurse forever and blow the stack when the row is expanded. (0.140.6)
if (seen.has(node.id)) return;
const fm = (this.app.metadataCache.getFileCache(node.file)?.frontmatter ?? {}) as any;
const color = typeof fm.color === "string" ? fm.color : null;
const color = typeof fm.color === "string" && isHexColor(fm.color) ? fm.color : null;
const completed = fm.completed === true;
const grandchildren = tree.getChildren(node.id) as Array<{ id: string; file: TFile | null }>;
const hasGrandkids = grandchildren.length > 0;
@ -457,8 +471,9 @@ export class StashpadDetailView extends ItemView {
label.onclick = () => { if (view?.navigateTo) view.navigateTo(node.id); };
if (hasGrandkids && isExpanded) {
const nextSeen = new Set(seen).add(node.id);
for (const grandchild of grandchildren) {
this.renderDetailChildRow(parent, view, tree, folder, grandchild, depth + 1);
this.renderDetailChildRow(parent, view, tree, folder, grandchild, depth + 1, nextSeen);
}
}
}

View file

@ -165,6 +165,20 @@ async function purgeSubtreePlaintext(
}
for (const [path, af] of subtreeAtts) {
if (sharedExternally.has(path)) continue;
// Same mid-lock-edit guard as notes: if the attachment changed since it was
// baselined (re-pasted image, sync write) its newer bytes aren't in the blob
// — keep the plaintext rather than destroy the newer copy. (0.140.8)
const baseline = mtimes?.get(path);
if (baseline != null) {
try {
const st = await app.vault.adapter.stat(path);
if (st && st.mtime !== baseline) {
console.warn("[Stashpad] attachment changed since it was bundled — keeping plaintext", path);
unpurged.push(path);
continue;
}
} catch { /* stat failed — fall through and let delete try */ }
}
try { await app.vault.delete(af); }
catch (e) { console.warn("[Stashpad] couldn't delete exclusive attachment", path, e); unpurged.push(path); }
}
@ -188,6 +202,11 @@ export async function lockSubtree(
const mtimes = new Map<string, number>();
for (const n of allNodes) {
try { const st = await app.vault.adapter.stat(n.file.path); if (st) mtimes.set(n.file.path, st.mtime); } catch { /* no baseline → delete proceeds unguarded */ }
// Baseline the note's exclusive attachments too, so purge skips one edited
// mid-lock (its newer bytes aren't in the blob). (0.140.8)
for (const af of await resolveNoteAttachmentFiles(app, n.file)) {
try { const st = await app.vault.adapter.stat(af.path); if (st) mtimes.set(af.path, st.mtime); } catch { /* no baseline */ }
}
}
const zip = await buildStashZip(app, {
@ -304,6 +323,16 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
try { origin = safeVaultFolder(new TextDecoder().decode(await decryptWithKey(b64ToBytes(meta.originalFolderEnc), dek))); }
catch { origin = null; } // wrong key / tampered — fall back
}
const blobDir = blobPath.replace(/\/[^/]*$/, "");
// 0.140.1: for a PER-FOLDER trash blob (`X/trash/…`) whose sidecar origin no
// longer exists, the blob's OWN location is authoritative — the trash travels
// with its folder on rename (A→B moves A/trash into B/trash), so a stale
// sidecar `originalFolder: A` would resurrect a dead folder A and restore
// there instead of B. Prefer the blob's parent folder in that case.
const trashParent = /(^|\/)trash$/.test(blobDir) ? blobDir.replace(/\/?trash$/, "") : "";
if (origin && trashParent && origin !== trashParent && !(await app.vault.adapter.exists(origin))) {
origin = trashParent || null;
}
if (origin) {
// Recreate a missing origin folder rather than falling back: for trash
// blobs the fallback would be `_deleted/` itself — decrypted plaintext
@ -311,10 +340,17 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
if (!(await app.vault.adapter.exists(origin))) await app.vault.adapter.mkdir(origin);
return origin;
}
const blobDir = blobPath.replace(/\/[^/]*$/, "");
if (blobDir === DELETED_DIR || blobDir.startsWith(`${DELETED_DIR}/`)) {
throw new Error("This deleted note's origin folder is unknown (missing or tampered sidecar) — can't restore it safely. The encrypted copy was kept.");
}
// 0.137.0: a per-folder trash blob with no usable sidecar restores to the
// trash's PARENT folder (that IS its origin) — never into the reserved
// trash/ dir itself, where the plaintext would be hidden from every list.
if (/(^|\/)trash$/.test(blobDir)) {
const parent = blobDir.replace(/\/?trash$/, "");
if (parent) { if (!(await app.vault.adapter.exists(parent))) await app.vault.adapter.mkdir(parent); return parent; }
throw new Error("This deleted note's origin folder is unknown (missing or tampered sidecar) — can't restore it safely. The encrypted copy was kept.");
}
return blobDir;
}
@ -332,6 +368,9 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
* back. `deletedAt` is passed in (callers stamp it keeps this pure-ish). */
export async function deleteEncryptSubtree(
app: App, folder: string, rootId: StashpadId, dek: Uint8Array, deletedAt: string, hideTitle = false, keyId?: string,
/** 0.137.0: where the blob lands. Defaults to the legacy vault-level
* `_deleted/`; per-folder trash passes `<folder>/trash`. */
destDir: string = DELETED_DIR,
): Promise<{ blobPath: string; noteCount: number; rootId: StashpadId; originalFolder: string; title: string; unpurged: string[] }> {
const sub = await collectSubtree(app, folder, rootId);
if (!sub) throw new Error("Couldn't find that note to delete.");
@ -341,6 +380,9 @@ export async function deleteEncryptSubtree(
const mtimes = new Map<string, number>();
for (const n of allNodes) {
try { const st = await app.vault.adapter.stat(n.file.path); if (st) mtimes.set(n.file.path, st.mtime); } catch { /* no baseline */ }
for (const af of await resolveNoteAttachmentFiles(app, n.file)) {
try { const st = await app.vault.adapter.stat(af.path); if (st) mtimes.set(af.path, st.mtime); } catch { /* no baseline */ }
}
}
const zip = await buildStashZip(app, {
@ -354,13 +396,18 @@ export async function deleteEncryptSubtree(
if (back.length !== zip.length) throw new Error("Encryption self-check failed (size).");
for (let i = 0; i < zip.length; i++) if (back[i] !== zip[i]) throw new Error("Encryption self-check failed (content).");
if (!(await app.vault.adapter.exists(DELETED_DIR))) await app.vault.adapter.mkdir(DELETED_DIR);
// 0.137.0: dest may be nested (X/trash) — mkdir intermediates.
{
const parts = destDir.replace(/\/+$/, "").split("/").filter(Boolean);
let cur = "";
for (const p of parts) { cur = cur ? `${cur}/${p}` : p; try { if (!(await app.vault.adapter.exists(cur))) await app.vault.adapter.mkdir(cur); } catch { /* race */ } }
}
const cleanedFolder = folder.replace(/\/+$/, "");
const folderSlug = cleanedFolder.split("/").pop() || "vault";
// Readable name groups by folder; opaque (rootId) when hiding titles.
const base = hideTitle ? safeBlobBase(rootId) : safeBlobBase(`${folderSlug} ${titleFromFile(rootNote.file)}`);
let blobPath = `${DELETED_DIR}/${base}.${STASHENC_EXT}`;
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${DELETED_DIR}/${base} (${n}).${STASHENC_EXT}`;
let blobPath = `${destDir}/${base}.${STASHENC_EXT}`;
for (let n = 1; await app.vault.adapter.exists(blobPath); n++) blobPath = `${destDir}/${base} (${n}).${STASHENC_EXT}`;
await writeBlobVerified(app, blobPath, blob);
const all = allNodes;
@ -376,7 +423,15 @@ export async function deleteEncryptSubtree(
...(keyId ? { keyId } : {}),
};
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); }
catch (e) { console.warn("[Stashpad] couldn't write deleted sidecar", e); }
catch (e) {
// The sidecar carries the restore metadata (origin folder + parent). Without
// it a _deleted/ blob is UNRESTORABLE from the UI (deletedRestoreDest hard-
// throws). Purging here would strand the content. Roll back the blob and
// fail the op — the note stays intact as plaintext, nothing lost. (0.140.8)
console.warn("[Stashpad] couldn't write deleted sidecar — aborting delete, keeping plaintext", e);
try { await app.vault.adapter.remove(blobPath); } catch { /* leave orphan blob; plaintext is safe */ }
throw new Error("Couldn't write trash metadata — the note was NOT deleted (kept intact).");
}
const { unpurged } = await purgeSubtreePlaintext(app, all, mtimes);
return { blobPath, noteCount: all.length, rootId, originalFolder: cleanedFolder, title: meta.title, unpurged };
@ -564,10 +619,12 @@ export async function rewrapDeletedSidecar(app: App, blobPath: string, oldDek: U
await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta));
}
/** Trash blob paths in `_deleted/` whose sidecar `keyId` matches `keyId`. */
export async function deletedBlobsForKeyId(app: App, keyId: string): Promise<string[]> {
/** Trash blob paths (legacy `_deleted/` + per-folder trash dirs) whose sidecar
* `keyId` matches. Rotation callers MUST pass their trash dirs (0.137.0) or
* per-folder trash blobs would silently keep the retired key. */
export async function deletedBlobsForKeyId(app: App, keyId: string, extraDirs: string[] = []): Promise<string[]> {
const out: string[] = [];
for (const b of await listDeletedBlobs(app)) {
for (const b of await listDeletedBlobs(app, extraDirs)) {
const m = await readDeletedMeta(app, b);
if (m?.keyId === keyId) out.push(b);
}
@ -708,11 +765,21 @@ export async function listRawFolderBlobs(app: App): Promise<{ folder: string; bl
return out;
}
/** All encrypted-deleted blob paths in `_deleted/`. */
export async function listDeletedBlobs(app: App): Promise<string[]> {
if (!(await app.vault.adapter.exists(DELETED_DIR))) return [];
try {
const listing = await app.vault.adapter.list(DELETED_DIR);
return listing.files.filter((f) => f.endsWith(`.${STASHENC_EXT}`));
} catch { return []; }
/** 0.137.0: a Stashpad folder's own trash subfolder (per-folder trash). */
export function trashSubfolderOf(folder: string): string {
return `${(folder || "").replace(/\/+$/, "")}/trash`;
}
/** All encrypted-deleted blob paths: the legacy vault-level `_deleted/` UNION
* (0.137.0) every per-folder `trash/` subfolder passed in `extraDirs`. */
export async function listDeletedBlobs(app: App, extraDirs: string[] = []): Promise<string[]> {
const out: string[] = [];
for (const dir of [DELETED_DIR, ...extraDirs]) {
try {
if (!(await app.vault.adapter.exists(dir))) continue;
const listing = await app.vault.adapter.list(dir);
out.push(...listing.files.filter((f) => f.endsWith(`.${STASHENC_EXT}`)));
} catch { /* skip unreadable dir */ }
}
return out;
}

View file

@ -52,6 +52,17 @@ export interface EncryptionConfig {
* private key, the only secret stored on this device. */
identityPrivWrapped: string | null;
identityPrivKdf: StashKdf | null;
/** 0.136.0: every keychain id this device has ever written (primary, parked
* `-r<stamp>`, `-d-<slotId>`, folder keys). Obsidian's secretStorage has NO
* list API, so without this registry parked entries are unreachable with
* it, unlock flows can cycle through every known candidate before ever
* prompting. */
knownKeychainIds?: string[];
/** 0.140.1: the user chose "Forget on this device" suppress auto-unlock
* entirely (the cycle-unlock would otherwise resurrect a PARKED password and
* silently undo the forget). Cleared the next time a password is explicitly
* remembered. */
keychainForgotten?: boolean;
}
export function defaultEncryptionConfig(): EncryptionConfig {
@ -85,16 +96,17 @@ export class EncryptionService {
* folder path. Overlay on the vault DEK a folder with no entry here uses the
* vault `sessionKey`. Zeroed on lock()/idle alongside the vault key. */
private folderSessionKeys = new Map<string, Uint8Array>();
private idleTimer: number | null = null;
private keyfiles: KeyfileStore;
/** In-memory cache of the synced keyfile (refreshed via init()/refresh()). */
private kf: VaultKeyfile | null = null;
// (0.135.0: the idle auto-lock timer is gone — armIdle/clearIdle/idleMinutes
// removed. The session key lives until Obsidian closes or encryption is
// removed/re-passworded.)
constructor(
private app: App,
private load: () => EncryptionConfig,
private save: (cfg: EncryptionConfig) => Promise<void>,
private idleMinutes: () => number = () => 0,
) { this.keyfiles = new KeyfileStore(app); }
argonProbe(): Promise<boolean> { return argon2Available(); }
@ -146,31 +158,96 @@ export class EncryptionService {
if (!pw) return false;
try { return await this.verifyPassword(pw); } catch { return false; }
}
private async remember(password: string): Promise<void> {
try { await this.secretStore()?.setSecret(this.keychainId(), password); }
catch (e) { console.warn("[Stashpad] couldn't save password to keychain", e); }
}
async forgetKeychain(): Promise<void> {
/** 0.135.1: NEVER overwrite a keychain entry if `id` already holds a
* different secret, park the old value under `<id>-r<stamp>` first (Obsidian
* secret ids: /^[a-z0-9-]{1,64}$/, so the base is bounded to leave room).
* Universal retention at the write bottleneck the change-password flows'
* `-d-<slotId>` parking still applies on top for nicer names. */
private async parkThenSet(id: string, next: string): Promise<void> {
const ss = this.secretStore();
if (!ss) return;
const touched: string[] = [id];
try {
const cur = ss.getSecret(id);
if (cur && cur !== next) {
const stamp = Date.now().toString(36); // ~8 lowercase alnum chars
const parkId = `${id.slice(0, 64 - (stamp.length + 2))}-r${stamp}`;
await ss.setSecret(parkId, cur);
touched.push(parkId);
}
} catch { /* best-effort retention — never block the new write */ }
await ss.setSecret(id, next);
await this.recordKeychainIds(touched);
}
/** 0.136.0: remember every keychain id we write (secretStorage can't list),
* so unlock flows can cycle through all candidates. */
private async recordKeychainIds(ids: string[]): Promise<void> {
try {
const cfg = this.load();
const known = new Set(cfg.knownKeychainIds ?? []);
let dirty = false;
for (const id of ids) if (id && !known.has(id)) { known.add(id); dirty = true; }
if (dirty) await this.save({ ...cfg, knownKeychainIds: [...known] });
} catch { /* best-effort */ }
}
/** Every keychain candidate this device knows about: current + legacy ids +
* everything ever written (parked, deprecated-slot, folder keys). */
private keychainCandidateIds(): string[] {
const ids = [this.keychainId(), this.legacyAppIdKeychainId(), LEGACY_KEYCHAIN_ID, ...(this.load().knownKeychainIds ?? [])];
return [...new Set(ids)];
}
private async remember(password: string): Promise<void> {
try { await this.parkThenSet(this.keychainId(), password); }
catch (e) { console.warn("[Stashpad] couldn't save password to keychain", e); }
// 0.140.1: an explicit remember re-arms auto-unlock (clears a prior forget).
try { const cfg = this.load(); if (cfg.keychainForgotten) await this.save({ ...cfg, keychainForgotten: false }); } catch { /* best-effort */ }
}
async forgetKeychain(): Promise<void> {
// 0.140.1: mark forgotten so tryAutoUnlock won't resurrect a parked password
// — this is the whole point of "Forget on this device".
try { await this.save({ ...this.load(), keychainForgotten: true }); } catch { /* best-effort */ }
const ss = this.secretStore();
if (!ss) return;
// 0.135.1: park the current value first (no-delete policy) — forgetting
// drops the ACTIVE entry but the old password stays recoverable.
try { const cur = ss.getSecret(this.keychainId()); if (cur) await this.parkThenSet(this.keychainId(), ""); return; } catch { /* fall through */ }
try { if (ss.removeSecret) await ss.removeSecret(this.keychainId()); else await ss.setSecret(this.keychainId(), ""); }
catch (e) { console.warn("[Stashpad] couldn't clear keychain", e); }
}
/** 0.136.0: cycle EVERY known keychain candidate (current id, legacy ids,
* parked `-r`/`-d` copies, even folder-key values) before giving up one
* stale primary entry no longer means a prompt (or a scare) when a working
* password is sitting one id over. On a hit from a non-primary id, the
* winning password is promoted to the primary id (old value parks). */
async tryAutoUnlock(): Promise<boolean> {
if (!this.isConfigured() || this.isUnlocked()) return this.isUnlocked();
// 0.140.1: honor an explicit "Forget on this device" — don't cycle parked
// passwords back into an unlock.
if (this.load().keychainForgotten) return false;
// Can't auto-unlock unless this device can unlock at all: a member (its own
// slot) or any device when a shared password is enabled.
if (this.accessState() !== "member" && !this.hasSharedPassword()) return false;
const ss = this.secretStore();
let stored: string | null = null;
try { stored = ss?.getSecret(this.keychainId()) ?? null; } catch { stored = null; }
if (stored) return this.unlock(stored);
let legacy: string | null = null;
try { legacy = ss?.getSecret(LEGACY_KEYCHAIN_ID) ?? null; } catch { legacy = null; }
if (!legacy) return false;
const ok = await this.unlock(legacy);
if (ok) { await this.remember(legacy); try { if (ss?.removeSecret) await ss.removeSecret(LEGACY_KEYCHAIN_ID); else await ss?.setSecret(LEGACY_KEYCHAIN_ID, ""); } catch { /* */ } }
return ok;
if (!ss) return false;
const primary = this.keychainId();
const tried = new Set<string>();
for (const id of this.keychainCandidateIds()) {
let pw: string | null = null;
try { pw = ss.getSecret(id) ?? null; } catch { pw = null; }
if (!pw || tried.has(pw)) continue;
tried.add(pw);
// verify first (cheap KDF check) so a wrong candidate doesn't spam
// failed-unlock side effects.
let ok = false;
try { ok = await this.verifyPassword(pw); } catch { ok = false; }
if (!ok) continue;
if (!(await this.unlock(pw))) continue;
if (id !== primary) await this.remember(pw); // promote (parks the stale primary)
return true;
}
return false;
}
// ---- state ----
@ -217,7 +294,6 @@ export class EncryptionService {
// explicit "Forget on this device" button and remove-encryption, so a password the
// user asked us to keep can't be silently zeroed by going through a later flow.
if (remember) await this.remember(password);
this.armIdle();
}
/** Unlock the session DEK with this device's password. Returns false on wrong
@ -240,7 +316,6 @@ export class EncryptionService {
// auto-unlock calls unlock() with remember=false; force-forgetting would
// wipe the very password it just used to unlock.)
if (remember) await this.remember(password);
this.armIdle();
return true;
}
// Member (device-approval) path — unwrap my private key, then the DEK from my
@ -253,9 +328,11 @@ export class EncryptionService {
try {
const dek = await unwrapDekWith(slot, priv);
priv.fill(0);
// 0.140.2: a tampered slot could yield a wrong-length "DEK" — reject
// rather than silently AES-key on garbage (which would corrupt/mislead).
if (dek.length !== DEK_LEN) { dek.fill(0); return false; }
this.sessionKey = dek;
if (remember) await this.remember(password);
this.armIdle();
return true;
} catch { priv.fill(0); }
} catch { /* wrong password for my key — try shared password below */ }
@ -267,9 +344,9 @@ export class EncryptionService {
for (const ps of (this.kf?.passwordSlots ?? []).filter((s) => !s.label.startsWith("[deprecated]"))) {
try {
const dek = await decryptStash(fromB64(ps.wrapped), password);
if (dek.length !== DEK_LEN) { dek.fill(0); continue; } // 0.140.2: reject wrong-length DEK
this.sessionKey = dek;
if (remember) await this.remember(password);
this.armIdle();
return true;
} catch { /* not this slot */ }
}
@ -293,7 +370,7 @@ export class EncryptionService {
const prevActive = (this.kf.passwordSlots ?? []).filter((s) => !s.label.startsWith("[deprecated]"));
const oldPw = this.rememberedPassword();
if (oldPw && prevActive[0]) {
try { await this.secretStore()?.setSecret(`${this.keychainId()}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort retention */ }
try { await this.parkThenSet(`${this.keychainId()}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort retention */ }
}
const wrapped = await encryptStash(this.sessionKey, passphrase);
const retained = (this.kf.passwordSlots ?? []).map((s) => s.label.startsWith("[deprecated]") ? s : { ...s, label: `[deprecated] ${s.label}` });
@ -376,7 +453,6 @@ export class EncryptionService {
if (!owner) return this.getSessionKey();
const k = this.folderSessionKeys.get(owner);
if (!k) return null;
this.armIdle();
return k.slice();
}
@ -405,7 +481,6 @@ export class EncryptionService {
await this.keyfiles.save(this.kf);
this.folderSessionKeys.set(f, dek);
if (remember) await this.rememberFolder(this.folderKcIdFor(entry), password);
this.armIdle();
}
/** Unlock a folder's own key with `password`. False on wrong password / no entry. */
@ -420,7 +495,6 @@ export class EncryptionService {
this.folderSessionKeys.set(entry.folderPath, dek); // key the session by the OWNING folder
const kcId = this.folderKcIdFor(entry);
if (remember || this.isFolderRemembered(kcId)) await this.rememberFolder(kcId, password);
this.armIdle();
return true;
} catch { /* try next slot */ }
}
@ -431,9 +505,26 @@ export class EncryptionService {
async tryAutoUnlockFolder(folder: string): Promise<boolean> {
const entry = this.folderKeyEntry(folder);
if (!entry || this.isFolderUnlocked(folder)) return this.isFolderUnlocked(folder);
const pw = this.rememberedFolderPassword(this.folderKcIdFor(entry));
if (!pw) return false;
return this.unlockFolder(folder, pw);
const kcId = this.folderKcIdFor(entry);
// Primary id first, then (0.136.0) cycle every other known candidate —
// parked copies included. A hit from a non-primary id is promoted.
const pw = this.rememberedFolderPassword(kcId);
if (pw && await this.unlockFolder(folder, pw)) return true;
const ss = this.secretStore();
if (!ss) return false;
const tried = new Set<string>(pw ? [pw] : []);
for (const id of this.keychainCandidateIds()) {
if (id === kcId) continue;
let cand: string | null = null;
try { cand = ss.getSecret(id) ?? null; } catch { cand = null; }
if (!cand || tried.has(cand)) continue;
tried.add(cand);
if (await this.unlockFolder(folder, cand)) {
await this.rememberFolder(kcId, cand); // promote (parks any stale value)
return true;
}
}
return false;
}
/** Cheap "Change password" for a folder: re-wrap the SAME DEK under a new
@ -459,7 +550,7 @@ export class EncryptionService {
const kcId = this.folderKcIdFor(entry);
const oldPw = this.rememberedFolderPassword(kcId);
if (oldPw && prevActive[0]) {
try { await this.secretStore()?.setSecret(`${kcId}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort retention */ }
try { await this.parkThenSet(`${kcId}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort retention */ }
}
const wrapped = await encryptStash(dek, newPassword);
// Relabel every prior slot as [deprecated] (retained), prepend the new active one.
@ -496,7 +587,7 @@ export class EncryptionService {
const prevActive = (entry?.passwordSlots ?? []).filter((s) => !s.label.startsWith("[deprecated]"));
const oldPw = oldKcId ? this.rememberedFolderPassword(oldKcId) : null;
if (oldPw && prevActive[0]) {
try { await this.secretStore()?.setSecret(`${oldKcId}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort */ }
try { await this.parkThenSet(`${oldKcId}-d-${prevActive[0].id}`, oldPw); } catch { /* best-effort */ }
}
const wrapped = await encryptStash(newDek, newPassword);
const retained = (entry?.passwordSlots ?? []).map((s) => s.label.startsWith("[deprecated]") ? s : { ...s, label: `[deprecated] ${s.label}` });
@ -539,7 +630,7 @@ export class EncryptionService {
try { return this.secretStore()?.getSecret(kcId) || null; } catch { return null; }
}
private async rememberFolder(kcId: string, password: string): Promise<void> {
try { await this.secretStore()?.setSecret(kcId, password); }
try { await this.parkThenSet(kcId, password); }
catch (e) { console.warn("[Stashpad] couldn't save folder password to keychain", e); }
}
async forgetFolderKeychain(folder: string): Promise<void> {
@ -548,6 +639,8 @@ export class EncryptionService {
const ss = this.secretStore();
if (!ss) return;
const id = this.folderKcIdFor(entry);
// 0.135.1: park before forgetting (no-delete policy), same as forgetKeychain.
try { const cur = ss.getSecret(id); if (cur) { await this.parkThenSet(id, ""); return; } } catch { /* fall through */ }
try { if (ss.removeSecret) await ss.removeSecret(id); else await ss.setSecret(id, ""); }
catch (e) { console.warn("[Stashpad] couldn't clear folder keychain", e); }
}
@ -581,7 +674,6 @@ export class EncryptionService {
// already saved here (the old one is now invalid — replace it so auto-unlock
// doesn't keep trying a stale password). Explicit forgetting lives elsewhere.
if (remember || this.isRemembered()) await this.remember(newPassword);
this.armIdle();
return true;
}
@ -651,7 +743,6 @@ export class EncryptionService {
// the explicit Lock command should drop ALL key material from memory).
for (const k of this.folderSessionKeys.values()) k.fill(0);
this.folderSessionKeys.clear();
this.clearIdle();
}
/** Lock a SINGLE folder's key (leave the vault key + other folders unlocked).
@ -666,22 +757,40 @@ export class EncryptionService {
private cleanFolder(p: string): string { return (p || "").replace(/\/+$/, ""); }
/** Remove encryption entirely (caller gates on "no .stashenc exists"): wipe the
* session key, this device's identity, the legacy wrap, AND the synced keyfile
* + backups (no encrypted content remains, so this is safe). */
* session key, this device's identity, the legacy wrap, and the ACTIVE keyfile.
*
* 0.135.0: the `_keys/` rolling backups are PARKED, not deleted. The old code
* wiped every backup, so one Remove-encryption run destroyed all key history
* hostile if the user ever needs an old key back. Backups now move to
* `_keys/removed-<stamp>/`, which KeyfileStore.load() never reads (it only
* lists `_keys/` top-level files), so the vault still comes up "not encrypted"
* while the wrapped keys stay recoverable by hand. */
async clear(): Promise<void> {
this.lock();
await this.forgetKeychain();
try {
const a = this.app.vault.adapter;
for (const p of [".stashpad/keys.json"]) { try { if (await a.exists(p)) await a.remove(p); } catch { /* */ } }
try { const list = await a.list("_keys"); for (const f of (list.files || [])) { if (/\/keys-\d+\.json$/.test(f)) { try { await a.remove(f); } catch { /* */ } } } } catch { /* */ }
try {
const list = await a.list("_keys");
const backups = (list.files || []).filter((f) => /\/keys-\d+\.json$/.test(f));
if (backups.length) {
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
const parkDir = `_keys/removed-${stamp}`;
try { if (!(await a.exists(parkDir))) await a.mkdir(parkDir); } catch { /* race */ }
for (const f of backups) {
const name = f.split("/").pop()!;
try { await a.write(`${parkDir}/${name}`, await a.read(f)); await a.remove(f); }
catch { /* keep the original rather than risk losing it */ }
}
}
} catch { /* */ }
} catch { /* best-effort */ }
this.kf = null;
await this.save(defaultEncryptionConfig());
}
getSessionKey(): Uint8Array | null {
if (this.sessionKey) this.armIdle();
return this.sessionKey ? this.sessionKey.slice() : null;
}
@ -703,13 +812,5 @@ export class EncryptionService {
return { id: id.id, label: id.label, pubKey: id.pub, addedAt: new Date().toISOString() };
}
private armIdle(): void {
this.clearIdle();
const mins = this.idleMinutes();
if (mins > 0) this.idleTimer = window.setTimeout(() => this.lock(), mins * 60_000);
}
private clearIdle(): void {
if (this.idleTimer != null) { window.clearTimeout(this.idleTimer); this.idleTimer = null; }
}
dispose(): void { this.lock(); }
}

View file

@ -105,25 +105,40 @@ export class StashpadFolderPanelView extends ItemView {
pending = f;
pinnedSection.style.flex = `0 0 ${(f * 100).toFixed(2)}%`;
};
const onUp = (ev: PointerEvent) => {
// 0.140.3 (review): tear the document listeners down on pointerup AND
// pointercancel (mobile scroll/palm-rejection ends a gesture with cancel,
// not up), and expose the cleanup so onClose can run it if the view is
// detached mid-drag — the old code leaked the document listeners + left the
// resizing cursor class stuck when either happened.
const cleanup = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
document.body.removeClass("stashpad-folderpanel-resizing");
this.dividerCleanup = null;
};
const onUp = (ev: PointerEvent) => {
try { divider.releasePointerCapture(ev.pointerId); } catch { /* noop */ }
if (pending != null) {
if (ev.type === "pointerup" && pending != null) {
this.plugin.settings.folderPanelPinnedFraction = pending;
void this.plugin.saveSettings();
}
cleanup();
};
divider.addEventListener("pointerdown", (ev: PointerEvent) => {
this.registerDomEvent(divider, "pointerdown", (ev: PointerEvent) => {
ev.preventDefault();
document.body.addClass("stashpad-folderpanel-resizing");
try { divider.setPointerCapture(ev.pointerId); } catch { /* noop */ }
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
this.dividerCleanup = cleanup;
});
}
/** 0.140.3: run any in-flight divider-drag cleanup (see attachDividerDrag). */
private dividerCleanup: (() => void) | null = null;
// ---------- pinned notes (top) ----------
/** Pin subtree expansion state (key = `folder|id`), kept across re-renders. */
@ -497,7 +512,8 @@ export class StashpadFolderPanelView extends ItemView {
// 0.118.0: a user-set per-folder icon wins; otherwise archive folders show
// the archive glyph and everything else the generic folder icon.
setIcon(folderIcon, this.plugin.getFolderIcon(folder) ?? (isArchive ? "archive" : "folder"));
if (isArchive) folderIcon.setAttr("aria-label", "Archive folder — notes moved in are auto-encrypted");
// 0.134.4 (B3): archives are plaintext by default now — don't claim auto-encryption.
if (isArchive) folderIcon.setAttr("aria-label", "Archive folder — de-indexed from search");
const homeColor = this.folderHomeColor(folder);
if (homeColor) folderIcon.style.color = homeColor;
@ -626,44 +642,10 @@ export class StashpadFolderPanelView extends ItemView {
menu.addItem((i) => i.setTitle("Decrypt (unlock) all notes in folder").setIcon("unlock")
.onClick(() => void this.plugin.unlockFolder(folder)));
}
// 0.98.25 (Phase 4): archive folder toggle. Marking requires an explicit
// confirm — auto-lock permanently deletes the arriving note's plaintext.
const cleaned = folder.replace(/\/+$/, "");
const isArchive = this.plugin.isArchiveFolder(cleaned);
menu.addItem((i) => i.setTitle(isArchive ? "Unmark archive folder" : "Mark as archive folder…").setIcon("archive")
.onClick(async () => {
if (isArchive) {
this.plugin.settings.archiveFolders = (this.plugin.settings.archiveFolders ?? []).filter((f) => f !== cleaned);
await this.plugin.saveSettings();
new Notice(`"${cleaned.split("/").pop()}" is no longer an archive folder. Existing locked notes stay locked.`, 0);
this.render();
return;
}
new ConfirmModal(
this.app,
`Make "${cleaned.split("/").pop()}" an archive folder?`,
[
`An archive folder automatically LOCKS (encrypts) any note you move into it.`,
``,
`What that means in plain terms:`,
`• "Encrypting" scrambles the note with your encryption password so its text can't be read by anyone — or any app — without that password.`,
`• The normal, readable copy is permanently removed from your vault. What's left is an unreadable, locked 🔒 placeholder.`,
`• To read or edit the note again, you unlock it with your encryption password (one click on the placeholder). Then you can re-archive it later.`,
`• If you ever lose your encryption password, the locked notes are gone for good — there is no backdoor or recovery, on purpose.`,
`• Notes ALREADY in this folder are not touched — only notes moved in from now on. (To lock the ones already here, use "Encrypt all notes in folder".)`,
``,
`Good for folders of things you want kept private at rest: finished or sensitive material you'd rather not have readable if someone opened your vault.`,
].join("\n"),
"Make it an archive folder",
async (ok) => {
if (!ok) return;
this.plugin.settings.archiveFolders = [...(this.plugin.settings.archiveFolders ?? []), cleaned];
await this.plugin.saveSettings();
new Notice(`"${cleaned.split("/").pop()}" is now an archive folder — notes moved in will be encrypted.`, 0);
this.render();
},
).open();
}));
// (0.136.0: "Mark as archive folder" is gone — every folder owns an
// `archive/` subfolder now; use "Move selection to archive" in a list, or
// the aggregated Archived view to browse. Encryption of archived notes is
// the per-folder "Encrypt archived notes" setting.)
// 0.98.31: open the encrypted-trash tab (recoverable secure-deleted notes).
menu.addItem((i) => i.setTitle("Open encrypted trash").setIcon("rotate-ccw")
@ -688,13 +670,14 @@ export class StashpadFolderPanelView extends ItemView {
const parent = tf.parent?.path && tf.parent.path !== "/" ? `${tf.parent.path}/` : "";
const target = `${parent}${safe}`;
if (this.app.vault.getAbstractFileByPath(target)) { new Notice(`"${safe}" already exists.`); return; }
// 0.140.3 (review): re-validate — the folder could've moved/been deleted
// by sync while the rename modal sat open.
if (!(this.app.vault.getAbstractFileByPath(cleaned) instanceof TFolder)) { new Notice("That folder no longer exists."); return; }
try {
await this.app.fileManager.renameFile(tf, target);
// Keep the configured default folder pointing at the renamed path.
if ((this.plugin.settings.folder || "").replace(/\/+$/, "") === cleaned) {
this.plugin.settings.folder = target;
await this.plugin.saveSettings();
}
// 0.140.3: carry ALL path-keyed settings (default folder + placement +
// archive + per-folder prefs, incl. descendants) to the new path.
await this.plugin.remapFolderPathInSettings(cleaned, target);
new Notice(`Renamed to "${safe}".`);
} catch (err) {
console.warn("[Stashpad] folder rename failed", err);
@ -725,6 +708,10 @@ export class StashpadFolderPanelView extends ItemView {
},
).open();
}
/** 0.140.3 (review): run any in-flight divider-drag cleanup on view close so
* a mid-drag detach doesn't leak the document pointer listeners. */
async onClose(): Promise<void> { this.dividerCleanup?.(); this.contentEl.empty(); }
}
/** Tiny single-input modal for renaming a folder. */

View file

@ -64,12 +64,26 @@ export class ImportLog {
return null;
}
/** Cap so a heavy drop-folder auto-import user can't grow the in-memory
* array (and the on-disk jsonl, re-read whole on startup) without bound.
* Newest entries win. 0.140.4 (review). */
private static readonly MAX_ENTRIES = 5000;
append(entry: ImportLogEntry): void {
this.entries.push(entry);
const overflow = this.entries.length > ImportLog.MAX_ENTRIES;
if (overflow) this.entries = this.entries.slice(-ImportLog.MAX_ENTRIES);
this.writeChain = this.writeChain.then(async () => {
try {
await this.ensureDir();
await this.app.vault.adapter.append(this.path, JSON.stringify(entry) + "\n");
if (overflow) {
// Rewrite the whole trimmed log so the file tracks the capped array
// instead of appending forever.
const body = this.entries.map((e) => JSON.stringify(e)).join("\n");
await this.app.vault.adapter.write(this.path, body + "\n");
} else {
await this.app.vault.adapter.append(this.path, JSON.stringify(entry) + "\n");
}
} catch (e) {
console.warn("[Stashpad] import log append failed", e);
}

View file

@ -2,6 +2,7 @@ import { Notice, TFile, TFolder } from "obsidian";
import type StashpadPlugin from "./main";
import { ROOT_ID, RESERVED_FRONTMATTER, toAttachmentLink } from "./types";
import { newId } from "./id-service";
import { formatDateOnly } from "./format";
import { bodyToSlug, buildFilename } from "./slug-service";
import { splitFrontmatter, serializeNote, importStashZip, STASH_EXT } from "./stash-package";
import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
@ -13,11 +14,8 @@ import { ConfirmModal, ImportDupChoiceModal } from "./modals";
const RESERVED_SUBFOLDERS = new Set([
"_attachments", "_authors", "_exports", "_imports", "_processed", "_archive",
".archive", // legacy (pre-0.79.10) — keep ignoring any that exist
"archive", "trash", // 0.136.0: per-folder archive/trash subfolders
]);
/** Where import originals are preserved. 0.79.10: a normal reserved folder
* (NOT dot-prefixed) Obsidian hides/ignores dot-folders, which made
* renameFile/getAbstractFileByPath into `.archive` unreliable. */
const ARCHIVE_DIR = "_archive";
/** Extensions that must NEVER be turned into a note/attachment by any import
* path. `.stash` is handled by the dedicated stash-import path (not a note);
@ -72,6 +70,16 @@ export class ImportService {
private get app() { return this.plugin.app; }
/** 0.136.0: import originals live in the folder's own archive subfolder,
* dated: `<folder>/archive/_imported/<date>/`. The date segment follows the
* user's date-format preference, sanitized for path safety (e.g. the "us"
* format's slashes become dashes). */
private importedOriginalsDir(folder: string): string {
const raw = formatDateOnly(Date.now(), this.plugin.settings);
const seg = raw.replace(/[/\\:]+/g, "-").replace(/\s+/g, " ").trim() || "undated";
return `${folder}/archive/_imported/${seg}`;
}
/** Called from the vault create/rename watcher. Cheap pre-filter, then
* queue + (re)arm the debounced drain. */
enqueue(file: TFile): void {
@ -284,8 +292,10 @@ export class ImportService {
const raw = await this.app.vault.read(file);
const { fm, body } = splitFrontmatter(raw);
// Archive the original verbatim under _archive (unique name).
const archiveDir = `${folder}/${ARCHIVE_DIR}`;
// 0.136.0: archive the original verbatim under the folder's own
// archive/_imported/<date>/ (unique name). Date follows the user's
// date-format preference, path-sanitized.
const archiveDir = this.importedOriginalsDir(folder);
await this.ensureFolder(archiveDir);
const archivePath = await this.uniquePath(archiveDir, file.name);
await this.app.fileManager.renameFile(file, archivePath);
@ -380,10 +390,10 @@ export class ImportService {
private async importFolder(folder: TFolder): Promise<ImportRecord | null> {
const root = folder.parent!.path.replace(/\/+$/, "");
const name = folder.name;
// Move the whole folder to _archive first; we then build notes by
// reading from its archived location, so non-md notes link to the
// final (archived) path and md content is cloned from there.
const archiveDir = `${root}/${ARCHIVE_DIR}`;
// Move the whole folder into archive/_imported/<date>/ first; we then
// build notes by reading from its archived location, so non-md notes link
// to the final (archived) path and md content is cloned from there.
const archiveDir = this.importedOriginalsDir(root);
await this.ensureFolder(archiveDir);
const archivePath = await this.uniquePath(archiveDir, name);
await this.app.fileManager.renameFile(folder, archivePath);
@ -841,7 +851,7 @@ export class ImportService {
// 0.85.4: try a passphrase remembered for this filename before prompting.
const buf = await resolveStashBytes(this.app, rawBytes, { ...promptOpts, secretId: secretIdForStashName(file.basename) });
if (!buf) return false; // cancelled / "remind me later" — leave the file
const summary = await importStashZip(this.app, buf, root, existingIds);
const summary = await importStashZip(this.app, buf, root, existingIds, { stripReserved: true });
// Merge any hex→name color aliases the bundle carried (e.g. from the web
// importer) into the destination folder, so the names show in the color UI.
if (summary.colorAliases) {

View file

@ -10,7 +10,19 @@ export class IntegrityWatcher {
* to entries under `folder` before diffing otherwise switching folders
* would log every note in the previously focused folder as missing.
*/
/** Serialize sweeps: the read-modify-write of the shared state file must be
* atomic across a whole sweep, or two folders sweeping concurrently (e.g.
* workspace restore opening two Stashpad leaves) each read the same base
* and the second writeState clobbers the first folder's fresh snapshot. */
private sweepChain: Promise<void> = Promise.resolve();
async sweep(folder?: string): Promise<void> {
const next = this.sweepChain.then(() => this.doSweep(folder), () => this.doSweep(folder));
this.sweepChain = next.catch(() => {});
return next;
}
private async doSweep(folder?: string): Promise<void> {
const prev = await this.log.readState();
const cur = this.tree.snapshot();

View file

@ -10,11 +10,22 @@ import type { Workspace, WorkspaceLeaf, EventRef } from "obsidian";
*
* No-ops when there's no distinct origin (origin missing, or identical to the
* spawned leaf) e.g. an aggregate view opened from the command palette with
* nothing else in the main area. In that case Obsidian's default stands. */
* nothing else in the main area. In that case Obsidian's default stands.
*
* 0.140.2 (review fixes):
* - Pass `register` (plugin.registerEvent) so the listener is OWNED by the
* plugin and cleaned on unload a spawned tab that's never closed no longer
* leaks a live listener (nor keeps a dead-view callback alive across a
* reload). It still self-detaches on close.
* - Only re-focus the origin when the spawned tab was FOCUSED as it vanished.
* A background middle-close doesn't fire `active-leaf-change`, leaving the
* listener armed; the OLD code then hijacked the NEXT deliberate tab switch,
* yanking focus to the origin. Tracking the last-active leaf fixes that. */
export function returnToOriginOnClose(
ws: Workspace,
newLeaf: WorkspaceLeaf,
originLeaf: WorkspaceLeaf | null,
register?: (ref: EventRef) => void,
): void {
if (!originLeaf || originLeaf === newLeaf) return;
const isOpen = (target: WorkspaceLeaf): boolean => {
@ -22,15 +33,19 @@ export function returnToOriginOnClose(
ws.iterateAllLeaves((l) => { if (l === target) found = true; });
return found;
};
// The spawned tab was just made active by the opener, so it starts as the
// last-active leaf we've seen.
let lastActive: WorkspaceLeaf | null = newLeaf;
const off: EventRef = ws.on("active-leaf-change", () => {
// Spawned tab still around (user just switched away from it) — leave it.
if (isOpen(newLeaf)) return;
// Spawned tab closed. Detach this listener and, if the origin tab is still
// open, make it active instead of whatever Obsidian picked (the right tab).
if (isOpen(newLeaf)) { lastActive = ws.activeLeaf ?? lastActive; return; }
// Spawned tab is gone. Detach either way.
ws.offref(off);
if (isOpen(originLeaf)) {
// Only reclaim focus if it closed WHILE FOCUSED (foreground close) — a
// background close shouldn't hijack the tab the user just switched to.
if (lastActive === newLeaf && isOpen(originLeaf)) {
ws.setActiveLeaf(originLeaf, { focus: true });
ws.revealLeaf(originLeaf);
}
});
register?.(off); // plugin-owned → cleaned on unload
}

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,21 @@ import { newId } from "./id-service";
import type { ImportLogEntry } from "./import-log";
export interface AssigneeRef { id: string; name: string }
export interface DuePickResult { iso: string | null; assignees: AssigneeRef[] }
export interface DuePickResult {
iso: string | null;
assignees: AssigneeRef[];
/** 0.140.0: recurrence + reminder rules (empty string = clear the field).
* Present only when the picker showed the "Repeat & reminders" section. */
repeat?: string;
autoDoneAfter?: string;
remindEvery?: string;
}
export interface DuePickerOptions {
/** 0.140.0: show the "Repeat & reminders" section, pre-filled from these. */
showRecurrence?: boolean;
currentRepeat?: string;
currentAutoDoneAfter?: string;
currentRemindEvery?: string;
/** Known authors to offer in the assignee picker (from the registry). */
knownAuthors?: AssigneeRef[];
/** Assignees already on the note, to pre-fill the chips. */
@ -252,6 +265,8 @@ export class LogModal extends Modal {
if (this.footerEl) this.footerEl.empty();
new Notice("Log cleared.");
},
"Cancel",
/*dangerous*/ true,
).open();
}
@ -1174,6 +1189,9 @@ export class TypeToConfirmModal extends Modal {
export class CustomColorModal extends Modal {
private value: string;
private delivered = false;
/** Whether the user actually changed the color. A dismiss (Esc/click-out)
* without touching anything must NOT apply the seeded default. (0.140.6) */
private touched = false;
constructor(
app: App,
seed: string | null,
@ -1207,6 +1225,7 @@ export class CustomColorModal extends Modal {
const sync = (next: string) => {
const v = next.startsWith("#") ? next : "#" + next;
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
this.touched = true;
this.value = v;
preview.style.background = v;
wheel.value = v;
@ -1236,9 +1255,10 @@ export class CustomColorModal extends Modal {
this.onResult(this.value, opts);
}
onClose(): void {
if (!this.delivered) {
// Click-out / Esc → apply hastily (no palette persistence), matching
// the user's "skip adding" intent.
if (!this.delivered && this.touched) {
// Click-out / Esc AFTER changing the color → apply hastily (no palette
// persistence), matching the user's "skip adding" intent. An UNtouched
// dismiss is a cancel — don't paint the seeded default over the note.
this.delivered = true;
this.onResult(this.value, { addToPalette: false });
}
@ -1265,6 +1285,9 @@ export class ColorPickerModal extends Modal {
/** Active grid index for keyboard nav. -1 = no focus yet. */
private focusIdx = -1;
/** Guard so re-rendering the grid (handleDelete onOpen) doesn't stack a
* second copy of the keyboard handlers on the persistent scope. (0.140.6) */
private scopeBound = false;
/** Snapshot of selectable tiles in render order. The "+" tile is `kind:
* "add"` and opens CustomColorModal rather than committing directly. */
private items: { kind: "none" | "preset" | "custom" | "add"; color: string | null; el: HTMLElement }[] = [];
@ -1332,12 +1355,17 @@ export class ColorPickerModal extends Modal {
this.focusIdx = seedIdx >= 0 ? seedIdx : 0;
this.refreshActive();
// Keyboard nav: arrows move focus; Enter activates.
this.scope.register([], "ArrowRight", (e) => { e.preventDefault(); this.moveFocus(1); });
this.scope.register([], "ArrowLeft", (e) => { e.preventDefault(); this.moveFocus(-1); });
this.scope.register([], "ArrowDown", (e) => { e.preventDefault(); this.moveFocus(this.columns()); });
this.scope.register([], "ArrowUp", (e) => { e.preventDefault(); this.moveFocus(-this.columns()); });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.activate(this.focusIdx); });
// Keyboard nav: arrows move focus; Enter activates. Registered once — the
// handlers read live this.focusIdx/this.items, so they survive a grid
// rebuild; re-registering (handleDelete → onOpen) would stack duplicates.
if (!this.scopeBound) {
this.scope.register([], "ArrowRight", (e) => { e.preventDefault(); this.moveFocus(1); });
this.scope.register([], "ArrowLeft", (e) => { e.preventDefault(); this.moveFocus(-1); });
this.scope.register([], "ArrowDown", (e) => { e.preventDefault(); this.moveFocus(this.columns()); });
this.scope.register([], "ArrowUp", (e) => { e.preventDefault(); this.moveFocus(-this.columns()); });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.activate(this.focusIdx); });
this.scopeBound = true;
}
// After paint, focus the modal so arrow keys land here, not in the
// background view.
@ -1428,6 +1456,9 @@ export class ConfirmModal extends Modal {
private confirmText: string,
private onChoose: (confirmed: boolean) => void,
private cancelText: string = "Cancel",
/** When true (destructive confirms like "Clear log"), focus Cancel instead
* of Confirm so a stray Enter doesn't fire the irreversible action. */
private dangerous: boolean = false,
) { super(app); }
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
@ -1446,8 +1477,9 @@ export class ConfirmModal extends Modal {
cancel.onclick = () => { this.didChoose = true; this.close(); this.onChoose(false); };
const ok = row.createEl("button", { cls: "mod-cta", text: this.confirmText });
ok.onclick = () => { this.didChoose = true; this.close(); this.onChoose(true); };
// Focus the confirm button so Enter accepts.
requestAnimationFrame(() => ok.focus());
// Focus the confirm button so Enter accepts — but for destructive confirms
// focus Cancel instead, so a stray Enter cancels rather than fires. (0.140.6)
requestAnimationFrame(() => (this.dangerous ? cancel : ok).focus());
}
onClose(): void {
this.contentEl.empty();
@ -1746,6 +1778,32 @@ export class DueDatePickerModal extends Modal {
timeInput.value = "";
dateInput.focus();
};
// 0.140.0: optional "Repeat & reminders" section (collapsible). Three
// free-text fields; recurrence uses natural language ("every weekday",
// "every 30 days when done"). Only shown/returned when opts.showRecurrence.
let repeatIn: HTMLInputElement | null = null;
let autoIn: HTMLInputElement | null = null;
let remindIn: HTMLInputElement | null = null;
if (this.opts.showRecurrence) {
const det = wrap.createEl("details", { cls: "stashpad-due-recur" });
if (this.opts.currentRepeat || this.opts.currentAutoDoneAfter || this.opts.currentRemindEvery) det.open = true;
det.createEl("summary", { text: "🔁 Repeat & reminders" });
const mkRow = (label: string, ph: string, val?: string): HTMLInputElement => {
const r = det.createDiv({ cls: "stashpad-due-recur-row" });
r.createEl("label", { text: label });
const inp = r.createEl("input", { type: "text", attr: { placeholder: ph } });
if (val) inp.value = val;
return inp;
};
repeatIn = mkRow("Repeat", 'e.g. "every weekday", "every 30 days when done"', this.opts.currentRepeat);
autoIn = mkRow("Auto-complete after", 'e.g. "1d" — mark done once this overdue', this.opts.currentAutoDoneAfter);
remindIn = mkRow("Remind every", 'e.g. "2h" — re-notify until done', this.opts.currentRemindEvery);
}
const recur = (): Pick<DuePickResult, "repeat" | "autoDoneAfter" | "remindEvery"> =>
this.opts.showRecurrence
? { repeat: repeatIn!.value.trim(), autoDoneAfter: autoIn!.value.trim(), remindEvery: remindIn!.value.trim() }
: {};
const cancel = grid.createEl("button", { cls: "stashpad-due-btn", text: "Cancel" });
cancel.onclick = () => { this.didChoose = true; this.close(); };
const ok = grid.createEl("button", { cls: "stashpad-due-btn mod-cta", text: "Set" });
@ -1755,7 +1813,7 @@ export class DueDatePickerModal extends Modal {
if (!dateInput.value) {
this.didChoose = true;
this.close();
this.onPick({ iso: null, assignees: this.assignees });
this.onPick({ iso: null, assignees: this.assignees, ...recur() });
return;
}
// Default time to 09:00 when only a date was chosen.
@ -1765,7 +1823,7 @@ export class DueDatePickerModal extends Modal {
const due = new Date(y, m - 1, d, hh, mm, 0, 0);
this.didChoose = true;
this.close();
this.onPick({ iso: due.toISOString(), assignees: this.assignees });
this.onPick({ iso: due.toISOString(), assignees: this.assignees, ...recur() });
};
requestAnimationFrame(() => dateInput.focus());
}
@ -1975,6 +2033,8 @@ export class NotificationHistoryModal extends Modal {
this.service.clearHistory();
// service.clearHistory emits — our subscriber refreshes.
},
"Cancel",
/*dangerous*/ true,
).open();
};
@ -2256,3 +2316,43 @@ export class OkfExportModal extends Modal {
}
onClose(): void { this.contentEl.empty(); }
}
/** 0.138.0 (re-encrypt sweep): ONE review modal listing everything that should
* be encrypted but is plaintext per-subtree rows, checkboxes pre-ticked.
* Confirm passes the CHECKED indices; nothing encrypts without it. */
export class ReEncryptReviewModal extends Modal {
constructor(
app: App,
private items: Array<{ label: string; detail: string }>,
private onConfirm: (chosenIndices: number[]) => void | Promise<void>,
) { super(app); }
onOpen(): void {
this.titleEl.setText("Re-encrypt everything applicable?");
const { contentEl } = this;
contentEl.createEl("p", {
cls: "setting-item-description",
text: "These notes should be encrypted (they were unlocked/restored, or their folder is set to encrypt) but are currently plaintext. Untick anything you want to leave readable.",
});
const list = contentEl.createDiv({ cls: "stashpad-reenc-list" });
const boxes: HTMLInputElement[] = [];
this.items.forEach((it) => {
const row = list.createEl("label", { cls: "stashpad-reenc-row" });
const cb = row.createEl("input", { type: "checkbox" });
cb.checked = true;
boxes.push(cb);
const body = row.createDiv({ cls: "stashpad-reenc-body" });
body.createDiv({ cls: "stashpad-reenc-label", text: it.label });
body.createDiv({ cls: "stashpad-reenc-detail", text: it.detail });
});
const btns = contentEl.createDiv({ cls: "modal-button-container" });
const go = btns.createEl("button", { cls: "mod-cta", text: `Re-encrypt checked` });
go.onclick = () => {
const chosen = boxes.map((b, i) => (b.checked ? i : -1)).filter((i) => i >= 0);
this.close();
if (chosen.length) void this.onConfirm(chosen);
};
btns.createEl("button", { text: "Cancel" }).onclick = () => this.close();
}
onClose(): void { this.contentEl.empty(); }
}

View file

@ -383,12 +383,9 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
// run token-order-agnostic match against title/body.
const parsed = parseSearchQuery(query);
const tokens = parsed.text;
// 0.64.2 diagnostic — use console.log (not debug) so it shows
// at the default DevTools log level. Always fires while we're
// debugging the filter pipeline.
console.log("[Stashpad] search query parsed", {
query, text: parsed.text, filters: parsed.filters,
});
// 0.140.1: removed a leftover `console.log` that dumped every typed search
// query to the DevTools console on every keystroke (info-leak on a shared
// machine + production noise). Was tagged "always fires while debugging".
const matchesAll = (haystack: string): boolean => {
if (!tokens.length) return true;
for (const t of tokens) if (!haystack.includes(t)) return false;

View file

@ -83,11 +83,26 @@ export class OrderStore {
private async doWrite(folder: string): Promise<void> {
const map = this.cache.get(folder) ?? {};
const path = `${folder}/${ORDER_FILE}`;
const adapter = this.app.vault.adapter;
// 0.140.3 (review): a mutation (setOrder/appendChild) that fires BEFORE this
// folder's load() has resolved seeds the cache with ONLY the touched parent,
// so writing it verbatim would clobber every other parent's saved order.
// Merge in any on-disk parents we DIDN'T touch this session (cache wins per
// key) so untouched orders survive.
try {
if (await adapter.exists(path)) {
const parsed = JSON.parse(await adapter.read(path));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
if (!(k in map) && Array.isArray(v) && v.every((x) => typeof x === "string")) map[k] = v as string[];
}
}
}
} catch { /* unreadable / absent — write what we have */ }
const trimmed: Record<string, string[]> = {};
for (const [k, v] of Object.entries(map)) if (v.length > 0) trimmed[k] = v;
this.cache.set(folder, trimmed);
const path = `${folder}/${ORDER_FILE}`;
const adapter = this.app.vault.adapter;
try {
if (Object.keys(trimmed).length === 0) {
// Skip the exists() probe — remove() throws "file not found" if

View file

@ -443,7 +443,8 @@ export class StashpadPanelsView extends ItemView {
/** Display title from a TFile strip the trailing "-id" suffix and
* un-hyphenate. */
private titleFromFile(file: TFile): string {
return titleFromTaskFile(file);
const id = this.app.metadataCache.getFileCache(file)?.frontmatter?.id;
return titleFromTaskFile(file, typeof id === "string" ? id : null);
}
// ---------- Actions ----------

135
src/recurrence.ts Normal file
View file

@ -0,0 +1,135 @@
/** 0.140.0: recurring + persistent reminders. Hand-rolled (no rrule dep) parser
* for the natural-language recurrence patterns people actually type, plus the
* duration parser the auto-complete / persistent-reminder features share.
*
* Frontmatter contract (all optional, on any task note):
* - `repeat`: a recurrence rule string (see parseRecurrence). A trailing
* "when done" anchors the next occurrence to the COMPLETION time
* instead of the due time (toothbrush-every-30-days case).
* - `autoDoneAfter`: a duration ("1h" / "2d" / "30m"). Once the task is this
* long past due, it auto-marks complete (un-failable tasks).
* - `remindEvery`: a duration. Re-notify this often until done (persistent).
*/
const UNIT_MS: Record<string, number> = {
min: 60_000, minute: 60_000, m: 60_000,
h: 3_600_000, hr: 3_600_000, hour: 3_600_000,
d: 86_400_000, day: 86_400_000,
w: 604_800_000, wk: 604_800_000, week: 604_800_000,
};
/** "30m" / "2 hours" / "1d" / "90 min" → milliseconds. null if unparseable. */
export function parseDuration(s: string | number | null | undefined): number | null {
if (typeof s === "number") return s > 0 ? s : null;
if (!s) return null;
const m = String(s).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)?$/);
if (!m) return null;
const n = parseFloat(m[1]);
const unit = (m[2] || "min").replace(/s$/, "");
const ms = UNIT_MS[unit];
return ms && n > 0 ? n * ms : null;
}
type Anchor = "due" | "completion";
export interface Recurrence {
/** Advance `from` (ms) to the next occurrence AFTER it. */
next: (from: number) => number;
/** "when done" → anchor the roll to the completion time, not the old due. */
anchor: Anchor;
/** Human echo of what we parsed, for confirmation UI. */
label: string;
}
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
function addMonths(ms: number, n: number): number {
const d = new Date(ms);
const day = d.getDate();
d.setMonth(d.getMonth() + n);
// Clamp end-of-month overflow (Jan 31 + 1mo → Feb 28/29, not Mar 3).
if (d.getDate() < day) d.setDate(0);
return d.getTime();
}
function addYears(ms: number, n: number): number {
const d = new Date(ms); d.setFullYear(d.getFullYear() + n); return d.getTime();
}
function nextWeekday(from: number, targetDow: number): number {
const d = new Date(from);
do { d.setDate(d.getDate() + 1); } while (d.getDay() !== targetDow);
return d.getTime();
}
function nextBusinessDay(from: number): number {
const d = new Date(from);
do { d.setDate(d.getDate() + 1); } while (d.getDay() === 0 || d.getDay() === 6);
return d.getTime();
}
/** Parse a recurrence rule. Understood forms (case-insensitive, "every"
* optional): "daily" / "every day" / "every 3 days"; "weekly" / "every week" /
* "every 2 weeks"; "every weekday"; "every monday"; "monthly" / "every month" /
* "every 2 months" / "first of the month"; "yearly" / "every year"; "every N
* hours" / "every N minutes". A trailing "when done" / "after completion" sets
* the completion anchor. Returns null if nothing matched. */
export function parseRecurrence(rule: string | null | undefined): Recurrence | null {
if (!rule) return null;
let s = String(rule).trim().toLowerCase();
if (!s) return null;
let anchor: Anchor = "due";
if (/\b(when done|after completion|on completion|from completion)\b/.test(s)) {
anchor = "completion";
s = s.replace(/\b(when done|after completion|on completion|from completion)\b/g, "").trim();
}
s = s.replace(/^every\s+/, "").replace(/\s+/g, " ").trim();
const mk = (next: (from: number) => number, label: string): Recurrence => ({ next, anchor, label });
// first of the month
if (/^(first|1st) of (the )?month$/.test(s)) {
return mk((from) => { const d = new Date(from); d.setMonth(d.getMonth() + 1, 1); return d.getTime(); }, "first of the month");
}
// weekday name(s) — single weekday only for v1
const dow = WEEKDAYS.indexOf(s.replace(/s$/, ""));
if (dow >= 0) return mk((from) => nextWeekday(from, dow), `every ${WEEKDAYS[dow]}`);
if (s === "weekday" || s === "weekdays" || s === "business day" || s === "business days")
return mk(nextBusinessDay, "every weekday");
// bare keywords
if (s === "daily" || s === "day") return mk((f) => f + UNIT_MS.day, "every day");
if (s === "weekly" || s === "week") return mk((f) => f + UNIT_MS.week, "every week");
if (s === "monthly" || s === "month") return mk((f) => addMonths(f, 1), "every month");
if (s === "yearly" || s === "annually" || s === "year") return mk((f) => addYears(f, 1), "every year");
if (s === "hourly" || s === "hour") return mk((f) => f + UNIT_MS.h, "every hour");
// "N <unit>"
const m = s.match(/^(\d+)\s*([a-z]+)$/);
if (m) {
const n = parseInt(m[1], 10);
const unit = m[2].replace(/s$/, "");
if (n > 0) {
if (unit === "day") return mk((f) => f + n * UNIT_MS.day, `every ${n} days`);
if (unit === "week" || unit === "wk") return mk((f) => f + n * UNIT_MS.week, `every ${n} weeks`);
if (unit === "month" || unit === "mo") return mk((f) => addMonths(f, n), `every ${n} months`);
if (unit === "year" || unit === "yr") return mk((f) => addYears(f, n), `every ${n} years`);
if (unit === "hour" || unit === "hr" || unit === "h") return mk((f) => f + n * UNIT_MS.h, `every ${n} hours`);
if (unit === "min" || unit === "minute" || unit === "m") return mk((f) => f + n * UNIT_MS.min, `every ${n} minutes`);
}
}
return null;
}
/** The next due timestamp for a repeating task being completed now. Rolls from
* the completion time (`nowMs`) when anchored "when done", else from the old
* due advancing PAST now so an overdue task doesn't reschedule into the past. */
export function nextDueOnComplete(rec: Recurrence, oldDueMs: number | null, nowMs: number): number {
const from = rec.anchor === "completion" ? nowMs : (oldDueMs ?? nowMs);
let next = rec.next(from);
// Never schedule the next occurrence in the past (missed several cycles).
let guard = 0;
while (next <= nowMs && guard++ < 5000) next = rec.next(next);
// 0.140.1: if we STILL landed in the past (pathological overdue — e.g. an
// every-5-min task months overdue), advance from NOW so the contract "never
// returns a past timestamp" always holds and callers don't rewrite the file
// every poll trying to converge.
if (next <= nowMs) { next = rec.next(nowMs); let g = 0; while (next <= nowMs && g++ < 5000) next = rec.next(next); }
return next;
}

146
src/reencrypt-scheduler.ts Normal file
View file

@ -0,0 +1,146 @@
import { MarkdownView, Notice, TFile } from "obsidian";
import type StashpadPlugin from "./main";
import type { ReEncryptWatchEntry } from "./settings";
/** 0.139.0 (Q5 "peek"): after a configurable idle time, an unlocked
* previously-encrypted subtree is QUEUED to be re-encrypted with a visible,
* cancellable countdown. This deliberately revives a form of the removed idle
* auto-lock, so it's hedged with hard safeguards:
* - OPT-IN, default off. Delay resolves per-note per-folder global; 0/undef = off.
* - NEVER fires while the note is OPEN in an editor or was edited very recently.
* - Always a "Re-encrypting in Ns… [Keep unlocked]" countdown the user can cancel.
* - Cancelling (or editing) re-arms; the entry stays on the watchlist either way.
* Granularity is per-subtree (rootId), matching the watchlist. */
export class ReEncryptScheduler {
private tick: number | null = null;
/** folder → last activity ms (a modify/open in that folder re-arms its timers). */
private lastTouch = new Map<string, number>();
/** key `${folder} ${rootId}` currently showing a countdown — don't double-fire. */
private counting = new Set<string>();
private static readonly COUNTDOWN_SECS = 10;
private static readonly RECENT_EDIT_MS = 20_000; // treat a just-edited folder as active
constructor(private plugin: StashpadPlugin) {}
private key(folder: string, rootId: string): string { return `${folder} ${rootId}`; }
/** Resolve the idle delay (minutes) for one entry: per-note > per-folder >
* global. 0 or undefined = feature off for that entry. */
private delayMin(e: ReEncryptWatchEntry): number {
const perNote = e.peekMin;
if (perNote != null) return perNote;
const fp = (this.plugin.settings.folderEncPrefs ?? {})[e.folder.replace(/\/+$/, "")];
const perFolder = fp?.reEncryptAfterMin;
if (perFolder != null) return perFolder;
return this.plugin.settings.reEncryptAfterMin ?? 0;
}
/** Any peek delay configured anywhere? If not, the interval stays off. */
private anyArmed(): boolean {
if ((this.plugin.settings.reEncryptAfterMin ?? 0) > 0) return true;
for (const p of Object.values(this.plugin.settings.folderEncPrefs ?? {})) if ((p?.reEncryptAfterMin ?? 0) > 0) return true;
return this.plugin.reEncryptWatching().some((e) => (e.peekMin ?? 0) > 0);
}
/** Register vault/workspace listeners + the poll. Call once from onload. */
start(): void {
const touch = (folder: string) => { if (folder) this.lastTouch.set(folder.replace(/\/+$/, ""), Date.now()); };
this.plugin.registerEvent(this.plugin.app.vault.on("modify", (f) => { if (f instanceof TFile) touch(f.parent?.path ?? ""); }));
this.plugin.registerEvent(this.plugin.app.workspace.on("file-open", (f) => { if (f) touch(f.parent?.path ?? ""); }));
// Poll every 30s — cheap, and only does work when a delay is configured.
this.tick = window.setInterval(() => void this.poll(), 30_000);
this.plugin.registerInterval(this.tick);
}
/** A markdown file for this subtree's folder is open+visible treat as busy.
* Coarse (folder-level, not exact-subtree) on purpose err toward NOT
* re-encrypting something the user might be looking at. */
private folderHasOpenEditor(folder: string): boolean {
const clean = folder.replace(/\/+$/, "");
let open = false;
this.plugin.app.workspace.iterateAllLeaves((leaf) => {
const v = leaf.view;
if (v instanceof MarkdownView && v.file && (v.file.parent?.path?.replace(/\/+$/, "") ?? "") === clean) open = true;
});
return open;
}
/** The re-lock key is already in memory (so re-encrypting won't PROMPT).
* Never fire a timer that would surprise the user with a password box. */
private keyReady(folder: string): boolean {
const enc = this.plugin.encryption;
if (!enc?.isConfigured?.()) return false;
return enc.hasOwnFolderKey(folder) ? enc.isFolderUnlocked(folder) : enc.isUnlocked();
}
private async poll(): Promise<void> {
if (!this.anyArmed()) return;
const now = Date.now();
for (const e of this.plugin.reEncryptWatching()) {
const mins = this.delayMin(e);
if (mins <= 0) continue;
const folder = e.folder.replace(/\/+$/, "");
const k = this.key(folder, e.rootId);
if (this.counting.has(k)) continue;
const base = Math.max(Date.parse(e.unlockedAt) || 0, this.lastTouch.get(folder) ?? 0);
if (now < base + mins * 60_000) continue; // still within idle window
if (!this.keyReady(folder)) continue; // locked — never prompt on a timer
if (this.folderHasOpenEditor(folder)) { touchFolder(this.lastTouch, folder, now); continue; } // busy — defer
void this.countdownThenLock(e, k);
}
}
/** Live countdown intervals, so dispose() can clear them on unload. */
private countdownTimers = new Set<number>();
private countdownThenLock(e: ReEncryptWatchEntry, k: string): void {
this.counting.add(k);
const folder = e.folder.replace(/\/+$/, "");
const title = e.title || "a note";
const startedAt = Date.now();
let left = ReEncryptScheduler.COUNTDOWN_SECS;
let cancelled = false;
const note = new Notice("", 0);
const finish = () => { window.clearInterval(iv); this.countdownTimers.delete(iv); note.hide(); this.counting.delete(k); };
const abortReArm = () => { this.lastTouch.set(folder, Date.now()); finish(); };
const render = () => {
note.noticeEl.empty();
note.noticeEl.createSpan({ text: `🔒 Re-encrypting “${title}” in ${left}s… ` });
note.noticeEl.createEl("button", { text: "Keep unlocked" }).onclick = () => { cancelled = true; abortReArm(); };
};
render();
const iv = window.setInterval(async () => {
if (cancelled) return;
// 0.140.1: a modify/open in the folder DURING the countdown re-arms and
// aborts (the header promised "editing re-arms" — the old code ignored it).
if ((this.lastTouch.get(folder) ?? 0) > startedAt) { abortReArm(); return; }
left -= 1;
if (left > 0) { render(); return; }
finish();
// Still applicable? (re-locked/deleted during the countdown.)
if (!this.plugin.reEncryptWatching().some((w) => w.folder === e.folder && w.rootId === e.rootId)) return;
if (this.folderHasOpenEditor(folder)) { this.lastTouch.set(folder, Date.now()); return; } // opened it mid-countdown → abort
// 0.140.1: re-check the key is STILL in memory — never pop a password
// modal from a background timer (it may have locked during the countdown).
if (!this.keyReady(folder)) { this.lastTouch.set(folder, Date.now()); return; }
const ok = await this.plugin.lockNoteSubtree(e.folder, e.rootId as unknown as import("./types").StashpadId, null, { silent: true });
if (ok) new Notice(`Re-encrypted “${title}”.`);
}, 1000);
// 0.140.1: track so dispose() clears it — a raw setInterval survived plugin
// unload and wrote stale settings over the reloaded instance.
this.countdownTimers.add(iv);
this.plugin.registerInterval(iv);
}
/** Called from the plugin's onunload stop any in-flight countdown so it
* can't fire lockNoteSubtree/saveSettings on a dead instance. */
dispose(): void {
for (const iv of this.countdownTimers) window.clearInterval(iv);
this.countdownTimers.clear();
this.counting.clear();
}
}
function touchFolder(map: Map<string, number>, folder: string, when: number): void {
map.set(folder.replace(/\/+$/, ""), when);
}

View file

@ -95,6 +95,10 @@ export class RenderCacheStore implements RenderCacheLike {
const obj = { schema: CACHE_SCHEMA, entries: Object.fromEntries(this.map) };
await this.app.vault.adapter.write(this.path, JSON.stringify(obj));
} catch (e) {
// Re-arm dirty so the next set()/evict() retries the write. Losing it
// here would strand an evicted (deleted/locked) note's plaintext in the
// cache file after a transient failure — defeating secure-delete. (0.140.5)
this.dirty = true;
console.warn("[Stashpad] render cache save failed", e);
}
});

View file

@ -140,7 +140,7 @@ export const COMMAND_META: CommandMeta[] = [
{ id: "commandPalette", label: "Command palette (Stashpad only)", desc: "Default: Mod+K — open a command palette listing only Stashpad's commands, with Sift search.", defaultPrimary: "Mod+K" },
{ id: "lockSelection", label: "Encrypt (lock) selection", desc: "Encrypt the selected note(s) + their children into a locked .stashenc bundle in place (prompts to unlock first if needed). No default chord.", defaultPrimary: "" },
{ id: "unlockAll", label: "Decrypt (unlock) locked notes in view", desc: "Decrypt every locked stash shown in the current view back into place, skipping any that can't be read. No default chord.", defaultPrimary: "" },
{ id: "moveToArchive", label: "Move selection to archive (encrypt)", desc: "Move the selected note(s) to the default archive folder, encrypted on arrival. Undoable. No default chord.", defaultPrimary: "" },
{ id: "moveToArchive", label: "Move selection to archive", desc: "Move the selected note(s) into this folder's own archive subfolder (drops out of search; plaintext unless this folder encrypts its archive). Undoable. No default chord.", defaultPrimary: "" },
{ id: "encryptDelete", label: "Encrypt & delete selection", desc: "Send the selected note(s) to the encrypted trash (recoverable with your password, Ctrl/Cmd+Z undoable). No default chord.", defaultPrimary: "" },
{ id: "copyNotes", label: "Copy notes (note clipboard)", desc: "Copy the selected note(s) as NOTES: paste in the list to duplicate them (new ids), or anywhere else to paste their text. Skipped when text is highlighted (normal copy wins).", defaultPrimary: "Mod+C" },
{ id: "cutNotes", label: "Cut notes", desc: "Cut the selected note(s): paste in the list to MOVE them, or in the composer to extract their text and delete the originals (undoable).", defaultPrimary: "Mod+X" },
@ -166,6 +166,14 @@ export function buildDefaultBindings(): CommandBindingMap {
* true to bring the UI back. */
const SHOW_DEVICE_APPROVAL_UI = false;
/** 0.135.0: the vault-level "Sharing" section (shared-password setup UI) is
* hidden redundant, since the vault password itself can be shared out-of-band
* (Signal, password manager) from the moment it's set up. The service code
* (setSharedPassword/removeSharedPassword/hasSharedPassword) stays DORMANT, and
* a vault that ALREADY has a shared password still gets the manage/turn-off UI
* so it isn't stranded. Flip to true to bring the setup UI back. */
const SHOW_SHARING_UI = false;
/** Per-folder overhaul: the dedicated "Forget password on this device" buttons are
* discontinued (passwords are retained, never deleted see the no-delete policy).
* The forgetKeychain service code stays for remove-encryption + possible reinstate;
@ -182,6 +190,28 @@ const SHOW_FORGET_KEYCHAIN_BUTTON = false;
* The six "encrypt" flags are the 6-checkbox model: a content flag + a filename
* flag for each of folder / archive / trash. A filename flag is only meaningful
* when its content flag is on (the UI greys it out otherwise). */
/** 0.138.0: one watchlist entry a subtree that WAS encrypted and is now
* plaintext. Per-SUBTREE (rootId) granularity: children ride with their root. */
export interface ReEncryptWatchEntry {
folder: string;
rootId: string;
title: string;
count: number;
/** ISO timestamp of the unlock/restore that put it on the list. */
unlockedAt: string;
/** How it became plaintext: an ad-hoc unlock, or a restore from trash. */
via: "unlock" | "restore";
/** Dismissed by the user lives in the "Removed from watchlist" section
* (recoverable) and is excluded from sweeps/nudges. */
removed?: boolean;
removedAt?: string;
/** 0.139.0 (peek): per-note override of the auto-re-encrypt idle delay
* (minutes). Wins over the per-folder + global settings. 0 = off for this
* note; undefined = fall through to folder/global. */
peekMin?: number;
}
export interface FolderEncPrefs {
/** Folder is an archive (de-indexed from cross-folder search). Supersedes the
* legacy `archiveFolders[]` list over time; both honored during migration. */
@ -195,6 +225,9 @@ export interface FolderEncPrefs {
archiveEncryptFilenames?: boolean;
trashEncryptContent?: boolean;
trashEncryptFilenames?: boolean;
/** 0.139.0 (peek): auto-re-encrypt this folder's unlocked notes after N idle
* minutes. Overrides the global `reEncryptAfterMin`; 0/undef = use global. */
reEncryptAfterMin?: number;
}
export interface StashpadSettings {
@ -296,19 +329,39 @@ export interface StashpadSettings {
* encrypt OR list those, so encrypted-trash + the recoverable trash view won't
* apply. The `_deleted/` store is the secure default precisely because it's the
* only location Stashpad fully controls. */
encryptTrashFollowObsidian?: boolean;
/** Drop the in-memory key after N idle minutes (0 = never). */
encryptionIdleLockMinutes: number;
/** 0.98.14: hide the note title on locked placeholders (show a generic label
* instead) so a glance at the vault doesn't reveal what's locked. Default OFF
* (titles shown). Global for now; per-folder/trash scoping is future work. */
hideLockedTitles: boolean;
// (0.135.0: encryptionIdleLockMinutes removed — idle auto-lock is gone.
// 0.137.1: encryptTrashFollowObsidian + hideLockedTitles removed — trash
// handling and filename-hiding are per-folder (folderEncPrefs) now. Stale
// values in an existing data.json are simply ignored.)
/** 0.124.1: one-time migration marker the default "Toggle task" hotkey
* changed from H to G. Existing installs persist the full bindings map, so
* the default change alone wouldn't reach them; on first load we flip a
* still-default `H` to `G` once, then set this so it never re-flips (the user
* can rebind to H afterwards and it sticks). */
migratedToggleTaskG: boolean;
/** 0.136.0: one-time move of legacy dedicated-archive folders' notes into
* each folder's own `archive/` subfolder (per-folder archive overhaul). */
migratedArchiveToSubfolders: boolean;
/** 0.137.0: one-time move of _deleted/ encrypted-trash blobs into each
* origin folder's own trash/ subfolder (per-folder trash overhaul). */
migratedTrashToSubfolders: boolean;
/** 0.138.0 (smart re-encrypt sweep): subtrees that WERE encrypted and got
* unlocked back to plaintext (ad-hoc unlock or restore-from-trash).
* Unlimited the "Previously encrypted" review view manages it. `removed`
* entries live in that view's "Removed from watchlist" section (a
* recoverable dismissal, not a delete). */
reEncryptWatch: ReEncryptWatchEntry[];
/** 0.138.0: on startup, if the watchlist has active entries, show a Notice
* with a Review button that opens the review view. Default OFF. */
reEncryptNudge: boolean;
/** 0.139.0 (peek): GLOBAL auto-re-encrypt idle delay (minutes). After a
* previously-encrypted note has been unlocked + idle this long, a cancellable
* countdown re-locks it. 0 = OFF (default). Per-folder / per-note override. */
reEncryptAfterMin: number;
/** 0.137.3: settings write generation for the multi-writer collision guard
* (see guardedSave in main.ts). Bumped on every save; a save that sees a
* HIGHER rev on disk knows another instance/machine wrote since. */
settingsRev?: number;
/** 0.125.1: quick relative time-adjust presets shown in the due-date / snooze
* picker (e.g. ["5m","15m","1h","1d"]). A +/- flip toggles add vs subtract. */
dueQuickAdjusts: string[];
@ -506,6 +559,9 @@ export interface StashpadSettings {
/** Keys (`<id>@<dueRaw>`) of task due-reminders already fired, so they don't
* re-fire on every launch. Bounded; pruned when it grows. */
notifiedDueKeys: string[];
/** 0.140.0: last-fired ms per task id for PERSISTENT reminders (`remindEvery`)
* so a persistent task re-notifies on its interval, not once. */
persistReminderLog?: Record<string, number>;
/** 0.71.0 / 0.71.2: JD Index Builder. Two flavors:
* - "Preview" writes a single Index.md inside the designated
* Stashpad folder, showing the would-be hierarchy + non-matches.
@ -577,9 +633,12 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
encryption: defaultEncryptionConfig(),
encryptTrash: false,
encryptTrashFilenames: false,
encryptionIdleLockMinutes: 0,
hideLockedTitles: false,
migratedToggleTaskG: false,
migratedArchiveToSubfolders: false,
migratedTrashToSubfolders: false,
reEncryptWatch: [],
reEncryptNudge: false,
reEncryptAfterMin: 0,
dueQuickAdjusts: ["5m", "15m", "30m", "1h", "1d", "1w"],
archiveFolders: [],
folderEncPrefs: {},
@ -1022,12 +1081,12 @@ export class StashpadSettingTab extends PluginSettingTab {
private promptSetFolderPassword(folder: string): void {
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: true, title: `Set password for “${folder.split("/").pop()}`,
intro: "A separate password just for this folder. Share it with collaborators out-of-band (a password manager, Signal, in person). There is NO recovery if it's lost.",
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.setupFolderKey(folder, next, this.folderKeyLabel(folder), remember); } catch (e) { return (e as Error).message; } new Notice("Folder password set — share it securely."); this.update?.(); return null; } }).open();
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.setupFolderKey(folder, next, this.folderKeyLabel(folder), remember); } catch (e) { return (e as Error).message; } new Notice("Folder password set — share it securely."); this.update?.(); this.pfeRerender?.(); return null; } }).open();
}
private promptChangeFolderPassword(folder: string): void {
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: true, title: `Change password for “${folder.split("/").pop()}`,
intro: "Re-wraps this folder's key under a new password. The OLD password stops working on THIS device right away (un-synced copies elsewhere keep working until they sync). To truly cut off someone who left, you'll want “Rotate key” (re-encrypts everything) — coming soon.",
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.changeFolderPassword(folder, next, remember); } catch (e) { return (e as Error).message; } new Notice("Folder password changed."); this.update?.(); return null; } }).open();
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.changeFolderPassword(folder, next, remember); } catch (e) { return (e as Error).message; } new Notice("Folder password changed."); this.update?.(); this.pfeRerender?.(); return null; } }).open();
}
private promptRotateFolderKey(folder: string): void {
const name = folder.split("/").pop() || folder;
@ -1037,12 +1096,12 @@ export class StashpadSettingTab extends PluginSettingTab {
(ok) => {
if (!ok) return;
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: true, title: `New password for “${name}`, intro: "The new password to use after rotating. Share it only with people who should keep access.",
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; const n = await this.plugin.rotateFolderKey(folder, next, remember); if (n < 0) return "Rotation failed — see the notice."; this.update?.(); return null; } }).open();
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; const n = await this.plugin.rotateFolderKey(folder, next, remember); if (n < 0) return "Rotation failed — see the notice."; this.update?.(); this.pfeRerender?.(); return null; } }).open();
}).open();
}
private promptUnlockFolder(folder: string): void {
new EncryptionPasswordModal(this.app, { mode: "unlock", offerKeychain: true, title: `Unlock “${folder.split("/").pop()}`, intro: "Enter this folder's password.",
onSubmit: async ({ current, remember }) => { const ok = await this.plugin.encryption.unlockFolder(folder, current!, remember); if (!ok) return "Wrong password. Try again."; new Notice("Folder unlocked."); this.update?.(); return null; } }).open();
onSubmit: async ({ current, remember }) => { const ok = await this.plugin.encryption.unlockFolder(folder, current!, remember); if (!ok) return "Wrong password. Try again."; new Notice("Folder unlocked."); this.update?.(); this.pfeRerender?.(); return null; } }).open();
}
/** Gate for settings that do nothing without vault encryption: returns true if
@ -1059,7 +1118,7 @@ export class StashpadSettingTab extends PluginSettingTab {
if (!next) return "Enter a password.";
try { await this.plugin.encryption.setup(next, remember, this.plugin.settings.authorName || "This device"); } catch (e) { return (e as Error).message; }
new Notice("Encryption set up — now switch on the setting you wanted.");
this.update?.();
this.update?.(); this.pfeRerender?.();
return null;
},
}).open();
@ -1069,6 +1128,9 @@ export class StashpadSettingTab extends PluginSettingTab {
/** The folder selected in the per-folder dropdown kept on the tab instance so it
* survives the declarative `update()` re-renders (no ghosting between folders). */
private pfeSelected: string | null = null;
/** 0.136.0: local re-render for the per-folder encryption panel (see
* renderPerFolderEncryption update() alone left stale toggle state). */
private pfeRerender: (() => void) | null = null;
/** 0.118.6: selected folder for the (searchable) per-folder icon control in
* Folders & Storage. */
private iconPickFolder: string | null = null;
@ -1079,9 +1141,8 @@ export class StashpadSettingTab extends PluginSettingTab {
host.createEl("p", { cls: "setting-item-description" }).setText("Set up vault encryption above first. Per-folder passwords layer on top: a folder with its own password uses a separate key; folders without one use the vault password.");
return;
}
// Merged archive + trash caution (the same warnings the folder-panel modal shows
// on the action) — surfaced here at the start of the folder section.
host.createEl("p", { cls: "setting-item-description stashpad-enc-warning" }).setText("⚠️ Encryption has no recovery — if you lose a password, anything encrypted under it (notes, archived items, and encrypted-trash items) is gone for good. Locking / archiving / secure-deleting permanently removes the plaintext; the encrypted copy is the only one left. A plaintext archive only de-indexes notes from search — it does NOT encrypt them.");
// (The no-recovery caution now lives in the single merged warning callout at
// the top of the Vault Encryption section — 0.134.3.)
const folders = this.plugin.discoverStashpadFolders();
if (folders.length === 0) { host.createEl("p", { cls: "setting-item-description" }).setText("No Stashpad folders found yet."); return; }
// Keep a VALID selection across re-renders (default to the first folder).
@ -1089,15 +1150,22 @@ export class StashpadSettingTab extends PluginSettingTab {
// One dropdown to pick the folder; the panel below shows only that folder's
// options — so the section stays compact regardless of folder count.
// 0.136.0: the panel re-renders LOCALLY (pfeRerender) — `this.update?.()`
// proved unreliable for refreshing section content in place (toggles kept
// stale values: a cancelled password prompt left the switch flipped, and
// the hide-filenames sub-toggles never enabled after their parent did).
const panel = host.createDiv({ cls: "stashpad-folderenc-panel" });
this.pfeRerender = () => { panel.empty(); this.renderFolderEncPanel(panel, this.pfeSelected!); };
new Setting(host).setName("Folder")
.setDesc("Pick a folder to configure its password, archive, and trash options. Everything below applies to the selected folder only.")
.addDropdown((d) => {
for (const f of folders) d.addOption(f, f);
d.setValue(this.pfeSelected!);
d.onChange((v) => { this.pfeSelected = v; this.update?.(); });
d.onChange((v) => { this.pfeSelected = v; this.pfeRerender?.(); });
});
const panel = host.createDiv({ cls: "stashpad-folderenc-panel" });
// (createDiv above put the panel before the dropdown in the DOM — move it
// back below so the layout reads Folder → panel.)
host.appendChild(panel);
this.renderFolderEncPanel(panel, this.pfeSelected);
}
@ -1119,17 +1187,15 @@ export class StashpadSettingTab extends PluginSettingTab {
const head = new Setting(host).setName("Password").setDesc(status);
if (inherited) {
head.addButton((b) => b.setButtonText("Manage on parent folder").onClick(() => { this.pfeSelected = owner; this.update?.(); }));
head.addButton((b) => b.setButtonText("Manage on parent folder").onClick(() => { this.pfeSelected = owner; this.update?.(); this.pfeRerender?.(); }));
} else if (!hasOwn) {
head.addButton((b) => b.setButtonText("Set folder password…").setCta().onClick(() => this.promptSetFolderPassword(folder)));
} else if (!unlocked) {
head.addButton((b) => b.setButtonText("Unlock…").setCta().onClick(() => this.promptUnlockFolder(folder)));
} else {
// "Lock" = forget THIS folder's key from memory (re-prompts on next access) —
// the per-folder analog of the vault "Lock now". It must NOT call
// plugin.lockFolder (that's the batch ENCRYPT op). Encrypting/decrypting the
// folder's content is the "Encrypt this folder's notes" toggle below.
head.addButton((b) => b.setButtonText("Lock (forget password)").onClick(() => { this.plugin.encryption.lockFolder(folder); new Notice("Folder locked — you'll re-enter its password next time."); this.update?.(); }));
// (0.135.0: the per-folder "Lock (forget password)" button is gone, same
// reasoning as the vault-level "Lock now". Encrypting/decrypting the
// folder's content is the "Encrypt this folder's notes" toggle below.)
head.addButton((b) => b.setButtonText("Change password…").onClick(() => this.promptChangeFolderPassword(folder)));
head.addButton((b) => { b.setButtonText("Rotate key…").onClick(() => this.promptRotateFolderKey(folder)); b.buttonEl.addClass("mod-warning"); });
}
@ -1139,39 +1205,41 @@ export class StashpadSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
};
new Setting(host).setName("Archive")
.setDesc("De-indexes this folder from cross-folder search; notes moved in get auto-encrypted (when “Encrypt archived notes” is on below).")
.addToggle((t) => t.setValue(!!prefs.archive).onChange(async (v) => {
await setPref({ archive: v, ...(v && prefs.archiveEncryptContent === undefined ? { archiveEncryptContent: true } : {}) });
const set = new Set((this.plugin.settings.archiveFolders ?? []).map((f) => f.replace(/\/+$/, "")));
if (v) set.add(folder); else set.delete(folder);
this.plugin.settings.archiveFolders = [...set];
if (!v && (this.plugin.settings.defaultArchiveFolder ?? "") === folder) this.plugin.settings.defaultArchiveFolder = undefined;
await this.plugin.saveSettings();
this.plugin.refreshFolderPanels();
this.update?.();
}));
// (The default archive target is chosen via the "Default archive folder" dropdown
// in the global settings below — no per-folder toggle needed.)
// (0.136.0: the per-folder "Archive" mark toggle is gone — every folder
// implicitly owns an `archive/` subfolder. What remains configurable is
// whether archiving INTO it encrypts: the "Encrypt archived notes" pair
// below.)
// 0.137.1: no more "Use global default" — the global follow-Obsidian's-
// trash option is gone; unset simply means Stashpad's own trash.
new Setting(host).setName("Trash handling").setDesc("Where notes deleted from this folder go.")
.addDropdown((d) => d
.addOption("", "Use global default")
.addOption("stashpad", "Stashpad encrypted trash")
.addOption("", "Stashpad trash (default)")
.addOption("obsidian", "Obsidian native trash")
.setValue(prefs.trashHandling ?? "")
.setValue(prefs.trashHandling === "obsidian" ? "obsidian" : "")
.onChange((v) => setPref({ trashHandling: (v || undefined) as FolderEncPrefs["trashHandling"] })));
const pair = (label: string, cKey: keyof FolderEncPrefs, fKey: keyof FolderEncPrefs, onContent?: (v: boolean) => Promise<void>) => {
// 0.136.0: `requireKey` gates turning a pair ON behind an actual unlock
// (vault key, or this folder's own key) — the archive/trash pairs used to
// flip on without ever proving a key exists. Every path ends in
// pfeRerender so the toggles + sub-toggles always show the REAL state
// (a cancelled password prompt used to leave the switch flipped, and the
// hide-filenames sub-toggles never enabled without a full reopen).
const pair = (label: string, cKey: keyof FolderEncPrefs, fKey: keyof FolderEncPrefs, onContent?: (v: boolean) => Promise<void>, requireKey = false) => {
new Setting(host).setName(label)
.addToggle((t) => t.setValue(!!prefs[cKey]).onChange(async (v) => {
if (v && requireKey) {
if (!this.encryptionOrOnboard()) { this.pfeRerender?.(); return; }
const dek = await this.plugin.ensureFolderUnlocked(folder);
if (!dek) { this.pfeRerender?.(); return; } // prompt cancelled/failed → revert
}
const patch: Partial<FolderEncPrefs> = {}; (patch as Record<string, unknown>)[cKey] = v; if (!v) (patch as Record<string, unknown>)[fKey] = false;
await setPref(patch);
if (onContent) await onContent(v);
this.update?.();
this.pfeRerender?.();
}));
new Setting(host).setName(`${label} — hide filenames`).setClass("stashpad-subsetting")
.addToggle((t) => { t.setValue(!!prefs[fKey]); t.setDisabled(!prefs[cKey]); t.onChange((v) => { const p: Partial<FolderEncPrefs> = {}; (p as Record<string, unknown>)[fKey] = v; void setPref(p); }); });
.addToggle((t) => { t.setValue(!!prefs[fKey]); t.setDisabled(!prefs[cKey]); t.onChange(async (v) => { const p: Partial<FolderEncPrefs> = {}; (p as Record<string, unknown>)[fKey] = v; await setPref(p); this.pfeRerender?.(); }); });
};
pair("Encrypt this folder's notes", "encryptContent", "encryptFilenames", async (v) => {
if (v) await this.plugin.lockFolder(folder); else await this.plugin.unlockFolder(folder);
@ -1181,8 +1249,37 @@ export class StashpadSettingTab extends PluginSettingTab {
const has = (this.plugin.settings.lockedSubtrees ?? []).some((e) => (e.folder || "").replace(/\/+$/, "") === folder);
await setPref({ encryptContent: has, ...(has ? {} : { encryptFilenames: false }) });
});
pair("Encrypt archived notes", "archiveEncryptContent", "archiveEncryptFilenames");
pair("Encrypt trashed notes", "trashEncryptContent", "trashEncryptFilenames");
pair("Encrypt archived notes", "archiveEncryptContent", "archiveEncryptFilenames", async (v) => {
// 0.137.2 (retro-apply): offer to lock what's ALREADY in the archive —
// the pref alone only affects future arrivals.
if (!v) return;
const plainCount = this.plugin.archivedPlainNotesIn(`${folder}/archive`).length;
if (plainCount === 0) return;
new ConfirmModal(this.app,
`Encrypt the ${plainCount} note${plainCount === 1 ? "" : "s"} already archived?`,
`${folder.split("/").pop()}”'s archive already holds ${plainCount} plaintext note${plainCount === 1 ? "" : "s"}. Encrypt ${plainCount === 1 ? "it" : "them"} now too? (Skipping leaves ${plainCount === 1 ? "it" : "them"} readable; only notes archived from now on get encrypted.)`,
"Encrypt them",
async (ok) => {
if (!ok) return;
const n = await this.plugin.encryptExistingArchiveNotes(folder);
new Notice(n > 0 ? `Encrypted ${n} archived note${n === 1 ? "" : "s"}.` : "Nothing was encrypted (cancelled or already locked).");
this.pfeRerender?.();
}).open();
}, true);
pair("Encrypt trashed notes", "trashEncryptContent", "trashEncryptFilenames", undefined, true);
// 0.139.0 (peek): per-folder auto-re-encrypt idle override. Blank = use the
// global setting; a number overrides it for this folder; 0 = off here.
new Setting(host).setName("Auto re-encrypt after idle minutes")
.setDesc("Override the global auto-re-encrypt timer for this folder. Blank = use global; 0 = off for this folder. A cancellable countdown always shows first, and open notes are never touched.")
.addText((t) => {
t.setPlaceholder("global");
t.setValue(prefs.reEncryptAfterMin == null ? "" : String(prefs.reEncryptAfterMin));
t.onChange((v) => {
const s = v.trim();
{ const n = Number(s); if (s === "") void setPref({ reEncryptAfterMin: undefined }); else if (Number.isFinite(n) && n >= 0) void setPref({ reEncryptAfterMin: Math.floor(n) }); else { t.setValue(prefs.reEncryptAfterMin == null ? "" : String(prefs.reEncryptAfterMin)); } }
});
});
// Subfolders: each shares this folder's key (inheritance), but you can encrypt
// their notes / hide their filenames individually.
@ -1202,7 +1299,7 @@ export class StashpadSettingTab extends PluginSettingTab {
if (v) await this.plugin.lockFolder(sub); else await this.plugin.unlockFolder(sub);
const has = (this.plugin.settings.lockedSubtrees ?? []).some((e) => (e.folder || "").replace(/\/+$/, "") === sub);
await setSubPref({ encryptContent: has, ...(has ? {} : { encryptFilenames: false }) });
this.update?.();
this.update?.(); this.pfeRerender?.();
}));
new Setting(host).setName(`${rel} — hide filenames`).setClass("stashpad-subsetting")
.addToggle((t) => { t.setValue(!!sp.encryptFilenames); t.setDisabled(!subLocked); t.onChange((v) => void setSubPref({ encryptFilenames: v })); });
@ -1511,12 +1608,40 @@ export class StashpadSettingTab extends PluginSettingTab {
const betaRow = host.createDiv({ cls: "stashpad-beta-row" });
betaRow.createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
betaRow.createEl("span", { cls: "stashpad-beta-note", text: "Encryption is in beta — keep your own backups of anything important." });
host.createEl("div", { cls: "stashpad-ai-disclaimer" }).setText(
"⚠️ AI-built, NOT human-audited. This encryption was written by an AI assistant — not designed, reviewed, or security-audited by a human, and not tested by any security professional. It may carry real security, privacy, and DATA-LOSS risks. Treat it as a best-effort nice-to-have that might buy a little time against a casual snoop — nothing is guaranteed. Do NOT rely on it for anything sensitive, and always keep your own unencrypted backups of anything important.",
// 0.134.3: ONE merged warning callout (was three: the AI disclaimer box,
// a ⚠️ description paragraph here, and a "no recovery" callout at the top
// of the per-folder section). Same .stashpad-enc-warning callout style.
const warn = host.createEl("div", { cls: "stashpad-enc-warning" });
warn.createEl("p").setText(
"⚠️ AI-built, NOT human-audited. This encryption was written by an AI assistant — not reviewed or security-audited by a human. Treat it as best-effort protection against a casual snoop, not a guarantee — and always keep your own unencrypted backups of anything important.",
);
host.createEl("p", { cls: "setting-item-description" }).setText(
"⚠️ Encryption protects what you lock in this vault. Each device unlocks with its own password (which never leaves the device); the vault key is shared with collaborators by approving their device — no shared password. If everyone with access loses their password, anything encrypted is gone for good. While encrypting, avoid a sync/cloud service writing the vault mid-operation — it can corrupt files.",
warn.createEl("p").setText(
"There is NO recovery. If you lose a password, anything encrypted under it (notes, archived items, encrypted-trash items) is gone for good. Locking, encrypted archiving, and secure-deleting permanently remove the plaintext — the encrypted copy is the only one left. (A plaintext archive only de-indexes notes from search; it does NOT encrypt them.)",
);
warn.createEl("p").setText(
"Each device unlocks with its own password (it never leaves the device); collaborators get access by device approval, not a shared password. If everyone with access loses their password, the content is unrecoverable. While encrypting or decrypting, avoid having a sync/cloud service write the vault mid-operation — it can corrupt files.",
);
// 0.137.4: ONE collapsible, toggle-free "how it works" reference block, so
// the individual setting rows below can stay one-liners instead of each
// re-explaining hide-filenames / plaintext-archive / trash / inheritance.
const info = host.createEl("details", { cls: "stashpad-enc-info" });
info.createEl("summary", { text: " How Stashpad encryption works (concepts)" });
const infoItem = (term: string, body: string): void => {
const p = info.createEl("p", { cls: "stashpad-enc-info-item" });
p.createEl("strong", { text: `${term}` });
p.appendText(body);
};
infoItem("Vault password vs per-folder passwords",
"One vault password protects everything by default. A folder can also get its OWN password (a separate key) so you can share just that folder with collaborators; subfolders inherit their nearest keyed ancestor's password. Folders without their own key use the vault password.");
infoItem("Encrypt notes vs Hide filenames",
"“Encrypt … notes” scrambles the note body into a locked .stashenc blob (the plaintext is removed). “Hide filenames” goes further: it replaces the on-disk name AND the placeholder title with a generic label, so a glance at the vault doesn't reveal WHAT is locked. Whether a title is hidden is decided when the note is locked.");
infoItem("Archive: plaintext vs encrypted",
"Every folder has its own “archive” subfolder. Archiving is a plain move by default — the notes drop out of cross-folder search but stay readable. Turn on “Encrypt archived notes” for a folder to lock notes as they're archived instead.");
infoItem("Trash: Stashpad vs Obsidian",
"Deleting a note normally sends it to that folder's own “trash” subfolder — encrypted if the folder encrypts its trash — and it's recoverable from the Trash view. Set a folder's “Trash handling” to “Obsidian native trash” to use Obsidian's plain deleted-files behavior instead (Stashpad can't encrypt or recover those).");
infoItem("No recovery",
"There is no backdoor and no reset. If a password is lost, everything encrypted under it is gone — so keep your own unencrypted backups of anything you can't afford to lose.");
const kdfProbe = () => enc.argonProbe();
const deviceLabel = () => (this.plugin.settings.authorName?.trim() || "This device");
@ -1621,10 +1746,11 @@ export class StashpadSettingTab extends PluginSettingTab {
},
}).open();
}));
} else {
new Setting(host).setName("Lock now").setDesc("Forget the password from memory until you re-enter it.").addButton((b) =>
b.setButtonText("Lock now").onClick(() => { enc.lock(); new Notice("Encryption locked."); this.update?.(); }));
}
// (0.135.0: the "Lock now" forget-from-memory button is gone — it confused
// more than it protected, and it's one of the flows users blamed for
// "something emptied my keys". The session key simply lives until Obsidian
// closes. Removing encryption or changing the password still re-prompts.)
if (SHOW_FORGET_KEYCHAIN_BUTTON && enc.keychainAvailable() && enc.isRemembered()) {
new Setting(host).setName("Forget password on this device").setDesc("Drops ONLY the copy saved in this device's keychain — your encryption stays set up and nothing is decrypted or deleted. You'll just re-type the password next session. (To turn encryption off entirely, use “Remove encryption” below.)").addButton((b) =>
@ -1753,12 +1879,31 @@ export class StashpadSettingTab extends PluginSettingTab {
// Nothing encrypted at all → clean removal.
proceedToConfirm("", false);
};
b.setButtonText("Remove…").onClick(() => void runRemoval());
// 0.136.0: loading button — the state check can take a while on slow
// (network) drives, and spam-clicks used to queue several removal runs
// (each spawning its own "delete encrypted contents" modal). While the
// check runs the button dims with a loading bar and SWALLOWS clicks.
let removalBusy = false;
b.setButtonText("Remove…").onClick(async () => {
if (removalBusy) return;
removalBusy = true;
b.setDisabled(true);
b.buttonEl.addClass("stashpad-btn-loading");
try { await runRemoval(); }
finally {
removalBusy = false;
b.setDisabled(false);
b.buttonEl.removeClass("stashpad-btn-loading");
}
});
b.buttonEl.addClass("mod-warning");
});
}
// ---- Sharing method 1: shared password (Model 1) ----
// 0.135.0: hidden unless a shared password ALREADY exists (see
// SHOW_SHARING_UI) — no new setups, but existing ones stay manageable.
if (SHOW_SHARING_UI || enc.hasSharedPassword()) {
new Setting(host).setName("Sharing").setHeading();
if (!enc.isUnlocked()) {
host.createEl("p", { cls: "setting-item-description" }).setText("Unlock encryption above to manage how this vault is shared.");
@ -1778,6 +1923,7 @@ export class StashpadSettingTab extends PluginSettingTab {
}));
}
}
} // end SHOW_SHARING_UI / existing-shared-password gate
// ---- Sharing method 2: device approval (Model 3) — members + requests ----
// Hidden in the per-folder overhaul (manual password sharing is the model
@ -1834,33 +1980,29 @@ export class StashpadSettingTab extends PluginSettingTab {
if (v && !this.encryptionOrOnboard()) { this.update?.(); return; }
this.plugin.settings.encryptTrashFilenames = v; await this.plugin.saveSettings();
})), ["trash", "filename", "encrypt"]));
items.push(this.renderDef("Follow Obsidian's trash setting instead", "OFF (recommended): encrypted-deleted notes go to Stashpad's own “_deleted/” store — the only trash location Stashpad fully controls, so it can encrypt, list, and restore them. ON: deletes follow Obsidian's “Deleted files” setting instead (system/OS trash or permanent). ⚠️ Stashpad CANNOT encrypt or recover notes that go to the system trash — so the encrypted trash + recoverable trash view won't apply. Only turn this on if you specifically want Obsidian's native trash behavior.", (s) =>
s.addToggle((t) => t.setValue(this.plugin.settings.encryptTrashFollowObsidian ?? false).onChange(async (v) => {
this.plugin.settings.encryptTrashFollowObsidian = v || undefined; await this.plugin.saveSettings();
})), ["trash", "obsidian", "system", "delete", "encrypt"]));
items.push(this.renderDef("Auto-lock after idle minutes", "Forget the password from memory after this many idle minutes (0 = never). Re-prompts on the next encryption action.", (s) =>
s.addText((t) => t.setValue(String(this.plugin.settings.encryptionIdleLockMinutes ?? 0)).onChange(async (v) => {
const n = Math.max(0, Math.floor(Number(v) || 0));
this.plugin.settings.encryptionIdleLockMinutes = n; await this.plugin.saveSettings();
})), ["auto-lock", "idle", "timeout", "lock"]));
items.push(this.renderDef("Hide titles of locked notes (default)", "The DEFAULT for hiding 🔒 locked-placeholder titles — used by any folder/trash that doesn't set its own “hide filenames” option in Per-Folder Passwords below (those override this). Shows a generic label instead of the note's title so a glance doesn't reveal what's locked. Default OFF.", (s) =>
s.addToggle((t) => t.setValue(this.plugin.settings.hideLockedTitles ?? false).onChange(async (v) => {
if (v && !this.encryptionOrOnboard()) { this.update?.(); return; }
this.plugin.settings.hideLockedTitles = v; await this.plugin.saveSettings();
this.plugin.refreshAllStashpadViews?.();
})), ["title", "hide", "private", "lock", "placeholder", "visibility"]));
// 0.138.0: re-encrypt sweep nudge (default OFF). Never auto-encrypts —
// the Notice just carries a Review button into the watchlist view.
items.push(this.renderDef("Remind me about unlocked encrypted notes", "On startup, if notes that WERE encrypted are still sitting plaintext (you unlocked them, or restored them from the trash), show a notice with a Review button that opens the “Previously encrypted” view. Nothing is ever re-encrypted automatically.", (s) =>
s.addToggle((t) => t.setValue(this.plugin.settings.reEncryptNudge ?? false).onChange(async (v) => {
this.plugin.settings.reEncryptNudge = v; await this.plugin.saveSettings();
})), ["remind", "nudge", "re-encrypt", "watchlist", "unlocked", "sweep"]));
items.push(this.renderDef("Auto re-encrypt after idle minutes (global)", "0 = OFF (default). After an unlocked, previously-encrypted note sits idle this long, Stashpad shows a “Re-encrypting in 10s… [Keep unlocked]” countdown you can cancel, then re-locks it. It NEVER re-encrypts a note you have open in an editor. Set per-folder overrides under “Per-Folder Passwords”; per-note in the “Previously encrypted” view.", (s) =>
s.addText((t) => t.setValue(String(this.plugin.settings.reEncryptAfterMin ?? 0)).onChange(async (v) => {
{ const n = Number(v.trim()); if (v.trim() === "") this.plugin.settings.reEncryptAfterMin = 0; else if (Number.isFinite(n) && n >= 0) this.plugin.settings.reEncryptAfterMin = Math.floor(n); else { t.setValue(String(this.plugin.settings.reEncryptAfterMin ?? 0)); return; } await this.plugin.saveSettings(); }
})), ["auto", "re-encrypt", "idle", "timer", "peek", "lock"]));
// (0.135.0: "Auto-lock after idle minutes" removed — the surprise
// key-forgetting caused more confusion than protection.)
// (0.137.1: the global "Follow Obsidian's trash setting" and "Hide titles
// of locked notes" options are gone — trash handling and filename-hiding
// are per-folder now ("Per-Folder Passwords" below). Whether a title is
// hidden is decided AT LOCK TIME by the folder's hide-filenames prefs and
// stored in the placeholder, so display simply follows the stored title.)
items.push(this.headingDef("Archive", "Archive folders are de-indexed from cross-folder search; mark a folder as an archive (and set whether it encrypts) under “Per-Folder Passwords” below."));
items.push(this.renderDef("Default archive folder", "Where the \"Move selection to archive\" command sends notes (they're auto-encrypted on arrival). Leaving this blank is fine — the command will just show you a list of your archive folders to pick from each time (or use the only one if you have a single archive). Mark a folder as an archive via the folder panel → right-click → \"Mark as archive\".", (s) => {
const archives = this.plugin.settings.archiveFolders ?? [];
s.addDropdown((d) => {
d.addOption("", archives.length ? "— pick from list each time —" : "— no archive folders yet —");
for (const f of archives) d.addOption(f, f);
const cur = this.plugin.settings.defaultArchiveFolder ?? "";
d.setValue(archives.includes(cur) ? cur : "");
d.onChange(async (v) => { this.plugin.settings.defaultArchiveFolder = v || undefined; await this.plugin.saveSettings(); });
});
}, ["archive", "default", "move", "encrypt", "folder"]));
// (0.136.0: the global "Archive" section is gone — every folder owns an
// `archive/` subfolder now; "Move selection to archive" always targets the
// current folder's own archive. Per-folder encryption of archived notes
// lives under "Per-Folder Passwords" below.)
items.push(this.headingDef("Archive", "Every folder has its own “archive” subfolder: “Move selection to archive” tucks notes into it, archived notes drop out of search, and everything shows up in the aggregated Archived view. Whether a folder's archive is encrypted is set per folder under “Per-Folder Passwords” below (“Encrypt archived notes”)."));
// ---- Per-folder passwords (per-folder overhaul) — below the global settings ----
items.push(this.sectionDef("Per-Folder Passwords", "Give a folder its own password (a separate key) so you can share just that folder with collaborators. Folders without their own password use the vault password.", (host) => {

View file

@ -47,7 +47,13 @@ export function buildFilename(slug: string, id: string): string {
}
export function parseIdFromFilename(basename: string): string | null {
const m = basename.match(/-([a-z0-9]{4,12})$/);
// Match EXACTLY what buildFilename emits: a trailing `-<id>` where <id> is
// 6 chars from id-service's alphabet (no l/o/0/1). The old {4,12} + full
// a-z0-9 class matched ordinary trailing words — `meeting-notes` and
// `quick-notes` both parsed to the same "notes", colliding the synthetic
// node ids minted for non-Stashpad files. (0.140.5 review.) The `-N`
// rename uniquifier keeps `-<id>` last, so this still recovers the id.
const m = basename.match(/-([abcdefghijkmnpqrstuvwxyz23456789]{6})$/);
return m ? m[1] : null;
}

View file

@ -113,6 +113,18 @@ export class SortStore {
const map = this.cache.get(folder) ?? {};
const path = `${folder}/${SORT_FILE}`;
const adapter = this.app.vault.adapter;
// 0.140.3 (review): merge on-disk parents we didn't touch this session, so a
// mutation before load() resolves can't clobber other parents' sort modes.
try {
if (await adapter.exists(path)) {
const parsed = JSON.parse(await adapter.read(path));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
if (!(k in map) && typeof v === "string" && VALID_MODES.has(v as SortMode)) map[k] = v as SortMode;
}
}
}
} catch { /* unreadable / absent — write what we have */ }
try {
if (Object.keys(map).length === 0) {
// Skip the exists() probe — remove() throws if it's missing, which

View file

@ -1,7 +1,7 @@
import { App, TFile, parseYaml, stringifyYaml } from "obsidian";
import { bytesToStr, unzipFiles, zipFiles, type ZipEntry } from "./zip";
import { newId } from "./id-service";
import { ROOT_ID, attachmentLinkPath, toAttachmentLink, type StashpadId } from "./types";
import { ROOT_ID, RESERVED_FRONTMATTER, attachmentLinkPath, toAttachmentLink, type StashpadId } from "./types";
export const STASH_EXT = "stash";
export const SCHEMA_VERSION = 1;
@ -121,7 +121,7 @@ export async function importStashZip(
buf: ArrayBuffer | Uint8Array,
destFolder: string,
existingIds: Set<StashpadId>,
opts: { dedupeExisting?: boolean; forceNewIds?: boolean; reparentRootsTo?: StashpadId | null } = {},
opts: { dedupeExisting?: boolean; forceNewIds?: boolean; reparentRootsTo?: StashpadId | null; stripReserved?: boolean } = {},
): Promise<ImportSummary> {
const zip = await unzipFiles(buf);
const manifestBytes = zip["manifest.json"];
@ -144,8 +144,13 @@ export async function importStashZip(
// Security: flatten to a safe single-segment name (zip-slip defense).
// Fall back to the note id (or a generated name) if the entry name is
// empty/traversal-only; a later collision check still de-dupes.
// Defense-in-depth: run the id fallback through safeZipEntryName too, so a
// future change to the note-entry filter can't let an attacker-controlled
// YAML `id` (e.g. "../../evil") escape the destination. Today the fallback
// is unreachable (note entries always end in .md), but don't rely on that.
const safeName = safeZipEntryName(name.slice("notes/".length))
|| `${(fm.id as string) || "imported-" + newId(4)}.md`;
|| safeZipEntryName(`${(fm.id as string) || "imported-" + newId(4)}.md`)
|| `imported-${newId(4)}.md`;
parsed.push({ originalName: safeName, fm, body });
}
@ -248,8 +253,28 @@ export async function importStashZip(
// or a reused existing file when deduping).
const rewrittenBody = rewriteImportedAttachmentLinks(p.body, attRoute, attachmentsFolder);
// For UNTRUSTED imports (external `.stash` bundles) strip reserved
// frontmatter the same way the markdown drop-import does, so a crafted
// bundle can't inject pin/due/assignee/position/parentLink state or forge
// structural fields. Authorship (who wrote it) and the original timestamps
// are retained; attachments are kept for the link-remap below. Trusted
// callers (unlock/restore, internal cut-paste) omit stripReserved so their
// own notes keep every field intact. (0.140.6 review — documented invariant.)
let srcFm: Record<string, any> = p.fm;
if (opts.stripReserved) {
const filtered: Record<string, any> = {};
for (const [k, v] of Object.entries(p.fm)) {
if (!RESERVED_FRONTMATTER.includes(k)) filtered[k] = v;
}
if (p.fm.author !== undefined) filtered.author = p.fm.author;
if (p.fm.contributors !== undefined) filtered.contributors = p.fm.contributors;
if (p.fm.created !== undefined) filtered.created = p.fm.created;
if (p.fm.modified !== undefined) filtered.modified = p.fm.modified;
if (Array.isArray(p.fm.attachments)) filtered.attachments = p.fm.attachments;
srcFm = filtered;
}
const newFm: Record<string, any> = {
...p.fm,
...srcFm,
id: newIdVal,
parent: newParent,
import_date: importDate,

View file

@ -21,12 +21,19 @@ export interface TaskItem {
author: { id: string; name: string } | null;
}
/** Title from a note filename: drop the trailing `-id` suffix, dashes → spaces. */
export function titleFromTaskFile(file: TFile): string {
return file.basename
.replace(/-[a-z0-9]{4,12}$/, "")
.replace(/-/g, " ")
.trim() || file.basename;
/** Title from a note filename: drop the trailing `-id` suffix, dashes spaces.
* Pass the note's real `id` (from frontmatter) so we only strip the actual id
* and not a legit trailing word `quarterly-budget-review` was losing "review"
* to the old blind `-[a-z0-9]{4,12}$` strip. When no id is available, fall back
* to stripping only an exact id-shaped token (id-service alphabet, 6 chars). */
export function titleFromTaskFile(file: TFile, id?: string | null): string {
let base = file.basename;
if (id && base.endsWith(`-${id}`)) {
base = base.slice(0, -(id.length + 1));
} else if (!id) {
base = base.replace(/-[abcdefghijkmnpqrstuvwxyz23456789]{6}$/, "");
}
return base.replace(/-/g, " ").trim() || file.basename;
}
/** Scan every Stashpad folder for task-flagged notes (the `task` tag, the legacy
@ -44,7 +51,13 @@ export function collectTasks(app: App, plugin: StashpadPlugin): TaskItem[] {
const task = fmHasTag(fm, "task") || fm.task === true || fm.completed !== undefined;
const dueRaw = typeof fm.due === "string" || typeof fm.due === "number" ? String(fm.due) : null;
let due: number | null = null;
if (dueRaw) {
if (typeof fm.due === "number") {
// A numeric `due` is a raw epoch value — Date.parse("1730000000000") is
// NaN, so parse it directly. Only accept plausible ms timestamps (≥ ~1973
// in ms) so a bare small number like `2026` isn't read as 2 seconds past
// epoch. (0.140.5 review.)
if (Number.isFinite(fm.due) && fm.due >= 1e11) due = fm.due;
} else if (dueRaw) {
const t = Date.parse(dueRaw);
if (!Number.isNaN(t)) due = t;
}
@ -53,7 +66,7 @@ export function collectTasks(app: App, plugin: StashpadPlugin): TaskItem[] {
file: f,
folder: dir,
id,
title: titleFromTaskFile(f),
title: titleFromTaskFile(f, id),
task,
completed,
due,

View file

@ -236,7 +236,12 @@ function renderRow(
if (t.author) meta.createSpan({ cls: "stashpad-review-author", text: `by ${t.author.name}` });
if (t.due != null) {
const overdue = t.due < now && !t.completed;
const isToday = t.due >= now - 86_400_000 && t.due < now + 86_400_000;
// Calendar "today" (midnight→midnight), matching the bucket boundaries —
// a ±24h window mislabels yesterday-11pm / tomorrow-9am as today and then
// renders them time-only with no date. (0.140.5 review.)
const startToday = new Date(now); startToday.setHours(0, 0, 0, 0);
const startTodayMs = startToday.getTime();
const isToday = t.due >= startTodayMs && t.due < startTodayMs + 86_400_000;
const dueEl = meta.createSpan({
cls: "stashpad-review-due",
text: isToday ? formatTimeOnly(t.due, plugin.settings) : formatDateOnly(t.due, plugin.settings),

View file

@ -31,6 +31,7 @@ export interface BuildTimePickerOpts {
/** Convert a typed (hh 124, period) into 24-hour hours. */
function to24(hh: number, period: "am" | "pm"): number {
if (hh === 0) return 0; // explicit midnight (24-hour entry)
if (hh > 12) return hh >= 24 ? 0 : hh; // already 24-hour
if (period === "am") return hh === 12 ? 0 : hh;
return hh === 12 ? 12 : hh + 12;
@ -101,9 +102,13 @@ export function buildTimePickerInto(pop: HTMLElement, opts: BuildTimePickerOpts)
syncAmpmEnabled();
const finalize = (): void => {
const hh = parseInt(hField.value || "12", 10) || 12;
// Distinguish a typed 0/00 (midnight) from an empty field. `|| 12` would
// coerce a real 0 to 12 and re-interpret it through the am/pm toggle
// (00:30+PM → 12:30 noon). Empty stays 12; a typed 0 stays 0. (0.140.5)
const hParsed = parseInt(hField.value, 10);
const hh = Number.isNaN(hParsed) ? 12 : hParsed;
const mm = parseInt(mField.value || "0", 10) || 0;
const is24 = hh > 12;
const is24 = hh > 12 || hh === 0;
opts.onFinalize({
hours24: to24(hh, period),
minutes: mm,

View file

@ -1,4 +1,4 @@
import { ItemView, WorkspaceLeaf, moment, setIcon } from "obsidian";
import { ItemView, Notice, WorkspaceLeaf, moment, setIcon } from "obsidian";
import type StashpadPlugin from "./main";
import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
import { ConfirmModal } from "./modals";
@ -58,7 +58,10 @@ export class StashpadTrashView extends ItemView {
* flat render order (for shift-range); `anchorIdx` is the last clicked row. */
private selected = new Set<string>();
private order: string[] = [];
private anchorIdx: number | null = null;
/** Shift-range anchor stored as a BLOB PATH (not an index): a restore/purge
* rebuilds `order` shorter, so an index anchor would point at the wrong row
* afterward. We re-derive the index from this path at shift-click. (0.140.7) */
private anchorBlob: string | null = null;
/** 0.130.0: shared view mode (same set as the Archive tab). Default per-folder. */
private trashMode: AggMode = "byfolder";
@ -67,6 +70,9 @@ export class StashpadTrashView extends ItemView {
const root = this.contentEl;
root.empty();
root.addClass("stashpad-trash-view-body");
// Reset the shift-range order at the TOP so "mixed" mode (which returns
// before the old reset point) can't leave a stale list for Mod+A. (0.140.7)
this.order = [];
const header = root.createDiv({ cls: "stashpad-trash-view-header" });
header.createEl("h3", { text: "Trash" });
@ -110,7 +116,16 @@ export class StashpadTrashView extends ItemView {
const bar = root.createDiv({ cls: "stashpad-trash-selbar" });
bar.createSpan({ cls: "stashpad-trash-selcount", text: `${this.selected.size} selected` });
const restoreSel = bar.createEl("button", { cls: "stashpad-trash-restore", text: "Restore selected" });
restoreSel.onclick = async () => { restoreSel.disabled = true; for (const b of [...this.selected]) await this.plugin.restoreDeletedAt(b, { silent: true }); this.clearSelection(); await this.render(); };
restoreSel.onclick = async () => {
restoreSel.disabled = true;
// Surface partial failures — silent:true suppresses per-item notices,
// so without this a key-locked item left behind reads as success. (0.140.7)
let failed = 0;
for (const b of [...this.selected]) { if (!(await this.plugin.restoreDeletedAt(b, { silent: true }))) failed++; }
if (failed > 0) new Notice(`${failed} item${failed === 1 ? "" : "s"} couldn't be restored (locked key?) — still in trash.`);
this.clearSelection();
await this.render();
};
const delSel = bar.createEl("button", { cls: "stashpad-trash-delete mod-warning", text: "Delete selected" });
delSel.onclick = () => this.confirmPurge([...this.selected]);
const clear = bar.createEl("button", { cls: "stashpad-trash-iconbtn", text: "Clear" });
@ -239,29 +254,32 @@ export class StashpadTrashView extends ItemView {
}
}
private clearSelection(): void { this.selected.clear(); this.anchorIdx = null; }
private clearSelection(): void { this.selected.clear(); this.anchorBlob = null; }
/** Select every trash item (Mod+A). No-op when the list is empty. */
private selectAll(): void {
if (this.order.length === 0) return;
for (const b of this.order) this.selected.add(b);
this.anchorIdx = this.order.length - 1;
this.anchorBlob = this.order[this.order.length - 1] ?? null;
void this.render();
}
/** Selection click with cmd/ctrl (toggle), shift (range), plain (single). */
private onRowClick(idx: number, blob: string, e: MouseEvent): void {
if (e.shiftKey && this.anchorIdx !== null) {
const [a, b] = this.anchorIdx < idx ? [this.anchorIdx, idx] : [idx, this.anchorIdx];
// Re-derive the anchor index from its blob path — a restore/purge may have
// shifted rows since the anchor was set. A vanished anchor → treat as none.
const anchorIdx = this.anchorBlob !== null ? this.order.indexOf(this.anchorBlob) : -1;
if (e.shiftKey && anchorIdx >= 0) {
const [a, b] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
this.selected.clear();
for (let i = a; i <= b; i++) this.selected.add(this.order[i]);
} else if (e.metaKey || e.ctrlKey) {
if (this.selected.has(blob)) this.selected.delete(blob); else this.selected.add(blob);
this.anchorIdx = idx;
this.anchorBlob = blob;
} else {
if (this.selected.size === 1 && this.selected.has(blob)) this.selected.clear();
else { this.selected.clear(); this.selected.add(blob); }
this.anchorIdx = idx;
this.anchorBlob = blob;
}
void this.render();
}
@ -278,7 +296,12 @@ export class StashpadTrashView extends ItemView {
for (const b of blobs) await this.plugin.purgeDeletedAt(b);
this.clearSelection();
await this.render();
}).open();
},
"Cancel",
// Permanent, undecryptable delete — focus Cancel so a stray Enter can't
// purge irreversibly. (0.140.7)
/*dangerous*/ true,
).open();
}
async onClose(): Promise<void> { this.contentEl.empty(); }
@ -294,5 +317,5 @@ export async function openTrashView(plugin: StashpadPlugin): Promise<void> {
const leaf = workspace.getLeaf("tab");
await leaf.setViewState({ type: STASHPAD_TRASH_VIEW_TYPE, active: true });
workspace.revealLeaf(leaf);
returnToOriginOnClose(workspace, leaf, originLeaf);
returnToOriginOnClose(workspace, leaf, originLeaf, (ref) => plugin.registerEvent(ref));
}

View file

@ -1,5 +1,5 @@
import { TFile, TFolder, type App } from "obsidian";
import { ROOT_ID, RESERVED_SUBFOLDER_NAMES, isInReservedSubfolder, type StashpadId, type TreeNode } from "./types";
import { ROOT_ID, isReservedSubfolderName, isInReservedSubfolder, type StashpadId, type TreeNode } from "./types";
/** Walk a Stashpad folder's TFolder subtree and return every .md file under
* it. Iterative DFS rather than recursive to avoid a deep-recursion blow-up
@ -18,10 +18,11 @@ function collectMarkdown(app: App, folderPath: string): TFile[] {
if (child.extension === "md") out.push(child);
} else if (child instanceof TFolder) {
// 0.79.12: never descend into reserved Stashpad subfolders
// (_archive / _attachments / _authors / …) — their files aren't
// notes, and an archived original carrying a Stashpad id would
// otherwise surface as a phantom duplicate note.
if (!RESERVED_SUBFOLDER_NAMES.has(child.name)) stack.push(child);
// (_archive / _attachments / _authors / … and, 0.136.0, the per-folder
// archive/ + trash/) — their files aren't the folder's live notes, and
// an archived note carrying a Stashpad id would otherwise surface as a
// phantom duplicate. (`child` is always in subfolder position here.)
if (!isReservedSubfolderName(child.name)) stack.push(child);
}
}
}
@ -128,6 +129,23 @@ export class TreeIndex {
this.byPath.set(f.path, id);
}
// 0.140.3 (review): break MULTI-node parent cycles (A.parent=B, B.parent=A —
// e.g. two devices moving notes in opposite directions before sync merges).
// A cycle member reaches neither ROOT nor a live ancestor, so its whole
// subtree would silently vanish from the UI and any recursive child-walk
// would loop forever. Walk each node's parent chain; on a revisit, reparent
// that node to ROOT so the tree stays acyclic + visible.
for (const node of this.nodes.values()) {
if (node.id === ROOT_ID) continue;
const seen = new Set<string>([node.id]);
let p = node.parent ?? ROOT_ID;
while (p !== ROOT_ID && this.nodes.has(p)) {
if (seen.has(p)) { node.parent = ROOT_ID; break; }
seen.add(p);
p = this.nodes.get(p)!.parent ?? ROOT_ID;
}
}
for (const node of this.nodes.values()) {
if (node.id === ROOT_ID) continue;
const parentId = node.parent ?? ROOT_ID;
@ -375,7 +393,12 @@ export class TreeIndex {
return changed;
}
const parentId = ((fm?.parent as string | null | undefined) ?? ROOT_ID);
let parentId = ((fm?.parent as string | null | undefined) ?? ROOT_ID);
// 0.140.3 (review): the self-parent guard existed only in rebuild() — a note
// declaring itself its own parent (hand-edited / synced) arriving via the
// incremental `changed` event would attach as its OWN child, hanging every
// recursive child-walk. Pin it to ROOT here too.
if (parentId === id) parentId = ROOT_ID;
const created = (fm?.created as string) ?? "";
// Safety net: if the declared parent isn't ROOT and isn't in the

View file

@ -130,9 +130,36 @@ export const RESERVED_SUBFOLDER_NAMES: ReadonlySet<string> = new Set([
"_attachments", "_authors", "_exports", "_imports", "_processed",
"_archive", ".archive", // .archive is legacy (pre-0.79.10)
]);
/** True if any path segment is a reserved Stashpad subfolder. */
/** 0.136.0 (per-folder archive/trash overhaul): every Stashpad folder owns an
* `archive/` (and, Phase 2, a `trash/`) subfolder. These names are reserved
* ONLY as subfolders a TOP-LEVEL folder literally named "archive"/"trash"
* stays a normal folder. NOTE: a pre-existing user SUBfolder named
* `archive`/`trash` becomes that folder's archive/trash its notes stay
* reachable via the aggregated views. */
export const SUBFOLDER_ONLY_RESERVED_NAMES: ReadonlySet<string> = new Set(["archive", "trash"]);
/** A reserved name in SUBFOLDER position (never applies to a path's first
* segment for archive/trash; always applies for the underscore names). */
export function isReservedSubfolderName(name: string): boolean {
return RESERVED_SUBFOLDER_NAMES.has(name) || SUBFOLDER_ONLY_RESERVED_NAMES.has(name);
}
/** 0.136.0: the archive subfolder for a Stashpad folder. */
export function archiveSubfolderOf(folder: string): string {
return `${(folder || "").replace(/\/+$/, "")}/archive`;
}
/** 0.136.0: is `path` (a folder or file path) inside some folder's `archive/`
* subfolder? Matches any `archive` segment except a leading one (a top-level
* folder literally named "archive" is a normal folder, not a subfolder). */
export function isArchiveSubfolderPath(path: string): boolean {
return (path || "").replace(/\/+$/, "").split("/").slice(1).includes("archive");
}
/** True if any path segment is a reserved Stashpad subfolder. archive/trash
* count only in SUBfolder position (see SUBFOLDER_ONLY_RESERVED_NAMES). */
export function isInReservedSubfolder(path: string): boolean {
return path.split("/").some((seg) => RESERVED_SUBFOLDER_NAMES.has(seg));
return path.split("/").some((seg, i) =>
RESERVED_SUBFOLDER_NAMES.has(seg) || (i > 0 && SUBFOLDER_ONLY_RESERVED_NAMES.has(seg)));
}
/** True if the path lives under an archive subfolder (`_archive`/`.archive`)
* the import-originals graveyard, excluded from search + link surfaces. */
@ -259,7 +286,10 @@ export type LogEventType =
| "complete" | "uncomplete"
| "stash_export" | "stash_import"
| "attachment_add" | "attachment_remove"
| "palette_color_add" | "palette_color_remove";
| "palette_color_add" | "palette_color_remove"
// 0.136.0: one-time move of legacy dedicated-archive contents into the
// folder's own archive/ subfolder (per-folder archive overhaul).
| "archive_migration";
export interface LogEvent {
ts: string;

View file

@ -21,7 +21,10 @@ export class UndoStack {
async undo(): Promise<UndoAction | null> {
const a = this.undoStack.pop();
if (!a) return null;
try { await a.undo(); } catch (e) { console.error("Stashpad: undo failed", e); throw e; }
// On failure (e.g. a file transiently locked on a network vault) push the
// action BACK onto the undo stack so it stays retryable — otherwise it's
// lost from both stacks, possibly after a partial mutation. (0.140.5)
try { await a.undo(); } catch (e) { this.undoStack.push(a); console.error("Stashpad: undo failed", e); throw e; }
this.redoStack.push(a);
return a;
}
@ -31,7 +34,7 @@ export class UndoStack {
if (!a) return null;
// Undo-only actions have no redo handler — moving them back to the undo
// stack without re-applying is correct (and was previously a crash).
if (a.redo) { try { await a.redo(); } catch (e) { console.error("Stashpad: redo failed", e); throw e; } }
if (a.redo) { try { await a.redo(); } catch (e) { this.redoStack.push(a); console.error("Stashpad: redo failed", e); throw e; } }
this.undoStack.push(a);
return a;
}

View file

@ -5,11 +5,13 @@ import {
} from "obsidian";
import {
ROOT_ID, STASHPAD_VIEW_TYPE, RESERVED_FRONTMATTER, fmHasTag, fmAddTag, fmRemoveTag, parseAssignees, parseAuthorRef, attachmentLinkPath, toAttachmentLink,
archiveSubfolderOf, isArchiveSubfolderPath,
type StashpadId, type TimeFilter, type TreeNode, type ViewConfigState, type ViewMode, type ScrollPolicy,
} from "./types";
import { TreeIndex } from "./tree-index";
import { perf } from "./perf";
import { formatDateTime, formatDateOnly, formatTimeOnly } from "./format";
import { parseRecurrence, nextDueOnComplete } from "./recurrence";
import { OrderStore } from "./order-store";
import { SortStore, SORT_MODE_LABELS, SORT_MODES_ORDER } from "./sort-store";
import { FrontmatterSyncQueue, rebootstrapFolderFrontmatter } from "./frontmatter-sync";
@ -1838,7 +1840,7 @@ export class StashpadView extends ItemView {
// 0.98.14: optionally hide the real title (privacy) — show a generic label.
// Also fall back to generic when the on-disk title is empty (it was locked with
// hide-titles on, so the real title lives only inside the blob).
const hideTitle = (this.plugin.settings.hideLockedTitles ?? false) || !lk.title;
const hideTitle = !lk.title; // 0.137.1: hidden iff hidden AT LOCK TIME (empty stored title)
row.createSpan({ cls: "stashpad-locked-title", text: hideTitle ? "Locked note" : lk.title });
row.createSpan({ cls: "stashpad-locked-count", text: lk.count > 1 ? `${lk.count} notes · locked` : "locked" });
const unlockBtn = row.createEl("button", { cls: "stashpad-locked-unlock", text: "Unlock" });
@ -6632,31 +6634,20 @@ export class StashpadView extends ItemView {
}
}
/** 0.98.28 (Phase 4): move the selected note(s) into an archive folder, which
* auto-encrypts them on arrival. Uses the default archive folder if set; else
* the only archive folder if there's just one; else offers a pick-list. */
/** 0.136.0: move the selected note(s) into THIS folder's own `archive/`
* subfolder (plaintext unless the folder's "Encrypt archived notes" is on). */
async cmdMoveToArchive(): Promise<void> {
const targets = this.getActionTargets();
if (targets.length === 0) return;
const cur = (this.noteFolder ?? "").replace(/\/+$/, "");
// If we're ALREADY in an archive folder, don't silently re-home to a DIFFERENT
// archive (the old bug: the current folder was filtered out, so the default was
// ignored and a different archive was picked). The note is already archived.
if (this.plugin.isArchiveFolder(cur)) {
new Notice(`These notes are already in an archive folder (“${cur.split("/").pop()}”).`, 6000);
// 0.136.0 (per-folder archive): archiving ALWAYS targets this folder's own
// `archive/` subfolder — no picker, no default-archive setting, no separate
// dedicated archive folders.
if (isArchiveSubfolderPath(cur)) {
new Notice("These notes are already in an archive.", 6000);
return;
}
const archives = (this.plugin.settings.archiveFolders ?? []).map((f) => f.replace(/\/+$/, "")).filter((f) => f !== cur);
if (archives.length === 0) {
new Notice("No archive folder available. Mark a folder as archive first (Settings → Stashpad → Encryption → Per-folder, or the folder panel right-click).", 8000);
return;
}
const def = (this.plugin.settings.defaultArchiveFolder ?? "").replace(/\/+$/, "");
const dest = (def && archives.includes(def)) ? def : (archives.length === 1 ? archives[0] : null);
const go = (folder: string) => { void this.archiveSources(targets, folder); };
if (dest) { go(dest); return; }
// Several archives, no (valid) default → pick one.
new ArchiveFolderSuggestModal(this.app, archives, go).open();
void this.archiveSources(targets, archiveSubfolderOf(cur));
}
/** 0.98.34 (Phase 4): archive the given notes into `dest` encrypt each root's
@ -6667,12 +6658,14 @@ export class StashpadView extends ItemView {
* no async hook, so undo is a clean self-contained reversal. */
private async archiveSources(sources: TreeNode[], dest: string): Promise<void> {
const src = this.noteFolder;
// Archiving currently encrypts the moved notes (under the destination folder's
// key, or the vault key as fallback). If the destination is set to NOT encrypt
// its archive ("Encrypt archived notes" off = plaintext archive), that's a
// plaintext move — not wired into this command yet, so guide rather than fail.
const cleanDest = dest.replace(/\/+$/, "");
const encryptDest = (this.plugin.settings.folderEncPrefs ?? {})[cleanDest]?.archiveEncryptContent ?? true;
// 0.136.0: dest is `<parent>/archive` — the encrypt-or-not pref lives on the
// PARENT folder (folderEncPrefs[parent].archiveEncryptContent, plaintext
// default via the shared B1 resolver).
const parentFolder = cleanDest.replace(/\/archive$/, "");
const encryptDest = this.plugin.archiveEncryptFor(parentFolder);
// The subfolder may not exist yet — create it before any move/blob write.
try { if (!(await this.app.vault.adapter.exists(cleanDest))) await this.app.vault.createFolder(cleanDest); } catch { /* race / exists */ }
if (!encryptDest) {
// Plaintext archive (encryption off): MOVE the subtree's files into the
// destination (de-indexed via the archive flag, but not encrypted). Stashpad's
@ -6702,7 +6695,7 @@ export class StashpadView extends ItemView {
return;
}
if (!this.plugin.encryption?.isConfigured?.()) {
new Notice(`Archiving encrypts the notes, but encryption isn't set up yet (Settings → Stashpad → Encryption). Set it up — or turn off “Encrypt archived notes” for “${cleanDest.split("/").pop()}” to make it a plaintext archive.`, 9000);
new Notice(`Archiving here encrypts the notes ("Encrypt archived notes" is on for “${parentFolder.split("/").pop()}”), but encryption isn't set up yet (Settings → Stashpad → Encryption). Set it up — or turn that toggle off for a plaintext archive.`, 9000);
return;
}
const ids = new Set(sources.map((t) => t.id));
@ -6729,8 +6722,16 @@ export class StashpadView extends ItemView {
label: `Archive (${blobs.length})`,
undo: async () => {
// Restore the blobs back to the SOURCE folder (not the archive folder).
for (const b of blobs) { try { await this.plugin.unlockBundleAt(b, { silent: true, destFolder: src }); } catch (e) { console.warn("[Stashpad] archive undo failed", b, e); } }
blobs = [];
// 0.140.2: only DROP the ones that actually restored — if a restore
// failed (key prompt declined, error), keep it in `blobs` so undo can be
// retried and redo doesn't rebuild from a phantom-empty state.
const failed: string[] = [];
for (const b of blobs) {
try { if (!(await this.plugin.unlockBundleAt(b, { silent: true, destFolder: src }))) failed.push(b); }
catch (e) { failed.push(b); console.warn("[Stashpad] archive undo failed", b, e); }
}
blobs = failed;
if (failed.length) new Notice(`Undo incomplete: ${failed.length} archived item${failed.length === 1 ? "" : "s"} couldn't be restored (they're still in the archive). Try again, or restore from the Archived view.`, 8000);
this.tree.rebuild(src); this.render();
},
redo: async () => {
@ -6804,8 +6805,14 @@ export class StashpadView extends ItemView {
this.plugin.getUndoStack(folder).push({
label: `Secure delete (${blobs.length})`,
undo: async () => {
for (const b of blobs) { try { await this.plugin.restoreDeletedAt(b, { silent: true }); } catch (e) { console.warn("[Stashpad] secure-delete undo failed", b, e); } }
blobs = [];
// 0.140.2: keep the ones that failed to restore so undo is retryable.
const failed: string[] = [];
for (const b of blobs) {
try { if (!(await this.plugin.restoreDeletedAt(b, { silent: true }))) failed.push(b); }
catch (e) { failed.push(b); console.warn("[Stashpad] secure-delete undo failed", b, e); }
}
blobs = failed;
if (failed.length) new Notice(`Undo incomplete: ${failed.length} deleted item${failed.length === 1 ? "" : "s"} couldn't be restored (still in Trash). Try again, or restore from the Trash view.`, 8000);
this.tree.rebuild(folder); this.render();
},
redo: async () => {
@ -7157,7 +7164,7 @@ export class StashpadView extends ItemView {
localFolder: this.noteFolder,
// 0.124.0: surface encrypted/locked notes in search (placeholder when titles hidden).
lockedNotes: () => this.plugin.lockedSubtreesInFolder(this.noteFolder),
hideLockedTitles: this.plugin.settings.hideLockedTitles ?? false,
hideLockedTitles: false, // 0.137.1: global option removed; picker falls back to !lk.title
});
this.openSearchInstance = instance;
// Wrap onClose to clear our tracked reference when the modal closes.
@ -8969,7 +8976,7 @@ export class StashpadView extends ItemView {
ws.revealLeaf(leaf);
// 0.57.5: when this spawned tab closes, the originating Stashpad tab
// regains focus (see returnToOriginOnClose — shared by every opener).
returnToOriginOnClose(ws, leaf, originLeaf);
returnToOriginOnClose(ws, leaf, originLeaf, (ref) => this.plugin.registerEvent(ref));
}
/** Open a Stashpad folder's home in a new tab (any folder, not just
@ -9006,7 +9013,7 @@ export class StashpadView extends ItemView {
ws.revealLeaf(leaf);
// 0.133.0: closing the search-opened tab returns to the tab you searched
// from, not the tab to the right.
returnToOriginOnClose(ws, leaf, originLeaf);
returnToOriginOnClose(ws, leaf, originLeaf, (ref) => this.plugin.registerEvent(ref));
}
private async openFolderInNewTab(folder: string): Promise<void> {
@ -9029,7 +9036,7 @@ export class StashpadView extends ItemView {
ws.setActiveLeaf(leaf, { focus: true });
ws.revealLeaf(leaf);
// When the spawned leaf closes, restore focus to the originating tab.
returnToOriginOnClose(ws, leaf, originLeaf);
returnToOriginOnClose(ws, leaf, originLeaf, (ref) => this.plugin.registerEvent(ref));
}
// --- Open shortcuts ---
@ -9073,7 +9080,7 @@ export class StashpadView extends ItemView {
// When the edit tab closes, reveal the originating Stashpad leaf instead
// of whatever Obsidian picked (the tab to the right).
returnToOriginOnClose(ws, leaf, originLeaf);
returnToOriginOnClose(ws, leaf, originLeaf, (ref) => this.plugin.registerEvent(ref));
const view: any = leaf.view;
const editor: any = view?.editor;
@ -9228,22 +9235,44 @@ export class StashpadView extends ItemView {
// more useful: if any are incomplete, mark all complete).
const anyIncomplete = targets.some((t) => !this.isCompleted(t));
const newState = anyIncomplete; // true means "mark complete"
const priorStates: { id: StashpadId; path: string; was: boolean }[] = [];
// 0.140.1: capture the pre-toggle due + whether it rolled, so undo/redo can
// reverse a recurrence roll (not just the `completed` flag).
const priorStates: { id: StashpadId; path: string; was: boolean; dueBefore: string | null; rolledTo: string | null }[] = [];
const changedIds: StashpadId[] = [];
const rolled: Array<{ title: string; when: number }> = []; // recurring tasks rescheduled
for (const t of targets) {
if (!t.file) continue;
const was = this.isCompleted(t);
priorStates.push({ id: t.id, path: t.file.path, was });
const rec0 = this.app.metadataCache.getFileCache(t.file)?.frontmatter;
const dueBefore = rec0?.due != null ? String(rec0.due) : null;
const ps = { id: t.id, path: t.file.path, was, dueBefore, rolledTo: null as string | null };
priorStates.push(ps);
if (was === newState) continue;
let didRoll = false;
await this.app.fileManager.processFrontMatter(t.file, (fm) => {
if (newState) fm.completed = true;
// 0.140.0: completing a REPEATING task rolls it forward (stays an active
// task with a new due) instead of just going strikethrough.
const rec = newState ? parseRecurrence(fm.repeat as string | undefined) : null;
if (rec) {
const oldDue = fm.due != null ? Date.parse(String(fm.due)) : NaN;
const next = nextDueOnComplete(rec, Number.isFinite(oldDue) ? oldDue : null, Date.now());
fm.due = new Date(next).toISOString();
delete fm.completed;
ps.rolledTo = fm.due as string;
rolled.push({ title: (t.file!.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ").trim()) || "task", when: next });
didRoll = true;
} else if (newState) fm.completed = true;
else delete fm.completed;
});
this.completedState.set(t.file.path, newState); // 0.76.11
// A rolled task is NOT completed — reflect its real (incomplete) state.
this.completedState.set(t.file.path, didRoll ? false : newState);
changedIds.push(t.id);
}
this.render();
for (const r of rolled) {
this.plugin.notifications.show({ message: `🔁 Rescheduled “${r.title}” → ${formatDateTime(r.when, this.plugin.settings)}.`, kind: "success", category: "system", folder: this.noteFolder });
}
if (changedIds.length > 0) {
await this.log.append({
type: newState ? "complete" : "uncomplete",
@ -9272,7 +9301,12 @@ export class StashpadView extends ItemView {
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
if (!f) continue;
await this.app.fileManager.processFrontMatter(f, (fm) => {
if (p.was) fm.completed = true;
if (p.rolledTo != null) {
// 0.140.1: reverse a recurrence roll — restore the old due, keep
// it an active (incomplete) task.
if (p.dueBefore != null) fm.due = p.dueBefore; else delete fm.due;
delete fm.completed;
} else if (p.was) fm.completed = true;
else delete fm.completed;
});
if (changedIds.includes(p.id)) reverted.push(p.id);
@ -9292,7 +9326,8 @@ export class StashpadView extends ItemView {
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
if (!f) continue;
await this.app.fileManager.processFrontMatter(f, (fm) => {
if (newState) fm.completed = true;
if (p.rolledTo != null) { fm.due = p.rolledTo; delete fm.completed; } // re-roll
else if (newState) fm.completed = true;
else delete fm.completed;
});
}
@ -9378,8 +9413,17 @@ 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, quickAdjusts: this.plugin.settings.dueQuickAdjusts }).open();
void this.applyDue(targets, result.iso, result.assignees, false, {
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery,
});
}, { knownAuthors, currentAssignees, quickAdjusts: this.plugin.settings.dueQuickAdjusts,
// 0.140.1: recurrence is a per-note concept — only show/write it for a
// single target, else a multi-select would clobber 2..n's rules with #1's.
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.125.0: Snooze reschedule a task's due date. Reuses the due-date picker
@ -9405,8 +9449,8 @@ export class StashpadView extends ItemView {
/** Write the chosen due value (or clear it) across `targets`, with
* undo. Setting a date also flips `task: true`; clearing leaves the
* task flag intact (clearing a due "no longer a task"). */
private async applyDue(targets: TreeNode[], iso: string | null, assignees: Array<{ id: string; name: string }> = [], dueOnly = false): Promise<void> {
const prior: { id: StashpadId; path: string; due: unknown; task: unknown; assignedTo: unknown; assignedBy: unknown; wasTagged: boolean }[] = [];
private async applyDue(targets: TreeNode[], iso: string | null, assignees: Array<{ id: string; name: string }> = [], dueOnly = false, recur?: { repeat?: string; autoDoneAfter?: string; remindEvery?: string }): Promise<void> {
const prior: { id: StashpadId; path: string; due: unknown; task: unknown; assignedTo: unknown; assignedBy: unknown; wasTagged: boolean; repeat: unknown; autoDoneAfter: unknown; remindEvery: unknown }[] = [];
const changedIds: StashpadId[] = [];
// 0.78.1: who is doing the assigning (the local user) — stamped as
// assignedBy so the "assigned by me" filter works. Null if the user
@ -9422,10 +9466,21 @@ export class StashpadView extends ItemView {
if (!t.file) continue;
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
const wasTagged = this.isTaskTagged(t);
prior.push({ id: t.id, path: t.file.path, due: fm?.due, task: fm?.task, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, wasTagged });
prior.push({ id: t.id, path: t.file.path, due: fm?.due, task: fm?.task, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, wasTagged, repeat: fm?.repeat, autoDoneAfter: fm?.autoDoneAfter, remindEvery: fm?.remindEvery });
await this.app.fileManager.processFrontMatter(t.file, (m) => {
if (iso === null) delete m.due;
else { m.due = iso; m.task = true; }
// 0.140.0: recurrence + reminder fields — an empty string clears the
// field; a non-empty one sets it (and implies task-ness).
if (recur) {
const set3 = (k: "repeat" | "autoDoneAfter" | "remindEvery", v?: string) => {
const val = (v ?? "").trim();
if (val) { m[k] = val; m.task = true; } else delete m[k];
};
set3("repeat", recur.repeat);
set3("autoDoneAfter", recur.autoDoneAfter);
set3("remindEvery", recur.remindEvery);
}
// 0.125.0: Snooze passes dueOnly — reschedule the due date WITHOUT
// touching assignees (the plain applyDue would clear them on an empty
// list). Skip all assignee writes in that mode.
@ -9473,6 +9528,10 @@ export class StashpadView extends ItemView {
if (p.task === undefined) delete m.task; else m.task = p.task;
if (p.assignedTo === undefined) delete m.assignedTo; else m.assignedTo = p.assignedTo;
if (p.assignedBy === undefined) delete m.assignedBy; else m.assignedBy = p.assignedBy;
// 0.140.1: restore recurrence/reminder fields too (else undo left them).
if (p.repeat === undefined) delete m.repeat; else m.repeat = p.repeat;
if (p.autoDoneAfter === undefined) delete m.autoDoneAfter; else m.autoDoneAfter = p.autoDoneAfter;
if (p.remindEvery === undefined) delete m.remindEvery; else m.remindEvery = p.remindEvery;
});
this.taskTaggedState.set(p.path, p.wasTagged); // 0.85.1
}
@ -9924,26 +9983,29 @@ export class StashpadView extends ItemView {
});
return;
}
// For nesting: prevent dropping onto a descendant of the source (would create a cycle).
if (position === "into") {
for (const src of sourceNodes) {
if (this.isDescendant(targetId, src.id)) {
this.plugin.notifications.show({
message: `Can't nest "${this.titleForNode(src)}" under one of its own descendants — that would create a cycle.`,
kind: "warning",
category: "move",
folder: this.noteFolder,
});
return;
}
}
}
// Decide which parent the sources will end up under.
const newParentId = position === "into"
? targetId
: ((targetNode.parent as StashpadId) ?? ROOT_ID);
// Cycle guard — applies to ALL positions, not just "into" (0.140.4 review).
// For "before"/"after" the destination parent is targetNode.parent, so
// dropping a parent adjacent to its own child (newParent === the source) or
// to a deeper descendant (newParent is a descendant of the source) would
// write a self-parent / cycle to frontmatter. The tree-index guard would
// reset it in-memory, but the on-disk `parent` field would still be corrupt.
for (const src of sourceNodes) {
if (newParentId === src.id || this.isDescendant(newParentId, src.id)) {
this.plugin.notifications.show({
message: `Can't move "${this.titleForNode(src)}" under itself or one of its own descendants — that would create a cycle.`,
kind: "warning",
category: "move",
folder: this.noteFolder,
});
return;
}
}
// Detect cross-parent sources (relative to the new destination).
const isCross = sourceNodes.some((n) => (n.parent ?? ROOT_ID) !== newParentId);
if (isCross) {
@ -10169,7 +10231,7 @@ export class StashpadView extends ItemView {
const _delFolder = (this.noteFolder ?? "").replace(/\/+$/, "");
const _delFp = (this.plugin.settings.folderEncPrefs ?? {})[_delFolder] ?? {};
const _encryptTrash = _delFp.trashEncryptContent ?? this.plugin.settings.encryptTrash ?? false;
const _followObsidian = _delFp.trashHandling ? _delFp.trashHandling === "obsidian" : (this.plugin.settings.encryptTrashFollowObsidian ?? false);
const _followObsidian = _delFp.trashHandling === "obsidian"; // 0.137.1: global follow-Obsidian option removed
if (_encryptTrash && !_followObsidian) {
if (!this.plugin.encryption?.isConfigured?.()) {
// Don't silently fall back to the plaintext trash the user asked to avoid.
@ -11957,23 +12019,8 @@ export class StashpadView extends ItemView {
export { matchBinding } from "./view-keys";
export { properCaseFolderPath } from "./view-helpers";
/** 0.98.28: tiny fuzzy picker over archive folders, for "Move to archive" when
* more than one archive exists and no default is set. */
class ArchiveFolderSuggestModal extends SuggestModal<string> {
constructor(app: App, private folders: string[], private onPick: (folder: string) => void) {
super(app);
this.setPlaceholder("Move to which archive folder?");
}
getSuggestions(query: string): string[] {
const q = query.toLowerCase();
return this.folders.filter((f) => f.toLowerCase().includes(q));
}
renderSuggestion(folder: string, el: HTMLElement): void {
el.createDiv({ text: folder.split("/").pop() || folder });
el.createEl("small", { text: folder, cls: "stashpad-suggest-path" });
}
onChooseSuggestion(folder: string): void { this.onPick(folder); }
}
// (0.136.0: ArchiveFolderSuggestModal removed — archiving always targets the
// current folder's own `archive/` subfolder now, so there's nothing to pick.)
/** 0.98.29: minimal restore picker over the encrypted trash. The richer grouped
* trash VIEW is separate; this is the keyboard/command path. Entries are

View file

@ -19,7 +19,16 @@
* verbatim (with slashes intact) so each caller can apply the right zip-slip
* defense: `safeZipEntryName` (flatten) for `.stash`, `safeTrashRelPath`
* (preserve nested dirs) for rawtrash. */
import { strFromU8, strToU8, unzipSync, zipSync, type Zippable } from "fflate";
import { strFromU8, strToU8, unzipSync, zipSync, type Zippable, type UnzipFileInfo } from "fflate";
/** 0.140.2: decompression-bomb caps. fflate's sync `unzipSync` decompresses
* everything at once, but its `filter` runs off the central directory which
* carries each entry's UNCOMPRESSED size so we can reject a bomb BEFORE it
* inflates. Generous enough for real note+attachment bundles; blocks the
* ~KB-that-inflates-to-GB attack that could freeze/OOM every synced device. */
const MAX_TOTAL_INFLATED = 512 * 1024 * 1024; // 512 MiB across all entries
const MAX_ENTRY_INFLATED = 256 * 1024 * 1024; // 256 MiB single entry
const MAX_ENTRIES = 20_000;
export interface ZipEntry {
name: string;
@ -50,5 +59,14 @@ export function bytesToStr(b: Uint8Array): string {
* by fflate; names are returned verbatim (slashes intact) sanitize per call site. */
export function unzipFiles(buf: Uint8Array | ArrayBuffer): Promise<Record<string, Uint8Array>> {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
return Promise.resolve(unzipSync(u8));
let total = 0, count = 0;
const filter = (f: UnzipFileInfo): boolean => {
count += 1;
if (count > MAX_ENTRIES) throw new Error(`Refusing to unzip: too many entries (>${MAX_ENTRIES}) — possible zip bomb.`);
if (f.originalSize > MAX_ENTRY_INFLATED) throw new Error(`Refusing to unzip: entry "${f.name}" is ${Math.round(f.originalSize / 1048576)} MiB — possible zip bomb.`);
total += f.originalSize;
if (total > MAX_TOTAL_INFLATED) throw new Error(`Refusing to unzip: total size >${Math.round(MAX_TOTAL_INFLATED / 1048576)} MiB — possible zip bomb.`);
return true;
};
return Promise.resolve(unzipSync(u8, { filter }));
}

View file

@ -2924,7 +2924,11 @@ body.is-mobile .stashpad-jd-actions .setting-item-control {
inputs and squeezing the description. */
flex-shrink: 0;
max-width: 100%;
justify-content: flex-start;
/* 0.134.0: right-align controls, matching Obsidian's own setting rows (they
were flex-start, which left every section control the per-folder
encryption panel especially drifting toward the middle while the plain
rows above sat at the right edge; the mix read as "randomly positioned"). */
justify-content: flex-end;
}
/* 0.121.14: Obsidian 1.13's MOBILE settings use a 20px horizontal gutter
(measured: .setting-item padding-left = 20px). Several stashpad insets were
@ -5408,8 +5412,98 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
background: rgba(var(--color-red-rgb, 224, 49, 49), 0.12);
border-left: 3px solid var(--color-red, #e03131);
/* 0.121.5: a touch more vertical padding + more bottom breathing room so the
advice banner doesn't feel cramped (L/R stays at the section's 14px inset). */
advice banner doesn't feel cramped. */
padding: 11px 12px;
border-radius: 4px;
margin: 4px 0 18px;
/* 0.134.3: now a single merged multi-paragraph callout at the top of the
Vault Encryption section (was three separate warnings) inset 14px to
line up with the section's setting rows. */
margin: 4px 14px 18px;
font-size: 13px;
line-height: 1.5;
}
.stashpad-enc-warning p { margin: 0; }
.stashpad-enc-warning p + p { margin-top: 8px; }
/* 0.137.4: collapsible "how encryption works" concepts block one toggle-free
reference so the individual setting rows can stay one-liners. Collapsed by
default (details), neutral (not a warning). */
.stashpad-enc-info {
margin: 0 14px 14px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
font-size: 13px;
line-height: 1.5;
}
.stashpad-enc-info > summary {
cursor: pointer;
padding: 9px 12px;
font-weight: 600;
color: var(--text-normal);
list-style-position: inside;
}
.stashpad-enc-info[open] > summary { border-bottom: 1px solid var(--background-modifier-border); }
.stashpad-enc-info-item { margin: 0; padding: 9px 12px; color: var(--text-muted); }
.stashpad-enc-info-item + .stashpad-enc-info-item { border-top: 1px solid var(--background-modifier-border); }
.stashpad-enc-info-item strong { color: var(--text-normal); }
/* 0.134.3: Obsidian's mod-warning buttons are solid bright red with white text
glaring, and the white text reads poorly on it. Within Stashpad's settings
sections (Remove encryption, Rotate key, Turn off shared password, ) use a
quieter danger style: red tint + red text + red border, matching the
.stashpad-enc-warning callout language. Hover deepens the tint. */
.stashpad-settings-section button.mod-warning {
background: rgba(var(--color-red-rgb, 224, 49, 49), 0.14);
color: var(--color-red, #e03131);
border: 1px solid rgba(var(--color-red-rgb, 224, 49, 49), 0.45);
text-shadow: none;
}
.stashpad-settings-section button.mod-warning:hover {
background: rgba(var(--color-red-rgb, 224, 49, 49), 0.25);
color: var(--color-red, #e03131);
}
/* 0.136.0: loading state for slow-running buttons (Remove encryption's state
check on slow drives). Dims the button and sweeps a loading bar across it;
clicks are swallowed in JS while the class is on. */
button.stashpad-btn-loading {
position: relative;
opacity: 0.55;
pointer-events: none;
overflow: hidden;
}
button.stashpad-btn-loading::after {
content: "";
position: absolute;
left: 0; bottom: 0;
height: 2px; width: 40%;
background: currentColor;
animation: stashpad-btn-loading-sweep 1.1s ease-in-out infinite;
}
@keyframes stashpad-btn-loading-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
/* 0.138.0: re-encrypt sweep review modal rows + the watch view's Removed
section. Reuses the trash row classes for the view itself. */
.stashpad-reenc-list { max-height: 50vh; overflow-y: auto; margin: 8px 0 12px; }
.stashpad-reenc-row {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 6px; border-radius: 6px; cursor: pointer;
}
.stashpad-reenc-row:hover { background: var(--background-modifier-hover); }
.stashpad-reenc-row input[type="checkbox"] { margin-top: 3px; }
.stashpad-reenc-label { font-weight: 500; }
.stashpad-reenc-detail { font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-watch-removed { margin-top: 16px; }
.stashpad-watch-removed > summary {
cursor: pointer; font-size: var(--font-ui-smaller); color: var(--text-muted);
padding: 4px 0;
}
/* 0.139.0: per-note peek override input in the "Previously encrypted" view. */
.stashpad-watch-peek { width: 56px; text-align: center; }
/* 0.140.0: recurring/reminder fields in the due-date picker. */
.stashpad-due-recur { margin-top: 10px; border-top: 1px solid var(--background-modifier-border); padding-top: 8px; }
.stashpad-due-recur > summary { cursor: pointer; font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-due-recur-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
.stashpad-due-recur-row label { flex: 0 0 130px; font-size: var(--font-ui-smaller); color: var(--text-muted); }
.stashpad-due-recur-row input { flex: 1; }