mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.112.11: friendlier Remove encryption, trash management, settings reliability
This commit is contained in:
parent
6257018293
commit
ec6cc151ac
14 changed files with 606 additions and 196 deletions
140
main.js
140
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.111.0",
|
||||
"version": "0.112.11",
|
||||
"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.111.0",
|
||||
"version": "0.112.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
26
release-notes/0.112.11.md
Normal file
26
release-notes/0.112.11.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# 0.112.11 — Friendlier "Remove encryption" + settings reliability
|
||||
|
||||
## Removing encryption is now simple and unblockable
|
||||
- **No password retyping.** Removing encryption only asks you to type the confirmation
|
||||
phrase — not your password. So if you've **lost the password**, you can still remove
|
||||
encryption and start fresh.
|
||||
- **Locked notes:** if anything is still encrypted (locked), removal offers to either
|
||||
**decrypt everything** (back to normal notes) or, if you've lost the password,
|
||||
**permanently delete the locked content** — then proceeds.
|
||||
- **Encrypted trash:** a clear choice to **Decrypt** it back to your folders or
|
||||
**Discard** it (with a second confirmation). Discarding now actually deletes the trash
|
||||
files rather than leaving unreadable leftovers.
|
||||
- The "is anything still encrypted?" check is now near-instant instead of scanning the
|
||||
whole vault.
|
||||
|
||||
## Encrypted trash tab
|
||||
- **Permanent delete** of individual items (with a "can't be restored" warning),
|
||||
**multi-select** (click, ⌘/Ctrl-click, Shift-range), bulk **Restore/Delete selected**,
|
||||
and **⌘/Ctrl+A** to select all.
|
||||
|
||||
## Settings reliability
|
||||
- Fixed a bug where the **settings tab could render blank** (it stopped on a button
|
||||
that used an API not present on all builds), and another where it **didn't refresh**
|
||||
after actions like removing encryption.
|
||||
- Settings sections are now listed **alphabetically**.
|
||||
- Hardened rendering so a single misbehaving control can no longer blank the whole tab.
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
// Node 18+ (global fetch / WebSocket). macOS paths.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, copyFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
|
@ -110,6 +110,30 @@ function seedConfig() {
|
|||
writeFileSync(cfgPath, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Obsidian auto-updates by writing a newer `obsidian-<ver>.asar` into the
|
||||
// user-data-dir (NOT the .app bundle), and loads the newest asar present there.
|
||||
// The dev profile is isolated, so it never receives those updates and runs the
|
||||
// bundled (older, stable) version — which can render/behave differently from the
|
||||
// user's main app (e.g. the 1.13 declarative-settings API is absent on stable, so
|
||||
// dev tests silently exercise a different code path). Mirror the main profile's
|
||||
// newest asar into the dev profile so the dev instance matches the real app.
|
||||
const MAIN_OBSIDIAN_DIR = join(process.env.HOME, "Library", "Application Support", "obsidian");
|
||||
function syncInsiderAsar() {
|
||||
try {
|
||||
if (!existsSync(MAIN_OBSIDIAN_DIR)) return;
|
||||
const verOf = (f) => (f.match(/(\d+)\.(\d+)\.(\d+)/) || []).slice(1).map(Number);
|
||||
const cmp = (a, b) => { for (let i = 0; i < 3; i++) { const d = (verOf(a)[i] ?? 0) - (verOf(b)[i] ?? 0); if (d) return d; } return 0; };
|
||||
const asars = readdirSync(MAIN_OBSIDIAN_DIR).filter((f) => /^obsidian-.*\.asar$/.test(f)).sort(cmp);
|
||||
if (!asars.length) return; // main app on the bundled version too — nothing to mirror
|
||||
const newest = asars[asars.length - 1];
|
||||
const dst = join(USER_DATA_DIR, newest);
|
||||
if (!existsSync(dst)) {
|
||||
copyFileSync(join(MAIN_OBSIDIAN_DIR, newest), dst);
|
||||
console.log(`synced ${newest} into the dev profile (matches your main Obsidian version)`);
|
||||
}
|
||||
} catch (e) { console.warn("asar sync skipped:", e.message); }
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (await portReady()) {
|
||||
const v = await verifyVault();
|
||||
|
|
@ -117,6 +141,7 @@ async function start() {
|
|||
return;
|
||||
}
|
||||
seedConfig();
|
||||
syncInsiderAsar();
|
||||
console.log(`launching dedicated Obsidian (vault: ${VAULT_NAME}, port :${PORT}, isolated user-data-dir)…`);
|
||||
const child = spawn(OBSIDIAN_BIN, [`--remote-debugging-port=${PORT}`, `--user-data-dir=${USER_DATA_DIR}`], {
|
||||
detached: true, stdio: "ignore",
|
||||
|
|
|
|||
92
scripts/seed-demo.mjs
Normal file
92
scripts/seed-demo.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/* Seed a rich, nested demo note-folder for website screenshots.
|
||||
* node scripts/seed-demo.mjs "/abs/path/to/vault" "My Stash" [--force]
|
||||
* Filenames are human-readable (drives Stashpad's breadcrumb/header titles);
|
||||
* the canonical identity is the frontmatter `id`. --force replaces the folder
|
||||
* (only ever the demo folder it generates — never touches sibling notes).
|
||||
*/
|
||||
import { mkdir, writeFile, access, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const vault = process.argv[2];
|
||||
const folder = process.argv[3] || "My Stash";
|
||||
const force = process.argv.includes("--force");
|
||||
if (!vault) { console.error("usage: seed-demo.mjs <vaultPath> [folder] [--force]"); process.exit(1); }
|
||||
|
||||
const dir = join(vault, folder);
|
||||
let exists = false;
|
||||
try { await access(dir); exists = true; } catch {}
|
||||
if (exists && !force) { console.error(`REFUSED: ${dir} exists (use --force to regenerate)`); process.exit(1); }
|
||||
if (exists && force) await rm(dir, { recursive: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
let t = Date.parse("2026-06-12T09:00:00");
|
||||
const stamp = () => { const d = new Date(t); t += 60_000; return d.toISOString().slice(0, 19); };
|
||||
|
||||
const notes = [];
|
||||
const byId = new Map();
|
||||
// id, parent, name(=filename/title), body, extra
|
||||
const add = (id, parent, name, body, extra = {}) => {
|
||||
const n = { id, parent, name, created: stamp(), body: body.trim(), extra };
|
||||
notes.push(n); byId.set(id, n);
|
||||
};
|
||||
|
||||
await writeFile(join(dir, `Home.md`),
|
||||
`---\nid: __root__\nparent: __root__\ncreated: ${stamp()}\nattachments: []\n---\n# My Stash\nEverything I'm thinking about, one stack at a time.\n`);
|
||||
|
||||
// ── 1. Reading list ──────────────────────────────────────────────────────
|
||||
add("read", "__root__", "Reading list", `📚 **Reading list** — what I'm working through and the bits worth keeping.`);
|
||||
add("read-ah", "read", "Atomic Habits", `📕 **Atomic Habits** — James Clear. The core move: don't set goals, design systems. You don't rise to the level of your goals — you fall to the level of your systems.`);
|
||||
add("read-ah-hs", "read-ah", "Habit stacking", `**Habit stacking** to try this week: "After I pour my morning coffee, I will write three lines in my stash." Anchor the new habit to one I already never skip.`);
|
||||
add("read-ah-2", "read-ah", "The two-minute rule", `**The 2-minute rule:** scale any new habit down until it takes two minutes. "Read before bed" → "read one page." Make it so easy you can't say no.`);
|
||||
add("read-tfs", "read", "Thinking Fast and Slow", `📗 **Thinking, Fast and Slow** — Kahneman. System 1 is fast, intuitive, wrong a lot. System 2 is slow, deliberate, lazy. Most "intuition" is System 1 pattern-matching wearing a confident face.`);
|
||||
add("read-pp", "read", "The Pragmatic Programmer", `📘 **The Pragmatic Programmer** — Hunt & Thomas. DRY isn't about code, it's about knowledge. Two snippets can look identical and not be a violation if they encode different decisions.`);
|
||||
|
||||
// ── 2. Japan trip ────────────────────────────────────────────────────────
|
||||
add("jp", "__root__", "Japan trip — March", `✈️ **Japan trip — March** 🌸 Nine days, Tokyo → Kyoto → back. Blossom forecast says peak around the 28th. Fingers crossed.`);
|
||||
add("jp-tok", "jp", "Tokyo", `**Tokyo** (5 nights, Airbnb in Shimokitazawa). Base in the west, day-trip out. Grab a Suica card at the airport and top it up before anything else.`);
|
||||
add("jp-tok-d1", "jp-tok", "Day 1 — arrival", `**Day 1** — recover from the flight slowly. Shimokitazawa vintage shops in the morning, Shibuya crossing at dusk, conveyor-belt sushi when we can't keep our eyes open.`);
|
||||
add("jp-tok-d2", "jp-tok", "Day 2 — teamLab", `**Day 2** — teamLab Planets (book tickets NOW, they sell out), then Tsukiji outer market for lunch. Evening: tiny bars in Golden Gai.`);
|
||||
add("jp-kyo", "jp", "Kyoto", `**Kyoto** (3 nights). Slower pace. Fushimi Inari at sunrise to beat the crowds, Arashiyama bamboo grove, an afternoon doing absolutely nothing in a tea house.`);
|
||||
add("jp-pack", "jp", "Packing list", `**Packing** — pack light, you'll buy stuff. Comfortable walking shoes (20k steps/day), a foldable tote, portable battery, and cash — Japan still loves cash.`);
|
||||
add("jp-budget", "jp", "Budget", `**Budget** — flights booked. Aim for ¥12k/day for food + transit per person. Splurge once on a proper kaiseki dinner in Kyoto; balance it with conbini breakfasts.`);
|
||||
|
||||
// ── 3. Recipes ───────────────────────────────────────────────────────────
|
||||
add("rec", "__root__", "Recipes", `🍳 **Recipes** — the ones that actually made it into rotation.`);
|
||||
add("rec-ramen", "rec", "Weeknight miso ramen", `**Weeknight miso ramen** (20 min). Soften garlic + ginger in sesame oil, whisk in 2 tbsp miso + a splash of soy, add stock. Noodles, soft egg, whatever greens are wilting in the drawer. Done.`);
|
||||
add("rec-sd", "rec", "Sourdough method", `**Sourdough — my method.** 500g flour, 350g water, 100g active starter, 10g salt. Autolyse 1h, 4 stretch-and-folds, bulk till 50% risen, shape, cold-proof overnight, bake in a dutch oven at 250°C.`);
|
||||
add("rec-chx", "rec", "Sheet-pan harissa chicken", `**Sheet-pan harissa chicken.** Thighs + chickpeas + red onion tossed in harissa, honey, lemon. 220°C for 35 min. Finish with yogurt and mint. Feeds 4, dirties one pan.`);
|
||||
|
||||
// ── 4. Rust ──────────────────────────────────────────────────────────────
|
||||
add("rust", "__root__", "Rust — learning notes", `🦀 **Rust — learning notes.** Slowly fighting the borrow checker less and understanding it more.`);
|
||||
add("rust-own", "rust", "Ownership", `**Ownership.** Every value has exactly one owner; when the owner goes out of scope, the value is dropped. Move semantics by default — assigning transfers ownership, it doesn't copy.`);
|
||||
add("rust-bor", "rust", "Borrowing", `**Borrowing.** Either one mutable reference *or* any number of immutable ones — never both at once. That's the whole game: data races become compile errors.`);
|
||||
add("rust-life", "rust", "Lifetimes", `**Lifetimes, finally clicking.** An annotation doesn't change how long anything lives — it *describes* a relationship the compiler can't infer. \`'a\` means "lives at least as long as 'a."`);
|
||||
add("rust-err", "rust", "Error handling", `**Error handling.** No exceptions — \`Result<T, E>\` and the \`?\` operator. \`?\` early-returns the error, otherwise unwraps the Ok. \`thiserror\` for libraries, \`anyhow\` for apps.`);
|
||||
|
||||
// ── 5. Ideas inbox ───────────────────────────────────────────────────────
|
||||
add("idea", "__root__", "Ideas inbox", `💡 **Ideas inbox** — capture now, judge later. Most won't survive the week, and that's fine.`);
|
||||
add("idea-out", "idea", "Why outliners beat documents", `**Blog post: why outliners beat documents.** A document forces one order. An outliner lets structure emerge — write the thoughts, then drag them into shape. Working title: "Structure is a verb."`);
|
||||
add("idea-plant", "idea", "Plant-care reminders app", `**App idea: plant-care reminders** that account for season + light. Not "water every Tuesday" but "this fern, this window, this month." Probably already exists. Capture anyway.`);
|
||||
|
||||
// ── 6. This week (tasks) ─────────────────────────────────────────────────
|
||||
add("week", "__root__", "This week", `🎯 **This week** — the short list. If it's not here, it's not happening before Sunday.`);
|
||||
add("week-invoice", "week", "Send invoice to Meridian", `Send the invoice to Meridian for the May work.`, { task: true, due: "2026-06-20T17:00:00" });
|
||||
add("week-dentist", "week", "Book dentist cleaning", `Book the dentist cleaning — it's been a year.`, { task: true, due: "2026-06-23T09:00:00" });
|
||||
add("week-passport", "week", "Renew passport", `Renew the passport before it expires (need it for Japan!).`, { task: true, due: "2026-06-25T12:00:00" });
|
||||
add("week-itin", "week", "Draft itinerary email", `Draft the Japan itinerary email for the group.`, { task: true, completed: true, due: "2026-06-18T20:00:00" });
|
||||
|
||||
// ── 7. Home projects ─────────────────────────────────────────────────────
|
||||
add("home", "__root__", "Home projects", `🏠 **Home projects** — the slow-burn list.`);
|
||||
add("home-shelf", "home", "Garage shelving", `**Garage shelving.** 2×4 frame, plywood shelves, lag-bolted into the studs. Measure twice: the door swing eats 80cm on the left wall.`);
|
||||
add("home-faucet", "home", "Fix leaky faucet", `**Fix the leaky bathroom faucet.** Almost certainly a worn cartridge — match the brand first. Shut off the supply valves *before* getting cocky.`, { task: true, due: "2026-06-28T10:00:00" });
|
||||
|
||||
for (const n of notes) {
|
||||
const fm = [`id: ${n.id}`, `parent: ${n.parent}`, `created: ${n.created}`, `attachments: []`];
|
||||
if (n.extra.task) fm.push(`task: true`);
|
||||
if (n.extra.completed) fm.push(`completed: true`);
|
||||
if (n.extra.due) fm.push(`due: ${n.extra.due}`);
|
||||
const parentName = n.parent === "__root__" ? "Home" : byId.get(n.parent)?.name || "Home";
|
||||
fm.push(`parentLink: "[[${folder}/${parentName}]]"`);
|
||||
await writeFile(join(dir, `${n.name}.md`), `---\n${fm.join("\n")}\n---\n${n.body}\n`);
|
||||
}
|
||||
console.log(JSON.stringify({ folder: dir, count: notes.length + 1 }, null, 2));
|
||||
|
|
@ -313,24 +313,13 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
|
|||
return blobDir;
|
||||
}
|
||||
|
||||
/** Is ANY `.stashenc` blob on disk? Walks the ADAPTER (not vault.getFiles) —
|
||||
* adapter-written blobs can be invisible to the vault index for a while, and
|
||||
* this backs the "Remove encryption" hard guard, which must never miss one.
|
||||
* Skips `.obsidian/` only. */
|
||||
export async function anyStashencOnDisk(app: App): Promise<boolean> {
|
||||
const queue = [""];
|
||||
while (queue.length) {
|
||||
const dir = queue.shift()!;
|
||||
let listing;
|
||||
try { listing = await app.vault.adapter.list(dir || "/"); } catch { continue; }
|
||||
if (listing.files.some((f) => f.endsWith(`.${STASHENC_EXT}`))) return true;
|
||||
for (const f of listing.folders) {
|
||||
if (f === ".obsidian" || f.endsWith("/.obsidian")) continue;
|
||||
queue.push(f);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// 0.112.2: `anyStashencOnDisk()` (a full recursive adapter walk) was removed —
|
||||
// it took minutes on big vaults and its premise was wrong. Empirically (Claude
|
||||
// Dev Vault, pendingEncBlobs empty): `vault.getFiles()` DOES return
|
||||
// `_deleted/*.stashenc` (it's a normal, fully-indexed folder), so the old
|
||||
// comment "`_deleted/` blobs aren't indexed" was stale. The only location
|
||||
// `getFiles()` misses is the `.trash/` DOT-folder, which `plugin.encryptionState`
|
||||
// + `encryptionStateStrict` now handle directly. Use those instead.
|
||||
|
||||
/** Encrypt-delete a subtree into `_deleted/` (recoverable, encrypted), then
|
||||
* permanently delete the plaintext. Mirrors lockSubtree but the blob lives in the
|
||||
|
|
@ -411,6 +400,14 @@ export async function readDeletedMeta(app: App, blobPath: string): Promise<Delet
|
|||
catch { return null; }
|
||||
}
|
||||
|
||||
/** Permanently delete an encrypted-trash item: the `.stashenc` blob AND its
|
||||
* `.stashmeta` sidecar. No decrypt, no restore — the content is GONE forever.
|
||||
* Callers MUST confirm first (it's irreversible). 0.112.3. */
|
||||
export async function purgeDeletedBlob(app: App, blobPath: string): Promise<void> {
|
||||
await app.vault.adapter.remove(blobPath);
|
||||
try { await app.vault.adapter.remove(sidecarPath(blobPath)); } catch { /* sidecar may not exist */ }
|
||||
}
|
||||
|
||||
// ------- v2 backfill: encrypt Obsidian's pre-existing plaintext trash -------
|
||||
|
||||
/** Obsidian's in-vault trash folder ("Deleted files" → "Move to vault trash"). */
|
||||
|
|
|
|||
|
|
@ -112,6 +112,23 @@ export class EncryptionService {
|
|||
isRemembered(): boolean {
|
||||
try { return !!this.secretStore()?.getSecret(this.keychainId()); } catch { return false; }
|
||||
}
|
||||
/** The password saved in this device's keychain, or null. Lets flows that
|
||||
* need the password (e.g. Remove encryption) pull it automatically instead
|
||||
* of forcing the user to retype it. Falls back to the legacy entry. */
|
||||
rememberedPassword(): string | null {
|
||||
const ss = this.secretStore();
|
||||
if (!ss) return null;
|
||||
try { const v = ss.getSecret(this.keychainId()); if (v) return v; } catch { /* fall through */ }
|
||||
try { return ss.getSecret(LEGACY_KEYCHAIN_ID) || null; } catch { return null; }
|
||||
}
|
||||
/** True if the keychain holds a password that actually verifies against the
|
||||
* current key — so a flow can treat "keychain present" as proof of access
|
||||
* without prompting. */
|
||||
async verifyWithKeychain(): Promise<boolean> {
|
||||
const pw = this.rememberedPassword();
|
||||
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); }
|
||||
|
|
|
|||
|
|
@ -72,8 +72,10 @@ export class StashpadFolderPanelView extends ItemView {
|
|||
he.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); this.plugin.openFolderPicker(); } };
|
||||
const newBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-newfolder" });
|
||||
setIcon(newBtn, "folder-plus");
|
||||
newBtn.setAttr("aria-label", "New Stashpad folder");
|
||||
newBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); void this.createNewFolderFlow(); } };
|
||||
newBtn.setAttr("aria-label", "New Stashpad folder (folder switcher)");
|
||||
// 0.112.3: open the folder switcher (which already has a "Create" entry)
|
||||
// rather than a bespoke prompt — one place to create + switch folders.
|
||||
newBtn.onmousedown = (e) => { if (e.button === 0) { e.preventDefault(); e.stopPropagation(); this.plugin.openFolderPicker(); } };
|
||||
if (this.plugin.encryption?.isConfigured?.()) {
|
||||
const trashBtn = head.createEl("button", { cls: "stashpad-folderpanel-iconbtn stashpad-folderpanel-heading-trash" });
|
||||
setIcon(trashBtn, "trash-2");
|
||||
|
|
@ -83,23 +85,6 @@ export class StashpadFolderPanelView extends ItemView {
|
|||
this.renderFolders(folderSection.createDiv({ cls: "stashpad-folderpanel-list" }));
|
||||
}
|
||||
|
||||
/** Prompt for a name, create a new Stashpad folder, then open it. Wired to the
|
||||
* "+" button in the Folders heading. 0.111.0. */
|
||||
private async createNewFolderFlow(): Promise<void> {
|
||||
const raw = await new NewFolderPromptModal(this.app).prompt();
|
||||
const cleaned = (raw ?? "").trim().replace(/^\/+|\/+$/g, "");
|
||||
if (!cleaned) return; // cancelled or empty
|
||||
try {
|
||||
await this.plugin.createNewStashpad(cleaned);
|
||||
await this.plugin.waitForStashpadFolder(cleaned, 2000);
|
||||
await this.plugin.activateViewForFolder(cleaned);
|
||||
this.scheduleRender();
|
||||
new Notice(`Created Stashpad "${cleaned.split("/").pop()}".`);
|
||||
} catch (e) {
|
||||
new Notice(`Couldn't create folder: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private clampFrac(f: number): number {
|
||||
if (!Number.isFinite(f)) return 0.5;
|
||||
return Math.max(0.15, Math.min(0.85, f));
|
||||
|
|
@ -769,51 +754,3 @@ export async function openFolderPanelView(app: App): Promise<void> {
|
|||
await leaf.setViewState({ type: STASHPAD_FOLDER_PANEL_VIEW_TYPE, active: true });
|
||||
app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
/** Minimal single-line name prompt for "New Stashpad folder". Resolves the
|
||||
* entered name, or null if cancelled (Esc / Cancel / empty). 0.111.0. */
|
||||
class NewFolderPromptModal extends Modal {
|
||||
private value = "";
|
||||
private resolved = false;
|
||||
private resolve: ((v: string | null) => void) | null = null;
|
||||
|
||||
prompt(): Promise<string | null> {
|
||||
return new Promise((resolve) => { this.resolve = resolve; this.open(); });
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText("New Stashpad folder");
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl("p", {
|
||||
cls: "setting-item-description",
|
||||
text: "Vault-relative folder path. It's created (with intermediates) and seeded with a Home note, then opened.",
|
||||
});
|
||||
const input = contentEl.createEl("input", { type: "text", cls: "stashpad-newfolder-input" });
|
||||
input.placeholder = "my-stashpad";
|
||||
input.setCssStyles({ width: "100%", marginBottom: "12px" });
|
||||
input.addEventListener("input", () => { this.value = input.value; });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); this.finish(input.value); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); this.finish(null); }
|
||||
});
|
||||
const btns = contentEl.createDiv({ cls: "modal-button-container" });
|
||||
const create = btns.createEl("button", { text: "Create", cls: "mod-cta" });
|
||||
create.onclick = () => this.finish(this.value);
|
||||
const cancel = btns.createEl("button", { text: "Cancel" });
|
||||
cancel.onclick = () => this.finish(null);
|
||||
window.setTimeout(() => input.focus(), 0);
|
||||
}
|
||||
|
||||
private finish(v: string | null): void {
|
||||
if (this.resolved) return;
|
||||
this.resolved = true;
|
||||
this.resolve?.(v);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
// Closed via the X / click-out without an explicit choice → treat as cancel.
|
||||
if (!this.resolved) { this.resolved = true; this.resolve?.(null); }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
src/main.ts
106
src/main.ts
|
|
@ -8,7 +8,7 @@ import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
|
|||
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
|
||||
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
|
||||
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
|
||||
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree } from "./encryption-ops";
|
||||
import { EncryptionPasswordModal } from "./modals";
|
||||
import {
|
||||
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
|
||||
|
|
@ -79,6 +79,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
* wikilink rewrites Obsidian does when slug-renames move files — never
|
||||
* bump `modified` (or add the local user as a contributor). */
|
||||
rebootstrapInProgress = false;
|
||||
/** 0.112.0: paths of `.stashenc` blobs written via the adapter THIS session
|
||||
* that the vault's in-memory index may not have picked up yet. Backs the fast
|
||||
* encryption-state check so the removal guard never has to do a full recursive
|
||||
* adapter walk. Pruned by the create watcher (once indexed) and delete watcher. */
|
||||
private pendingEncBlobs = new Set<string>();
|
||||
/** 0.86.6: while `Date.now() < this`, a Stashpad view's activation
|
||||
* auto-focus skips grabbing the composer. Set by the folder panel before it
|
||||
* reveals/opens a leaf so tapping a pinned note on mobile doesn't pop the
|
||||
|
|
@ -760,6 +765,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
// has settled so folder discovery + the "already has my stub" check
|
||||
// are accurate (avoids creating a duplicate before the cache lists
|
||||
// the existing one). New folders are seeded at creation time instead.
|
||||
// 0.112.0: keep the fast encryption-state index honest — drop a pending blob
|
||||
// once the vault indexes it (then getFiles() covers it), and reflect external
|
||||
// or synced deletes so a stale pending entry can't falsely report "locked".
|
||||
this.registerEvent(this.app.vault.on("create", (f) => { if (f.path.endsWith(".stashenc")) this.pendingEncBlobs.delete(f.path); }));
|
||||
this.registerEvent(this.app.vault.on("delete", (f) => { if (f.path.endsWith(".stashenc")) this.pendingEncBlobs.delete(f.path); }));
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
// Vault is fully indexed now — safe to reconcile locked placeholders
|
||||
// (drop entries whose blob is truly gone, add cross-device blobs).
|
||||
|
|
@ -3130,6 +3140,60 @@ export default class StashpadPlugin extends Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
/** 0.112.0: FAST encryption-state check — replaces a full recursive adapter
|
||||
* walk that made "Remove encryption" take minutes on big vaults. Reads
|
||||
* Obsidian's in-memory file index (instant) plus this session's
|
||||
* `pendingEncBlobs` (adapter writes not yet indexed). Splits LIVE locked
|
||||
* content (notes/folders — irrecoverable if the key is erased) from TRASH
|
||||
* (`_deleted/` — recoverable until the key goes). Short-circuits as soon as it
|
||||
* knows both.
|
||||
*
|
||||
* 0.112.2: empirically verified (Claude Dev Vault, pendingEncBlobs empty)
|
||||
* that `vault.getFiles()` DOES return `_deleted/*.stashenc` — it's a normal,
|
||||
* fully-indexed folder — so trash detection here is reliable across sessions,
|
||||
* NOT dependent on `pendingEncBlobs`. The only location `getFiles()` misses is
|
||||
* the `.trash/` DOT-folder; for any IRREVERSIBLE decision use
|
||||
* `encryptionStateStrict()`, which adapter-confirms `_deleted/` + `.trash/`. */
|
||||
encryptionState(): { live: boolean; trash: boolean } {
|
||||
const isTrash = (p: string): boolean => {
|
||||
const dir = p.replace(/\/[^/]*$/, "");
|
||||
return dir === "_deleted" || dir.startsWith("_deleted/");
|
||||
};
|
||||
let live = false, trash = false;
|
||||
for (const f of this.app.vault.getFiles()) {
|
||||
if (f.extension !== "stashenc") continue;
|
||||
if (isTrash(f.path)) trash = true; else live = true;
|
||||
if (live && trash) return { live, trash };
|
||||
}
|
||||
for (const p of this.pendingEncBlobs) {
|
||||
if (isTrash(p)) trash = true; else live = true;
|
||||
if (live && trash) break;
|
||||
}
|
||||
return { live, trash };
|
||||
}
|
||||
|
||||
/** Authoritative-but-cheap variant for the IRREVERSIBLE "Remove encryption"
|
||||
* moment. Starts from the fast in-memory check, then confirms TRASH via two
|
||||
* adapter folder listings: `_deleted/` (covers an index lag) and Obsidian's
|
||||
* vault-local trash `.trash/` — a DOT-folder that `vault.getFiles()` never
|
||||
* returns, so a `.stashenc` the user trashed there would otherwise be erased
|
||||
* silently. Two listings, not a full vault walk. Live blobs only ever live in
|
||||
* normal (indexed) Stashpad folders, so live detection stays on the fast path. */
|
||||
async encryptionStateStrict(): Promise<{ live: boolean; trash: boolean }> {
|
||||
const s = this.encryptionState();
|
||||
let trash = s.trash;
|
||||
if (!trash) {
|
||||
try { trash = (await listDeletedBlobs(this.app)).length > 0; } catch { /* ignore */ }
|
||||
}
|
||||
if (!trash) {
|
||||
try {
|
||||
const t = await this.app.vault.adapter.list(OBSIDIAN_TRASH_DIR);
|
||||
trash = t.files.some((f) => f.endsWith(".stashenc"));
|
||||
} catch { /* no .trash dir — fine */ }
|
||||
}
|
||||
return { live: s.live, trash };
|
||||
}
|
||||
|
||||
async lockNoteSubtree(folder: string, rootId: StashpadId, prevSibling: StashpadId | null = null, opts: { silent?: boolean; blobFolder?: string } = {}): Promise<LockResult | null> {
|
||||
if (!(await this.ensureEncryptionUnlocked())) return null;
|
||||
const dek = this.encryption.getSessionKey();
|
||||
|
|
@ -3137,6 +3201,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
try {
|
||||
const hideTitle = this.settings.hideLockedTitles ?? false;
|
||||
const r = await lockSubtree(this.app, folder, rootId, dek, prevSibling, hideTitle, opts.blobFolder);
|
||||
this.pendingEncBlobs.add(r.blobPath); // fast-state index: cover the pre-vault-index window
|
||||
// Record a placeholder registry entry so the list shows a 🔒 stub where
|
||||
// the note was. The blob may live in a different folder than the note's
|
||||
// source (archive) — register it under the blob's actual folder.
|
||||
|
|
@ -3188,6 +3253,9 @@ export default class StashpadPlugin extends Plugin {
|
|||
const folder = (opts.destFolder ?? blobPath.replace(/\/[^/]*$/, "")).replace(/\/+$/, "");
|
||||
try {
|
||||
const r = await this.unlockBundleCore(blobPath, dek, opts.destFolder);
|
||||
// Blob is removed via raw adapter (no vault 'delete' event) — prune the
|
||||
// fast-index pending entry directly so it can't falsely report "locked".
|
||||
this.pendingEncBlobs.delete(blobPath);
|
||||
this.settings.lockedSubtrees = (this.settings.lockedSubtrees ?? []).filter((e) => e.blob !== blobPath);
|
||||
await this.saveSettings();
|
||||
if (!opts.silent) this.notifications.show({ message: `Unlocked ${r.notesWritten} note${r.notesWritten === 1 ? "" : "s"}.`, kind: "success", category: "system", folder });
|
||||
|
|
@ -3317,6 +3385,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
// the general hide-locked-titles setting is off.
|
||||
const hideTitle = (this.settings.hideLockedTitles ?? false) || (this.settings.encryptTrashFilenames ?? false);
|
||||
const r = await deleteEncryptSubtree(this.app, folder, rootId, dek, deletedAt, hideTitle);
|
||||
this.pendingEncBlobs.add(r.blobPath); // fast-state index
|
||||
if (r.unpurged.length > 0) {
|
||||
new Notice(`⚠️ Sent to encrypted trash, but ${r.unpurged.length} file${r.unpurged.length === 1 ? " is" : "s are"} still in plaintext (couldn't be removed or changed during the delete):\n${r.unpurged.join("\n")}`, 0);
|
||||
}
|
||||
|
|
@ -3339,6 +3408,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
if (meta?.kind === "rawtrash") {
|
||||
try {
|
||||
const r = await restoreRawTrash(this.app, blobPath, dek);
|
||||
this.pendingEncBlobs.delete(blobPath);
|
||||
if (!opts.silent) this.notifications.show({ message: `Restored ${r.filesWritten} file${r.filesWritten === 1 ? "" : "s"} to Obsidian's trash (${OBSIDIAN_TRASH_DIR}/).`, kind: "success", category: "system", folder: "" });
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
@ -3364,6 +3434,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
} catch { /* unreadable — skip */ }
|
||||
}
|
||||
const r = await restoreDeleted(this.app, blobPath, dek, existing);
|
||||
this.pendingEncBlobs.delete(blobPath);
|
||||
if (!opts.silent) this.notifications.show({ message: `Restored ${r.notesWritten} note${r.notesWritten === 1 ? "" : "s"} to “${r.restoredTo.split("/").pop()}”.`, kind: "success", category: "system", folder: r.restoredTo });
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
@ -3373,6 +3444,39 @@ export default class StashpadPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/** Permanently delete one encrypted-trash item (blob + sidecar). Irreversible
|
||||
* — no decrypt. The caller MUST confirm first. Returns true on success. */
|
||||
async purgeDeletedAt(blobPath: string): Promise<boolean> {
|
||||
try {
|
||||
await purgeDeletedBlob(this.app, blobPath);
|
||||
this.pendingEncBlobs.delete(blobPath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[Stashpad] purge-from-trash failed", blobPath, e);
|
||||
new Notice(`Couldn't delete: ${(e as Error).message}`, 0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Permanently delete EVERY live locked `.stashenc` bundle (+ sidecar) WITHOUT
|
||||
* decrypting — the notes inside are gone for good. Backs "Remove encryption →
|
||||
* Delete locked content" for when the password is lost. Excludes `_deleted/`
|
||||
* trash (that's handled separately). Caller MUST confirm. Returns the count. */
|
||||
async purgeAllLockedContent(): Promise<number> {
|
||||
const isTrash = (p: string): boolean => { const d = p.replace(/\/[^/]*$/, ""); return d === "_deleted" || d.startsWith("_deleted/"); };
|
||||
const blobs = new Set<string>();
|
||||
for (const f of this.app.vault.getFiles()) if (f.extension === "stashenc" && !isTrash(f.path)) blobs.add(f.path);
|
||||
for (const p of this.pendingEncBlobs) if (!isTrash(p)) blobs.add(p);
|
||||
let n = 0;
|
||||
for (const b of blobs) {
|
||||
try { await purgeDeletedBlob(this.app, b); this.pendingEncBlobs.delete(b); n++; }
|
||||
catch (e) { console.warn("[Stashpad] purge locked content failed", b, e); }
|
||||
}
|
||||
this.settings.lockedSubtrees = (this.settings.lockedSubtrees ?? []).filter((e) => !blobs.has(e.blob));
|
||||
await this.saveSettings();
|
||||
return n;
|
||||
}
|
||||
|
||||
/** List the encrypted-trash contents (blob path + sidecar metadata). */
|
||||
async listDeletedTrash(): Promise<Array<{ blob: string; meta: DeletedMeta | null }>> {
|
||||
const blobs = await listDeletedBlobs(this.app);
|
||||
|
|
|
|||
|
|
@ -1409,6 +1409,7 @@ export class ConfirmModal extends Modal {
|
|||
private message: string,
|
||||
private confirmText: string,
|
||||
private onChoose: (confirmed: boolean) => void,
|
||||
private cancelText: string = "Cancel",
|
||||
) { super(app); }
|
||||
onOpen(): void {
|
||||
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
|
||||
|
|
@ -1423,7 +1424,7 @@ export class ConfirmModal extends Modal {
|
|||
block.createDiv({ cls: "stashpad-confirm-line", text: line });
|
||||
}
|
||||
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
|
||||
const cancel = row.createEl("button", { text: "Cancel" });
|
||||
const cancel = row.createEl("button", { text: this.cancelText });
|
||||
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); };
|
||||
|
|
|
|||
170
src/settings.ts
170
src/settings.ts
|
|
@ -9,14 +9,13 @@ import { FolderSuggest } from "./folder-suggest";
|
|||
import type StashpadPlugin from "./main";
|
||||
import { RESERVED_FRONTMATTER, type ViewMode } from "./types";
|
||||
import { type SplitMode } from "./view-helpers";
|
||||
import { LogModal, ColorPickerModal, NotificationHistoryModal, EncryptionPasswordModal, TypeToConfirmModal } from "./modals";
|
||||
import { LogModal, ColorPickerModal, NotificationHistoryModal, EncryptionPasswordModal, TypeToConfirmModal, ConfirmModal } from "./modals";
|
||||
import { CATEGORY_LABELS, type NotificationCategory } from "./notifications";
|
||||
import { startHotkeyRecording, prettifyChord } from "./hotkey-recorder";
|
||||
import { DEFAULT_STOPWORDS } from "./slug-service";
|
||||
import { newId } from "./id-service";
|
||||
import { formatDateTime } from "./format";
|
||||
import { type EncryptionConfig, defaultEncryptionConfig } from "./encryption-service";
|
||||
import { anyStashencOnDisk } from "./encryption-ops";
|
||||
import { getActiveView } from "./active-view";
|
||||
|
||||
export interface ShortcutMap {
|
||||
|
|
@ -597,7 +596,7 @@ export type SettingsTabId =
|
|||
| "movingNotes" | "deleting" | "composerCopy" | "windowsTabs" | "notifications"
|
||||
| "encryption" | "authorship" | "templates" | "jdindex" | "okf"
|
||||
| "maintenance" | "diagnostics" | "misc" | "hotkeys";
|
||||
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
|
||||
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = ([
|
||||
{ id: "foldersStorage", label: "Folders & storage" },
|
||||
{ id: "importExport", label: "Import & export" },
|
||||
{ id: "noteTitles", label: "Note titles" },
|
||||
|
|
@ -617,7 +616,9 @@ export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = [
|
|||
{ id: "diagnostics", label: "Diagnostics" },
|
||||
{ id: "misc", label: "Misc" },
|
||||
{ id: "hotkeys", label: "Hotkeys" },
|
||||
];
|
||||
// 0.112.9: sections shown alphabetically by label. Display order only — the
|
||||
// `id`-keyed itemsForTab dispatch is unaffected, and new tabs auto-sort in.
|
||||
] as Array<{ id: SettingsTabId; label: string }>).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
/** 0.94.0: a declarative sub-page that renders one of Stashpad's settings tabs
|
||||
* via the existing imperative `renderTabContent`. Used by
|
||||
|
|
@ -670,8 +671,19 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
* (and super.display() is a no-op), so render the SAME settings imperatively
|
||||
* (one section per tab) — no native search there, which is fine. Without this
|
||||
* the Stashpad settings tab renders BLANK on older Obsidian. */
|
||||
/** 0.112.11: IMPERATIVE fallback ONLY. On 1.13+ Obsidian ignores display()
|
||||
* whenever getSettingDefinitions() returns items — it renders declaratively
|
||||
* via the base `update()` (a navigable page list + native search). So this
|
||||
* runs only on pre-1.13 (no declarative API). It must NOT call super.display()
|
||||
* — on 1.13 that renders nothing (the source of the earlier blank tab) and on
|
||||
* pre-1.13 it's a no-op. Just render every tab's items inline.
|
||||
*
|
||||
* CRITICAL: do NOT override `update()`. The base SettingTab.update() IS the
|
||||
* 1.13 declarative renderer + search indexer; overriding it (as 0.112.8 did,
|
||||
* based on stale-Obsidian testing where update() didn't exist) blanks the tab
|
||||
* and forces this imperative fallback. The `this.update?.()` refresh calls
|
||||
* resolve to the base update() on 1.13 (re-render) and no-op on pre-1.13. */
|
||||
display(): void {
|
||||
if (SettingPage) { super.display(); return; }
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
for (const t of SETTINGS_TABS) {
|
||||
|
|
@ -861,7 +873,13 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
build: (s: Setting) => void,
|
||||
aliases?: string[],
|
||||
): SettingDefinitionItem {
|
||||
return { name, desc, aliases, render: (s: Setting) => { s.setName(name).setDesc(desc); build(s); } };
|
||||
// 0.112.5: try/catch so a single throwing control (e.g. a missing Obsidian
|
||||
// API on an older build) degrades to a labelled row instead of aborting the
|
||||
// ENTIRE declarative settings render and blanking every later section.
|
||||
return { name, desc, aliases, render: (s: Setting) => {
|
||||
s.setName(name).setDesc(desc);
|
||||
try { build(s); } catch (e) { console.error(`[Stashpad] settings control "${name}" failed to render:`, e); }
|
||||
} };
|
||||
}
|
||||
|
||||
/** 0.94.3: a declarative item whose render builds a whole MULTI-element
|
||||
|
|
@ -881,7 +899,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
host.empty();
|
||||
host.removeClass("setting-item");
|
||||
host.addClass("stashpad-settings-section");
|
||||
render(host);
|
||||
try { render(host); } catch (e) { console.error(`[Stashpad] settings section "${name}" failed to render:`, e); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1279,37 +1297,113 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
if (enc.amIMember()) {
|
||||
new Setting(host).setName("Remove encryption").setDesc("Erases the key from this vault. Refused while any locked notes exist — decrypt everything first (locked notes have NO plaintext copy; losing the key loses them forever).").addButton((b) => {
|
||||
b.setButtonText("Remove…").onClick(async () => {
|
||||
// 0.98.23: HARD GUARD — locked blobs are the ONLY copy of their notes
|
||||
// (lock permanently deletes the plaintext). Erasing the key with blobs
|
||||
// still on disk = permanent data loss. Refuse until everything is
|
||||
// decrypted; offer the vault-wide unlock as the way out.
|
||||
// 0.98.42: scan the ADAPTER, not vault.getFiles — blobs are written
|
||||
// via the adapter and can be invisible to the vault index for a
|
||||
// while (and `_deleted/` / `.trash` blobs aren't indexed at all).
|
||||
if (await anyStashencOnDisk(this.app)) {
|
||||
new Notice(`Can't remove encryption: locked/encrypted-deleted notes still exist and would be lost forever. Run "Decrypt (unlock) ALL locked notes in the vault" and empty the encrypted trash first.`, 10000);
|
||||
return;
|
||||
}
|
||||
new TypeToConfirmModal(this.app, {
|
||||
title: "Remove encryption?",
|
||||
body: "This erases the encryption key for this vault. Nothing is currently encrypted (locked notes are checked), so no content is lost — but you'll need to set a new password to encrypt later, and anything you had exported encrypted with this key stays locked to its passphrase.",
|
||||
phrase: "REMOVE ENCRYPTION",
|
||||
confirmText: "Remove encryption",
|
||||
requirePassword: (pw) => enc.verifyPassword(pw),
|
||||
onConfirm: async () => {
|
||||
// Re-check at confirm time — a lock could have happened while the
|
||||
// modal sat open (another device syncing, another window).
|
||||
if (await anyStashencOnDisk(this.app)) {
|
||||
new Notice("Locked notes appeared while this dialog was open — removal cancelled. Decrypt everything first.", 10000);
|
||||
new Setting(host).setName("Remove encryption").setDesc("Erases the key for this vault so you can start fresh. If anything is still encrypted, you'll be offered to decrypt it (needs your password) or — if you've lost the password — permanently delete it. Then just type the confirmation phrase; no password needed.").addButton((b) => {
|
||||
const runRemoval = async (): Promise<void> => {
|
||||
// 0.112.0: authoritative-but-cheap state check (in-memory index + two
|
||||
// folder listings of `_deleted/` and `.trash/`) instead of the old full
|
||||
// recursive adapter walk that took minutes.
|
||||
const state = await this.plugin.encryptionStateStrict();
|
||||
if (state.live) {
|
||||
// 0.112.7: live locked content has no plaintext copy. Offer to either
|
||||
// DECRYPT everything (needs the password) or DELETE it forever (for a
|
||||
// lost password), then re-enter the removal flow. Mirrors the trash.
|
||||
const deleteAllLocked = (): void => {
|
||||
new ConfirmModal(this.app, "Delete all locked content forever?",
|
||||
"This PERMANENTLY destroys every locked note and folder — there is NO decrypted copy and NO recovery. Only do this if you've lost the password and want to start fresh.",
|
||||
"Delete locked content forever",
|
||||
async (del) => {
|
||||
if (!del) return;
|
||||
const n = await this.plugin.purgeAllLockedContent();
|
||||
new Notice(`Deleted ${n} locked item${n === 1 ? "" : "s"}.`);
|
||||
void runRemoval();
|
||||
}).open();
|
||||
};
|
||||
const decryptAll = async (): Promise<void> => {
|
||||
await this.plugin.unlockAllInVault(); // prompts for the password if locked
|
||||
if ((await this.plugin.encryptionStateStrict()).live) {
|
||||
new Notice("Some notes are still locked (decryption was cancelled or failed) — removal cancelled.", 10000);
|
||||
return;
|
||||
}
|
||||
await enc.clear(); new Notice("Encryption removed."); this.update?.();
|
||||
},
|
||||
}).open();
|
||||
});
|
||||
b.setDestructive();
|
||||
void runRemoval();
|
||||
};
|
||||
new ConfirmModal(this.app, "Encrypted notes are still locked",
|
||||
"Some notes or folders are still encrypted (locked) and have no plaintext copy. Before removing encryption:\n\n• Decrypt everything — unlock them back to normal notes (needs your password).\n• Delete locked content — permanently destroy them (only if you've lost the password).",
|
||||
"Decrypt everything",
|
||||
(decrypt) => { if (decrypt) void decryptAll(); else deleteAllLocked(); },
|
||||
"Delete locked content").open();
|
||||
return;
|
||||
}
|
||||
// No password required to remove encryption. By the time we get here
|
||||
// there is nothing recoverable to protect: live locked content already
|
||||
// HARD-REFUSES removal (you must decrypt it first), and the trash has
|
||||
// been explicitly decrypted or discarded. So the "REMOVE ENCRYPTION"
|
||||
// phrase is the only gate — critically, this lets a user who has LOST
|
||||
// the password (keychain empty/zeroed) still start fresh.
|
||||
// trashConsented: the user already chose to decrypt or discard the
|
||||
// encrypted trash; the confirm-time re-check uses it to abort only if
|
||||
// NEW encrypted content appears mid-dialog.
|
||||
const proceedToConfirm = (extraWarn: string, trashConsented: boolean): void => {
|
||||
new TypeToConfirmModal(this.app, {
|
||||
title: "Remove encryption?",
|
||||
body: `This erases the encryption key for this vault. No locked notes/folders exist, so your live content is safe.${extraWarn} You'll need to set a new password to encrypt again later, and anything previously exported with this key stays locked to its passphrase.`,
|
||||
phrase: "REMOVE ENCRYPTION",
|
||||
confirmText: "Remove encryption",
|
||||
onConfirm: async () => {
|
||||
// Re-check authoritatively at confirm time — a lock or encrypt-
|
||||
// delete could have synced in while the modal sat open.
|
||||
const now = await this.plugin.encryptionStateStrict();
|
||||
if (now.live) {
|
||||
new Notice("Locked notes appeared while this dialog was open — removal cancelled. Decrypt everything first.", 10000);
|
||||
return;
|
||||
}
|
||||
if (now.trash && !trashConsented) {
|
||||
new Notice("Encrypted trash appeared while this dialog was open — removal cancelled. Re-open Remove encryption to handle it.", 10000);
|
||||
return;
|
||||
}
|
||||
// Discard path: actually DELETE the encrypted-trash blobs now, so
|
||||
// removal doesn't leave orphaned, forever-unreadable files behind.
|
||||
// (Decrypt path already emptied `_deleted/`, so this is a no-op there.)
|
||||
const leftover = await this.plugin.listDeletedTrash();
|
||||
for (const it of leftover) await this.plugin.purgeDeletedAt(it.blob);
|
||||
await enc.clear(); new Notice("Encryption removed."); this.update?.();
|
||||
},
|
||||
}).open();
|
||||
};
|
||||
|
||||
if (state.trash) {
|
||||
// Encrypted trash exists but no live locked content. Removing the key
|
||||
// makes the trash permanently unreadable. Fork: Decrypt Trash (CTA,
|
||||
// recommended — restores them to their folders) vs Discard trash
|
||||
// (→ a second Cancel/Confirm step before anything is lost). Esc on
|
||||
// this modal routes to the Discard confirm, which is cancelable.
|
||||
const decryptThenRemove = async (): Promise<void> => {
|
||||
const n = await this.plugin.restoreAllTrash();
|
||||
// Authoritative re-check — restore removes blobs via raw adapter
|
||||
// (no vault event), so confirm `_deleted/` is actually empty.
|
||||
if ((await this.plugin.encryptionStateStrict()).trash) {
|
||||
new Notice("Some encrypted-trash items couldn't be decrypted — removal cancelled. Check the trash tab and try again.", 10000);
|
||||
return;
|
||||
}
|
||||
proceedToConfirm(n > 0 ? ` (${n} trash item${n === 1 ? "" : "s"} were decrypted back to their folders.)` : "", true);
|
||||
};
|
||||
const confirmDiscard = (): void => {
|
||||
new ConfirmModal(this.app, "Discard encrypted trash?",
|
||||
"The encrypted-trash items will be PERMANENTLY lost the moment the key is erased — there is no recovery. Continue?",
|
||||
"Confirm",
|
||||
(discard) => { if (discard) proceedToConfirm(" ⚠️ The encrypted trash is being permanently discarded.", true); }).open();
|
||||
};
|
||||
new ConfirmModal(this.app, "Encrypted notes in the trash",
|
||||
"There are encrypted, deleted notes in the trash. Removing encryption makes them PERMANENTLY unreadable.\n\n• Decrypt Trash — restore them to their original folders first (recommended).\n• Discard trash — let them be permanently erased with the key.",
|
||||
"Decrypt Trash",
|
||||
(decrypt) => { if (decrypt) void decryptThenRemove(); else confirmDiscard(); },
|
||||
"Discard trash").open();
|
||||
return;
|
||||
}
|
||||
// Nothing encrypted at all → clean removal.
|
||||
proceedToConfirm("", false);
|
||||
};
|
||||
b.setButtonText("Remove…").onClick(() => void runRemoval());
|
||||
b.buttonEl.addClass("mod-warning");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1324,7 +1418,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: false, kdfProbe, title: "Change shared password", intro: "Everyone who unlocks with the shared password will need the new one. Re-share it securely after changing.",
|
||||
onSubmit: async ({ next }) => { if (!next) return "Enter a password."; try { await enc.setSharedPassword(next); } catch (e) { return (e as Error).message; } new Notice("Shared password updated."); this.update?.(); return null; } }).open();
|
||||
}))
|
||||
.addButton((b) => { b.setButtonText("Turn off").onClick(async () => { await enc.removeSharedPassword(); new Notice("Shared password turned off."); this.update?.(); }); b.setDestructive(); });
|
||||
.addButton((b) => { b.setButtonText("Turn off").onClick(async () => { await enc.removeSharedPassword(); new Notice("Shared password turned off."); this.update?.(); }); b.buttonEl.addClass("mod-warning"); });
|
||||
} else {
|
||||
new Setting(host).setName("Shared password").setDesc("OFF — set one passphrase that everyone types to unlock (the simplest way to share). Anyone who knows it can unlock; turning it off later doesn't claw back copies already synced elsewhere.")
|
||||
.addButton((b) => b.setButtonText("Set shared password…").onClick(() => {
|
||||
|
|
@ -1354,7 +1448,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
await enc.removeMember(m.id);
|
||||
new Notice(`Removed ${m.label}. (Not full revocation without rotating the key.)`);
|
||||
this.update?.();
|
||||
}); b.setDestructive(); });
|
||||
}); b.buttonEl.addClass("mod-warning"); });
|
||||
}
|
||||
}
|
||||
const reqs = enc.pendingJoinRequests();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ItemView, WorkspaceLeaf, moment, setIcon } from "obsidian";
|
||||
import type StashpadPlugin from "./main";
|
||||
import { STASHPAD_TRASH_VIEW_TYPE } from "./types";
|
||||
import { ConfirmModal } from "./modals";
|
||||
|
||||
// Obsidian types `moment` as the namespace (not callable); cast to a callable.
|
||||
const momentFn = moment as unknown as (...args: unknown[]) => { fromNow: () => string };
|
||||
|
|
@ -24,6 +25,18 @@ export class StashpadTrashView extends ItemView {
|
|||
// re-reads the store. Debounced via a microtask guard.
|
||||
this.registerEvent(this.app.vault.on("create", (f) => { if (f.path.startsWith("_deleted/")) this.scheduleRender(); }));
|
||||
this.registerEvent(this.app.vault.on("delete", (f) => { if (f.path.startsWith("_deleted/")) this.scheduleRender(); }));
|
||||
// 0.112.4: Mod+A selects every trash item — but only while THIS tab is the
|
||||
// active leaf, and not while typing in a field.
|
||||
this.registerDomEvent(document, "keydown", (e) => {
|
||||
if (this.app.workspace.activeLeaf !== this.leaf) return;
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt && (tgt.tagName === "INPUT" || tgt.tagName === "TEXTAREA" || tgt.isContentEditable)) return;
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "a" || e.key === "A")) {
|
||||
if (this.order.length === 0) return;
|
||||
e.preventDefault();
|
||||
this.selectAll();
|
||||
}
|
||||
});
|
||||
await this.render();
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +51,11 @@ export class StashpadTrashView extends ItemView {
|
|||
* each await (render is async + called from buttons/events — two in flight
|
||||
* would each empty-then-append, duplicating rows). */
|
||||
private renderGen = 0;
|
||||
/** 0.112.3: multi-select state. `selected` holds blob paths; `order` is the
|
||||
* 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;
|
||||
|
||||
async render(): Promise<void> {
|
||||
const gen = ++this.renderGen;
|
||||
|
|
@ -65,7 +83,27 @@ export class StashpadTrashView extends ItemView {
|
|||
|
||||
const restoreAll = header.createEl("button", { cls: "stashpad-trash-restore", text: "Restore all" });
|
||||
setIcon(restoreAll.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
|
||||
restoreAll.onclick = async () => { restoreAll.disabled = true; await this.plugin.restoreAllTrash(); await this.render(); };
|
||||
restoreAll.onclick = async () => { restoreAll.disabled = true; await this.plugin.restoreAllTrash(); this.clearSelection(); await this.render(); };
|
||||
|
||||
// Drop any selected paths that no longer exist (restored/purged elsewhere).
|
||||
const present = new Set(items.map((it) => it.blob));
|
||||
for (const b of [...this.selected]) if (!present.has(b)) this.selected.delete(b);
|
||||
|
||||
// Selection action bar (only when something is selected).
|
||||
if (this.selected.size > 0) {
|
||||
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();
|
||||
};
|
||||
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" });
|
||||
clear.onclick = () => { this.clearSelection(); void this.render(); };
|
||||
}
|
||||
|
||||
// Group by origin folder; obscured-title notes (no readable origin on disk)
|
||||
// go under "Hidden" so we don't reveal where they came from.
|
||||
|
|
@ -76,6 +114,9 @@ export class StashpadTrashView extends ItemView {
|
|||
}
|
||||
const keys = [...groups.keys()].sort((a, b) => (a === " hidden" ? 1 : b === " hidden" ? -1 : a.localeCompare(b)));
|
||||
|
||||
// Rebuild the flat render order for shift-range selection.
|
||||
this.order = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const hidden = key === " hidden";
|
||||
const group = root.createDiv({ cls: "stashpad-trash-group" });
|
||||
|
|
@ -85,7 +126,15 @@ export class StashpadTrashView extends ItemView {
|
|||
head.createSpan({ cls: "stashpad-trash-group-count", text: String(groups.get(key)!.length) });
|
||||
|
||||
for (const it of groups.get(key)!) {
|
||||
const idx = this.order.length;
|
||||
this.order.push(it.blob);
|
||||
const row = group.createDiv({ cls: "stashpad-trash-row" });
|
||||
if (this.selected.has(it.blob)) row.addClass("is-selected");
|
||||
// Click the row body to (multi-)select; cmd/ctrl toggles, shift ranges.
|
||||
row.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return; // let buttons act
|
||||
this.onRowClick(idx, it.blob, e);
|
||||
});
|
||||
const main = row.createDiv({ cls: "stashpad-trash-row-main" });
|
||||
main.createSpan({ cls: "stashpad-trash-title", text: hidden ? "Locked note" : (it.meta?.title || "Locked note") });
|
||||
const when = it.meta?.deletedAt ? `deleted ${momentFn(it.meta.deletedAt).fromNow()}` : "deleted";
|
||||
|
|
@ -94,10 +143,55 @@ export class StashpadTrashView extends ItemView {
|
|||
const btn = row.createEl("button", { cls: "stashpad-trash-restore", text: "Restore" });
|
||||
setIcon(btn.createSpan({ cls: "stashpad-btn-icon" }), "rotate-ccw");
|
||||
btn.onclick = async () => { btn.disabled = true; const ok = await this.plugin.restoreDeletedAt(it.blob); if (ok) await this.render(); else btn.disabled = false; };
|
||||
const del = row.createEl("button", { cls: "stashpad-trash-delete", attr: { "aria-label": "Delete permanently" } });
|
||||
setIcon(del, "trash-2");
|
||||
del.onclick = () => this.confirmPurge([it.blob]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearSelection(): void { this.selected.clear(); this.anchorIdx = 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;
|
||||
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];
|
||||
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;
|
||||
} else {
|
||||
if (this.selected.size === 1 && this.selected.has(blob)) this.selected.clear();
|
||||
else { this.selected.clear(); this.selected.add(blob); }
|
||||
this.anchorIdx = idx;
|
||||
}
|
||||
void this.render();
|
||||
}
|
||||
|
||||
/** Confirm + permanently delete trash blobs (irreversible — no decrypt). */
|
||||
private confirmPurge(blobs: string[]): void {
|
||||
if (blobs.length === 0) return;
|
||||
const n = blobs.length;
|
||||
new ConfirmModal(this.app, n === 1 ? "Delete permanently?" : `Delete ${n} items permanently?`,
|
||||
`${n === 1 ? "This encrypted item" : `These ${n} encrypted items`} will be PERMANENTLY deleted and CANNOT be restored — there is no decrypted copy. Continue?`,
|
||||
"Delete forever",
|
||||
async (ok) => {
|
||||
if (!ok) return;
|
||||
for (const b of blobs) await this.plugin.purgeDeletedAt(b);
|
||||
this.clearSelection();
|
||||
await this.render();
|
||||
}).open();
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> { this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
|
|
|
|||
17
styles.css
17
styles.css
|
|
@ -2435,6 +2435,23 @@
|
|||
.stashpad-trash-sub { font-size: var(--font-ui-smaller); color: var(--text-muted); }
|
||||
.stashpad-trash-restore { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; font-size: var(--font-ui-smaller); padding: 3px 10px; cursor: pointer; --icon-size: 13px; }
|
||||
.stashpad-trash-restore .svg-icon { width: var(--icon-size); height: var(--icon-size); }
|
||||
/* 0.112.3: multi-select + permanent-delete in the encrypted-trash tab. */
|
||||
.stashpad-trash-row { cursor: pointer; }
|
||||
.stashpad-trash-row.is-selected { background: var(--background-modifier-hover); box-shadow: inset 0 0 0 1px var(--interactive-accent); }
|
||||
.stashpad-trash-delete {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 3px 8px; cursor: pointer; --icon-size: 14px;
|
||||
background: transparent; border: none; box-shadow: none; color: var(--text-faint);
|
||||
}
|
||||
.stashpad-trash-delete:hover { color: var(--text-error); background: var(--background-modifier-error-hover); }
|
||||
.stashpad-trash-delete .svg-icon { width: var(--icon-size); height: var(--icon-size); }
|
||||
.stashpad-trash-selbar {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 6px 8px; margin-bottom: 8px; border-radius: 6px;
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
.stashpad-trash-selcount { flex: 1 1 auto; font-size: var(--font-ui-smaller); color: var(--text-muted); }
|
||||
.stashpad-trash-delete.mod-warning { color: var(--text-on-accent); background: var(--text-error); padding: 3px 10px; border-radius: 4px; font-size: var(--font-ui-smaller); }
|
||||
/* 0.98.24: mobile ⋮ menu button on locked stubs (desktop uses right-click). */
|
||||
.stashpad-locked-menu {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
||||
|
|
|
|||
Loading…
Reference in a new issue