mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.140.8: per-folder archive/trash subfolders, encryption cleanup, smart re-encrypt, recurring reminders, deep review pass
This commit is contained in:
parent
b5afc87d38
commit
fdf6f3c336
37 changed files with 2459 additions and 520 deletions
182
main.js
182
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
114
release-notes/0.140.8.md
Normal 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 B1–B6: 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
699
src/main.ts
699
src/main.ts
File diff suppressed because it is too large
Load diff
128
src/modals.ts
128
src/modals.ts
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
135
src/recurrence.ts
Normal 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
146
src/reencrypt-scheduler.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
310
src/settings.ts
310
src/settings.ts
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface BuildTimePickerOpts {
|
|||
|
||||
/** Convert a typed (hh 1–24, 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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
src/types.ts
36
src/types.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
205
src/view.ts
205
src/view.ts
|
|
@ -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
|
||||
|
|
|
|||
22
src/zip.ts
22
src/zip.ts
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
100
styles.css
100
styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue