0.115.6: per-folder encryption polish + subfolders, plaintext archives, encrypt-from-explorer

This commit is contained in:
Human 2026-06-20 19:53:15 -07:00
parent d24f4a0892
commit 60b7634519
9 changed files with 419 additions and 177 deletions

160
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.114.16",
"version": "0.115.6",
"minAppVersion": "1.13.0",
"description": "A chat-style, nested-notes workspace: rapid capture, outliner navigation, fast search, tasks, and per-folder templates, with one-click Open Knowledge Format (OKF) export for LLMs and agents.",
"author": "Human",

View file

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

36
release-notes/0.115.6.md Normal file
View file

@ -0,0 +1,36 @@
# 0.115.6 — Per-folder encryption polish + four follow-up features
Builds on the per-folder encryption release with subfolder support, plaintext
archives, an encrypt-from-file-explorer entry point, and settings polish.
## Important fix
- **Rotating a folder's key no longer leaves it temporarily "stuck."** A regression
in the previous release zeroed the folder's in-memory key right after a rotation, so
folder actions failed until you re-entered the password. Fixed — rotation keeps the
folder unlocked. (No data was ever lost; re-entering the password worked around it.)
## Per-folder encryption
- **Subfolders inherit the parent folder's password.** A note in a subfolder of an
encrypted folder uses that folder's key — set the password once, on the top folder.
You can't set a separate password on a subfolder that already inherits one.
- **Per-subfolder options.** Selecting a folder in the encryption settings now lists
its subfolders, each with its own "encrypt notes" + "hide filenames" toggles (they
still share the parent's password).
- **Per-folder trash keys.** Notes deleted from a folder with its own password are now
encrypted under *that* key (not the vault key), and rotating the folder's key
re-encrypts its trash too, so restore keeps working.
## Folders & import
- **Exclude subfolders by prefix.** A new setting (default `_`): any subfolder whose
name starts with a listed prefix is kept local — not surfaced as a Stashpad folder
or pulled into import.
- **🔒 Encrypt with Stashpad** — a new right-click action on folders in Obsidian's
file explorer: give a folder its own Stashpad password (and lock the notes in it)
without opening settings.
- **Archive command works with plaintext archives.** Archiving into a folder whose
"Encrypt archived notes" is off now moves the notes there (de-indexed, not
encrypted) instead of refusing.
## Settings
- Section names are now Title Case with an emoji per section (📁 Folders & Storage,
🔒 Encryption, 🗑️ Deleting, …) for faster scanning.

View file

@ -278,6 +278,11 @@ export interface DeletedMeta {
originalFolderEnc?: string;
parentId: string | null; title: string; count: number; created: string;
rootId: string; deletedAt: string;
/** Per-folder trash keys: the FOLDER key id this blob was encrypted under
* (plaintext just an identifier, safe to expose). Absent encrypted under the
* vault DEK (legacy / vault-keyed folders). Restore resolves the right key by this
* id; rotation re-encrypts the trash blobs whose keyId matches the rotated key. */
keyId?: string;
}
function bytesToB64(bytes: Uint8Array): string {
@ -326,7 +331,7 @@ export async function deletedRestoreDest(app: App, blobPath: string, meta: Delet
* trash store + the sidecar records the original folder so Restore can put it
* 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,
app: App, folder: string, rootId: StashpadId, dek: Uint8Array, deletedAt: string, hideTitle = false, keyId?: string,
): 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.");
@ -368,6 +373,7 @@ export async function deleteEncryptSubtree(
parentId,
title: hideTitle ? "" : titleFromFile(rootNote.file), count: all.length,
created: rootNote.created, rootId, deletedAt,
...(keyId ? { keyId } : {}),
};
try { await app.vault.adapter.write(sidecarPath(blobPath), JSON.stringify(meta)); }
catch (e) { console.warn("[Stashpad] couldn't write deleted sidecar", e); }
@ -546,6 +552,28 @@ export async function writeRotatedBlob(app: App, blobPath: string, oldDek: Uint8
} finally { plain.fill(0); }
}
/** During a folder-key rotation, re-wrap a deleted blob's sidecar field that is
* itself encrypted with the folder DEK (`originalFolderEnc`, hidden-titles deletes)
* old new. No-op if absent. Run AFTER the blob's `.rot` is committed. */
export async function rewrapDeletedSidecar(app: App, blobPath: string, oldDek: Uint8Array, newDek: Uint8Array): Promise<void> {
const meta = await readDeletedMeta(app, blobPath);
if (!meta?.originalFolderEnc) return;
const plain = await decryptWithKey(b64ToBytes(meta.originalFolderEnc), oldDek);
try { meta.originalFolderEnc = bytesToB64(await encryptWithKey(plain, newDek)); }
finally { plain.fill(0); }
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[]> {
const out: string[] = [];
for (const b of await listDeletedBlobs(app)) {
const m = await readDeletedMeta(app, b);
if (m?.keyId === keyId) out.push(b);
}
return out;
}
/** Commit a re-encrypted blob: replace the original with its verified `.rot`, then
* remove the temp. No-op if the `.rot` is missing (already committed). */
export async function commitRotatedBlob(app: App, blobPath: string): Promise<void> {
@ -556,25 +584,24 @@ export async function commitRotatedBlob(app: App, blobPath: string): Promise<voi
try { await app.vault.adapter.remove(rot); } catch { /* leave temp; original is good */ }
}
/** Remove any leftover `.rot` temp files in a folder (cleanup after an aborted
* rotation originals are intact, so the temps are just orphans). */
/** Remove any leftover `.rot` temp files under a folder SUBTREE (cleanup after an
* aborted rotation originals are intact, so the temps are just orphans).
* Recursive: subfolders inherit the parent key, so their blobs rotate too. */
export async function cleanupRotTemps(app: App, folder: string): Promise<void> {
const cleaned = folder.replace(/\/+$/, "");
try {
const listing = await app.vault.adapter.list(cleaned);
for (const f of listing.files) if (f.endsWith(`.${STASHENC_EXT}.rot`)) { try { await app.vault.adapter.remove(f); } catch { /* */ } }
} catch { /* no folder */ }
for (const f of await listFilesRecursive(app, cleaned)) {
if (f.endsWith(`.${STASHENC_EXT}.rot`)) { try { await app.vault.adapter.remove(f); } catch { /* */ } }
}
}
/** The original blob paths that have a pending `.rot` temp in a folder read from
* the ADAPTER (authoritative), not the in-memory file index, so rotation resume
* works during onload before the index is populated. */
/** The original blob paths that have a pending `.rot` temp anywhere under a folder
* SUBTREE read from the ADAPTER (authoritative), not the in-memory file index, so
* rotation resume works during onload before the index is populated. */
export async function listRotTemps(app: App, folder: string): Promise<string[]> {
const cleaned = folder.replace(/\/+$/, "");
try {
const listing = await app.vault.adapter.list(cleaned);
return listing.files.filter((f) => f.endsWith(`.${STASHENC_EXT}.rot`)).map((f) => f.replace(/\.rot$/, ""));
} catch { return []; }
return (await listFilesRecursive(app, cleaned))
.filter((f) => f.endsWith(`.${STASHENC_EXT}.rot`))
.map((f) => f.replace(/\.rot$/, ""));
}
/** All encrypted-deleted blob paths in `_deleted/`. */

View file

@ -325,11 +325,37 @@ export class EncryptionService {
// unaffected. UNVERIFIED: no live crypto test yet; wiring into the lock/unlock
// ops is intentionally deferred to an attended pass.
/** The keyfile entry for a folder, or null (folder uses the vault DEK). */
folderKeyEntry(folder: string): FolderKeyEntry | null {
return this.kf?.folderKeys?.[this.cleanFolder(folder)] ?? null;
/** The nearest ancestor folder (including the folder itself) that has its OWN
* key, or null. Implements the "subfolders inherit the parent's key" decision:
* a note in `Projects/Sub` is owned by `Projects` if only `Projects` has a key. */
private owningFolder(folder: string): string | null {
const fks = this.kf?.folderKeys;
if (!fks) return null;
let p = this.cleanFolder(folder);
while (p) {
if (fks[p]) return p;
const i = p.lastIndexOf("/");
if (i < 0) break;
p = p.slice(0, i);
}
return null;
}
/** True only if THIS exact folder has its own key (not inherited) for settings
* UI decisions ("Set folder password" vs "Unlock/Change"). */
hasOwnFolderKey(folder: string): boolean { return !!this.kf?.folderKeys?.[this.cleanFolder(folder)]; }
/** The keyfile entry governing a folder its own, or the nearest ancestor's
* (inheritance). Null the folder uses the vault DEK. */
folderKeyEntry(folder: string): FolderKeyEntry | null {
const owner = this.owningFolder(folder);
return owner ? (this.kf!.folderKeys![owner] ?? null) : null;
}
hasFolderKey(folder: string): boolean { return this.owningFolder(folder) !== null; }
/** The folder path whose key has this keyId, or null for resolving which key a
* trash blob was encrypted under (its sidecar stores the keyId). */
folderPathByKeyId(keyId: string): string | null {
for (const [path, e] of Object.entries(this.kf?.folderKeys ?? {})) if (e.keyId === keyId) return path;
return null;
}
hasFolderKey(folder: string): boolean { return !!this.folderKeyEntry(folder); }
/** Active (non-deprecated) password slots for a folder, newest first. Deprecated
* slots are NEVER returned an old/retired password must not unlock current
* content (matches the vault shared-password path). If there are somehow zero
@ -337,18 +363,18 @@ export class EncryptionService {
private folderActiveSlots(entry: FolderKeyEntry): KeyfilePasswordSlot[] {
return entry.passwordSlots.filter((s) => !s.label.startsWith("[deprecated]"));
}
/** Is the effective key for this folder available right now? */
/** Is the effective key for this folder available right now? (Session is keyed by
* the OWNING folder, so an inheriting subfolder reports the owner's state.) */
isFolderUnlocked(folder: string): boolean {
const entry = this.folderKeyEntry(folder);
return entry ? this.folderSessionKeys.has(this.cleanFolder(folder)) : this.isUnlocked();
const owner = this.owningFolder(folder);
return owner ? this.folderSessionKeys.has(owner) : this.isUnlocked();
}
/** The DEK to use for `folder`: the unlocked folder key if it has its own,
* otherwise the vault DEK. Returns a COPY (caller may zero), or null if the
* needed key isn't unlocked. Arms the idle timer like getSessionKey. */
/** The DEK to use for `folder`: the unlocked owning-folder key, else the vault DEK.
* Returns a COPY (caller may zero), or null if the needed key isn't unlocked. */
getFolderKey(folder: string): Uint8Array | null {
const entry = this.folderKeyEntry(folder);
if (!entry) return this.getSessionKey();
const k = this.folderSessionKeys.get(this.cleanFolder(folder));
const owner = this.owningFolder(folder);
if (!owner) return this.getSessionKey();
const k = this.folderSessionKeys.get(owner);
if (!k) return null;
this.armIdle();
return k.slice();
@ -363,6 +389,10 @@ export class EncryptionService {
if (!this.kf) throw new Error("Set up vault encryption first.");
const f = this.cleanFolder(folder);
if (this.kf.folderKeys?.[f]) throw new Error("This folder already has its own key.");
// Inheritance: a subfolder of an already-keyed folder uses the ancestor's key —
// don't let it mint a separate (nested) key.
const ancestor = this.owningFolder(f);
if (ancestor && ancestor !== f) throw new Error(`A parent folder (“${ancestor.split("/").pop()}”) already has its own password; this folder inherits it.`);
const dek = crypto.getRandomValues(new Uint8Array(DEK_LEN));
const wrapped = await encryptStash(dek, password);
const keyId = newId(8);
@ -387,7 +417,7 @@ export class EncryptionService {
for (const slot of this.folderActiveSlots(entry)) {
try {
const dek = await decryptStash(fromB64(slot.wrapped), password);
this.folderSessionKeys.set(f, dek);
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();
@ -417,11 +447,12 @@ export class EncryptionService {
async changeFolderPassword(folder: string, newPassword: string, remember = false): Promise<void> {
if (!newPassword) throw new Error("Password required.");
const f = this.cleanFolder(folder);
const dek = this.folderSessionKeys.get(f);
if (!dek) throw new Error("Unlock this folder first.");
await this.refresh();
const entry = this.folderKeyEntry(f);
if (!entry || !this.kf) throw new Error("This folder has no key.");
const owner = entry.folderPath; // inheritance: operate on the key-owning folder
const dek = this.folderSessionKeys.get(owner);
if (!dek) throw new Error("Unlock this folder first.");
// Retain the OLD keychain password under a deprecated id (never delete) so an
// old export emailed months ago can still be looked up / decrypted.
const prevActive = entry.passwordSlots.filter((s) => !s.label.startsWith("[deprecated]"));
@ -441,7 +472,7 @@ export class EncryptionService {
...retained,
],
};
this.kf.folderKeys = { ...(this.kf.folderKeys ?? {}), [f]: next };
this.kf.folderKeys = { ...(this.kf.folderKeys ?? {}), [owner]: next };
await this.keyfiles.save(this.kf);
if (remember || this.isFolderRemembered(kcId)) await this.rememberFolder(this.folderKcIdFor(next), newPassword);
}
@ -458,6 +489,7 @@ export class EncryptionService {
if (!this.kf) throw new Error("Encryption is not set up.");
const f = this.cleanFolder(folder);
const entry = this.folderKeyEntry(f);
const owner = entry?.folderPath ?? f; // inheritance: rotate the key-owning folder
const keyId = entry?.keyId ?? newId(8);
const oldKcId = entry ? this.folderKcIdFor(entry) : null;
// Park the old active keychain password (retain, never delete).
@ -470,17 +502,19 @@ export class EncryptionService {
const retained = (entry?.passwordSlots ?? []).map((s) => s.label.startsWith("[deprecated]") ? s : { ...s, label: `[deprecated] ${s.label}` });
const newLabel = label ?? entry?.label ?? `rotated - ${f.split("/").pop() || f}`;
const next: FolderKeyEntry = {
keyId, folderPath: f, label: newLabel, kcId: this.folderKcId(newLabel, keyId), rotId,
keyId, folderPath: owner, label: newLabel, kcId: this.folderKcId(newLabel, keyId), rotId,
passwordSlots: [{ id: newId(8), label: "Folder password", wrapped: toB64(wrapped.data), kdf: wrapped.kdf, createdAt: new Date().toISOString() }, ...retained],
createdAt: entry?.createdAt ?? new Date().toISOString(),
};
this.kf.folderKeys = { ...(this.kf.folderKeys ?? {}), [f]: next };
this.kf.folderKeys = { ...(this.kf.folderKeys ?? {}), [owner]: next };
await this.keyfiles.save(this.kf);
// Zero the OLD folder DEK before replacing it — after a true-invalidation rotation
// the prior key shouldn't linger in memory.
const prevSess = this.folderSessionKeys.get(f);
const prevSess = this.folderSessionKeys.get(owner);
if (prevSess && prevSess !== newDek) prevSess.fill(0);
this.folderSessionKeys.set(f, newDek);
// Store a COPY — the caller (rotateFolderKey) zeros its own newDek buffer in a
// finally, which would otherwise zero the live session key we just set here.
this.folderSessionKeys.set(owner, newDek.slice());
if (remember || (oldKcId && this.isFolderRemembered(oldKcId))) await this.rememberFolder(this.folderKcIdFor(next), newPassword);
}
@ -620,11 +654,13 @@ export class EncryptionService {
this.clearIdle();
}
/** Lock a SINGLE folder's key (leave the vault key + other folders unlocked). */
/** Lock a SINGLE folder's key (leave the vault key + other folders unlocked).
* Resolves to the OWNING folder so locking an inheriting subfolder drops the
* shared key. */
lockFolder(folder: string): void {
const f = this.cleanFolder(folder);
const k = this.folderSessionKeys.get(f);
if (k) { k.fill(0); this.folderSessionKeys.delete(f); }
const owner = this.owningFolder(folder) ?? this.cleanFolder(folder);
const k = this.folderSessionKeys.get(owner);
if (k) { k.fill(0); this.folderSessionKeys.delete(owner); }
}
private cleanFolder(p: string): string { return (p || "").replace(/\/+$/, ""); }

View file

@ -10,7 +10,7 @@ import { STASHPAD_TRASH_VIEW_TYPE, STASHPAD_AGGREGATE_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, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree, writeRotatedBlob, commitRotatedBlob, cleanupRotTemps, listRotTemps } from "./encryption-ops";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, backfillTrashEncrypt, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree, writeRotatedBlob, commitRotatedBlob, cleanupRotTemps, listRotTemps, deletedBlobsForKeyId, rewrapDeletedSidecar } from "./encryption-ops";
import { EncryptionPasswordModal } from "./modals";
import {
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
@ -433,6 +433,18 @@ export default class StashpadPlugin extends Plugin {
* still tell whether the deleted folder was a Stashpad. */
knownStashpadFolders = new Set<string>();
/** Subfolder name prefixes (user-configurable, comma-separated; default "_") that
* EXCLUDE a folder from Stashpad discovery + import it stays local, not pulled
* in. A path is excluded if ANY of its segments starts with a listed prefix. */
importExcludePrefixList(): string[] {
return (this.settings.importExcludePrefixes ?? "_").split(",").map((s) => s.trim()).filter(Boolean);
}
pathHasExcludedSegment(path: string): boolean {
const prefixes = this.importExcludePrefixList();
if (!prefixes.length) return false;
return path.replace(/\/+$/, "").split("/").some((seg) => prefixes.some((p) => seg.startsWith(p)));
}
discoverStashpadFolders(): string[] {
const folders = new Set<string>();
for (const f of this.app.vault.getMarkdownFiles()) {
@ -444,7 +456,7 @@ export default class StashpadPlugin extends Plugin {
// field isn't a Stashpad note.
if (!fm || !("parent" in fm)) continue;
const dir = f.parent?.path?.replace(/\/+$/, "") ?? "";
if (dir) folders.add(dir);
if (dir && !this.pathHasExcludedSegment(dir)) folders.add(dir);
}
const sorted = [...folders].sort();
this.knownStashpadFolders = new Set(sorted);
@ -920,6 +932,10 @@ export default class StashpadPlugin extends Plugin {
this.registerEvent(this.app.workspace.on("file-menu", (menu, file) => {
if (!(file instanceof TFolder)) return;
const path = file.path.replace(/\/+$/, "");
// "Encrypt with Stashpad" on ANY folder (incl. import-excluded subfolders that
// aren't surfaced as Stashpad folders) — the entry point for encrypting a
// folder straight from Obsidian's file explorer.
menu.addItem((item) => item.setTitle("🔒 Encrypt with Stashpad").setIcon("lock").onClick(() => void this.encryptFolderFromExplorer(path)));
if (!this.discoverStashpadFolders().includes(path)) return;
menu.addItem((item) => {
item
@ -3464,6 +3480,32 @@ export default class StashpadPlugin extends Plugin {
* protected `.stash` original blob untouched (feedback #4 / Option B). */
exportLockedSubtree(blobPath: string): Promise<void> { return cmdExportLockedBlob(this, blobPath); }
/** "Encrypt with Stashpad" from the file-explorer folder menu. Gives the folder its
* own Stashpad password (works for any folder path, incl. import-excluded
* subfolders) and locks any Stashpad notes inside it. */
async encryptFolderFromExplorer(folder: string): Promise<void> {
if (this.encryption.hasOwnFolderKey(folder)) { if (await this.ensureFolderUnlocked(folder)) await this.lockFolder(folder); return; }
if (!this.encryption.isConfigured()) { new Notice("Set up Stashpad vault encryption first (Settings → Stashpad → Encryption), then try again."); return; }
const name = folder.split("/").pop() || folder;
const d = new Date(); const p = (n: number) => String(n).padStart(2, "0");
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
const who = (this.settings.authorName || "").trim();
const initials = who ? who.split(/\s+/).map((w) => w[0]).join("") : (this.settings.authorId || "anon").slice(0, 4);
const label = `${stamp} - ${name} - ${initials}`;
new EncryptionPasswordModal(this.app, {
mode: "setup", offerKeychain: true, title: `Encrypt “${name}” with Stashpad`,
intro: "Give this folder its own password. Any Stashpad notes inside it get encrypted under it; you'll re-enter the password to unlock.",
onSubmit: async ({ next, remember }) => {
if (!next) return "Enter a password.";
try { await this.encryption.setupFolderKey(folder, next, label, remember); } catch (e) { return (e as Error).message; }
const n = await this.lockFolder(folder);
new Notice(n > 0 ? `Encrypted ${n} note${n === 1 ? "" : "s"} in “${name}”.` : `${name}” now has a Stashpad password — notes added here will use it.`);
this.refreshFolderPanels?.();
return null;
},
}).open();
}
// --- Phase B: per-folder key ROTATION (true invalidation — re-encrypt) ---
private rotationLockPath(folder: string): string {
const cleaned = folder.replace(/\/+$/, "");
@ -3491,15 +3533,24 @@ export default class StashpadPlugin extends Plugin {
* resumeRotations() compares the lock's rotId to the keyfile entry's rotId to know
* whether the swap landed ( commit) or not ( drop temps). */
async rotateFolderKey(folder: string, newPassword: string, remember = false): Promise<number> {
const cleaned = folder.replace(/\/+$/, "");
const requested = folder.replace(/\/+$/, "");
if (!newPassword) { new Notice("A new password is required to rotate."); return -1; }
const oldDek = await this.ensureFolderUnlocked(cleaned);
const oldDek = await this.ensureFolderUnlocked(requested);
if (!oldDek) return -1;
// Inheritance: rotate the key-OWNING folder, and re-encrypt every blob under its
// whole subtree (subfolders share the owner's key).
const cleaned = this.encryption.folderKeyEntry(requested)?.folderPath ?? requested;
const lock = this.rotationLockPath(cleaned);
if (await this.app.vault.adapter.exists(lock)) { new Notice("A rotation is already in progress for this folder (or one was interrupted — reload to recover)."); return -1; }
const blobs = this.app.vault.getFiles()
.filter((f) => f.extension === "stashenc" && (f.parent?.path?.replace(/\/+$/, "") ?? "") === cleaned)
const inSubtree = (p: string) => p === cleaned || p.startsWith(cleaned + "/");
const liveBlobs = this.app.vault.getFiles()
.filter((f) => f.extension === "stashenc" && inSubtree(f.parent?.path?.replace(/\/+$/, "") ?? ""))
.map((f) => f.path);
// Per-folder trash keys: the folder's TRASH blobs (in _deleted/, identified by
// the sidecar keyId) must rotate too, or they'd be orphaned under the old DEK.
const ownerKeyId = this.encryption.folderKeyEntry(cleaned)?.keyId;
const trashBlobs = ownerKeyId ? await deletedBlobsForKeyId(this.app, ownerKeyId) : [];
const blobs = [...liveBlobs, ...trashBlobs];
const newDek = crypto.getRandomValues(new Uint8Array(32));
const rotId = [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, "0")).join("");
const prog = blobs.length > 0 ? new Notice("", 0) : null;
@ -3511,7 +3562,11 @@ export default class StashpadPlugin extends Plugin {
await this.app.vault.adapter.write(lock, JSON.stringify({ folder: cleaned, phase: "committing", rotId, at: new Date().toISOString() }));
await this.encryption.commitFolderRotation(cleaned, newPassword, newDek, rotId, undefined, remember);
prog?.setMessage("🔄 Finalizing…");
await this.commitRotTemps(cleaned);
await this.commitRotTemps(cleaned); // live subtree
for (const tb of trashBlobs) { // trash blobs live in _deleted/, outside the subtree
await commitRotatedBlob(this.app, tb);
try { await rewrapDeletedSidecar(this.app, tb, oldDek, newDek); } catch (e) { console.warn("[Stashpad] trash sidecar rewrap failed", tb, e); }
}
try { await this.app.vault.adapter.remove(lock); } catch { /* */ }
prog?.hide();
new Notice(`🔑 Rotated the key for “${cleaned.split("/").pop()}” — ${blobs.length} item${blobs.length === 1 ? "" : "s"} re-encrypted. The OLD password no longer unlocks it; share the new one with anyone who should keep access.`, 0);
@ -3520,6 +3575,7 @@ export default class StashpadPlugin extends Plugin {
prog?.hide();
// Failure before the keyfile swap → originals intact; just drop the temps.
try { await cleanupRotTemps(this.app, cleaned); } catch { /* */ }
for (const tb of trashBlobs) { try { await this.app.vault.adapter.remove(`${tb}.rot`); } catch { /* */ } }
try { await this.app.vault.adapter.remove(lock); } catch { /* */ }
console.warn("[Stashpad] rotation failed", e);
new Notice(`Rotation failed: ${(e as Error).message}. Nothing was changed (originals intact).`, 0);
@ -3541,12 +3597,18 @@ export default class StashpadPlugin extends Plugin {
try { info = JSON.parse(await this.app.vault.adapter.read(lockPath)); } catch { /* */ }
const folder = (info.folder ?? "").replace(/\/+$/, "");
if (!folder) { try { await this.app.vault.adapter.remove(lockPath); } catch { /* */ } continue; }
const swapped = !!info.rotId && this.encryption.folderKeyEntry(folder)?.rotId === info.rotId;
const entry = this.encryption.folderKeyEntry(folder);
const swapped = !!info.rotId && entry?.rotId === info.rotId;
if (swapped) {
await this.commitRotTemps(folder);
await this.commitRotTemps(folder); // live subtree
// Trash blobs (in _deleted/) keyed to this folder — commit their .rot too
// (byte copy; the keyfile already holds the new DEK). Hidden-origin sidecars
// can't be re-wrapped here (the old DEK is gone) — a rare crash edge.
if (entry?.keyId) { for (const tb of await deletedBlobsForKeyId(this.app, entry.keyId)) { try { await commitRotatedBlob(this.app, tb); } catch (e) { console.warn("[Stashpad] resume trash-commit failed", tb, e); } } }
new Notice(`Finished an interrupted key rotation for “${folder.split("/").pop()}”.`, 0);
} else {
try { await cleanupRotTemps(this.app, folder); } catch { /* */ }
try { await cleanupRotTemps(this.app, "_deleted"); } catch { /* */ }
}
try { await this.app.vault.adapter.remove(lockPath); } catch { /* */ }
}
@ -3557,20 +3619,21 @@ export default class StashpadPlugin extends Plugin {
/** Encrypt-delete a subtree into `_deleted/` (recoverable, encrypted) and
* permanently remove the plaintext. Returns the blob path, or null on failure. */
async encryptDeleteSubtree(folder: string, rootId: StashpadId): Promise<string | null> {
if (!(await this.ensureEncryptionUnlocked())) return null;
const dek = this.encryption.getSessionKey();
// Per-folder trash keys: encrypt the trash blob under the FOLDER's key (or the
// vault DEK as fallback) and stamp its keyId into the sidecar so restore can
// resolve the right key — even for hidden-origin deletes.
const dek = await this.ensureFolderUnlocked(folder);
if (!dek) return null;
try {
// Plugin runtime (not a workflow script) — Date is available.
const deletedAt = new Date().toISOString();
// "Encrypt trash filenames" hides the trash blob's name/origin even when
// the general hide-locked-titles setting is off. Per-folder overhaul: this
// folder's trashEncryptFilenames pref takes precedence over the globals.
// (Trash blobs still use the vault DEK for now — per-folder trash KEYS +
// hidden-origin restore are a later increment.)
// the general hide-locked-titles setting is off. This folder's
// trashEncryptFilenames pref takes precedence over the globals.
const _trFp = (this.settings.folderEncPrefs ?? {})[folder.replace(/\/+$/, "")] ?? {};
const hideTitle = (_trFp.trashEncryptFilenames ?? false) || (this.settings.hideLockedTitles ?? false) || (this.settings.encryptTrashFilenames ?? false);
const r = await deleteEncryptSubtree(this.app, folder, rootId, dek, deletedAt, hideTitle);
const keyId = this.encryption.folderKeyEntry(folder)?.keyId; // undefined ⇒ vault-keyed
const r = await deleteEncryptSubtree(this.app, folder, rootId, dek, deletedAt, hideTitle, keyId);
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);
@ -3585,10 +3648,19 @@ export default class StashpadPlugin extends Plugin {
/** Restore an encrypted-deleted blob back into its original folder. */
async restoreDeletedAt(blobPath: string, opts: { silent?: boolean } = {}): Promise<boolean> {
if (!(await this.ensureEncryptionUnlocked())) return false;
const dek = this.encryption.getSessionKey();
if (!dek) return false;
const meta = await readDeletedMeta(this.app, blobPath);
// Per-folder trash keys: resolve the key this blob was encrypted under via its
// sidecar keyId (the owning folder's key); else the vault DEK (legacy/vault-keyed).
let dek: Uint8Array | null;
if (meta?.keyId) {
const owner = this.encryption.folderPathByKeyId(meta.keyId);
dek = owner ? await this.ensureFolderUnlocked(owner) : null;
if (!dek) { if (!opts.silent) new Notice("Couldn't unlock the folder key this trashed note was encrypted with."); return false; }
} else {
if (!(await this.ensureEncryptionUnlocked())) return false;
dek = this.encryption.getSessionKey();
}
if (!dek) return false;
// Backfill blobs are raw `.trash/` zips, not Stashpad bundles — different
// restore path (plain unzip back into `.trash/`).
if (meta?.kind === "rawtrash") {

View file

@ -306,6 +306,10 @@ export interface StashpadSettings {
* keyed by cleaned folder path. See `FolderEncPrefs`. Empty default no folder
* is encrypted until the user opts in, so existing vaults are unaffected. */
folderEncPrefs: Record<string, FolderEncPrefs>;
/** Comma-separated subfolder-name prefixes (default "_") that EXCLUDE a folder from
* Stashpad discovery + import it stays local, not surfaced/pulled in. A path is
* excluded if any of its segments starts with a listed prefix. */
importExcludePrefixes: string;
/** 0.98.1: registry of locked subtrees, so the list can render a placeholder
* where the note was (and find the blob to unlock). One entry per `.stashenc`
* bundle. `parentId` = where the locked root was attached (null/ROOT = top). */
@ -547,6 +551,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
hideLockedTitles: false,
archiveFolders: [],
folderEncPrefs: {},
importExcludePrefixes: "_",
lockedSubtrees: [],
searchOpensInNewTab: true,
pinnedNotes: [],
@ -639,28 +644,33 @@ export type SettingsTabId =
| "encryption" | "authorship" | "templates" | "jdindex" | "okf"
| "maintenance" | "diagnostics" | "misc" | "hotkeys";
export const SETTINGS_TABS: Array<{ id: SettingsTabId; label: string }> = ([
{ id: "foldersStorage", label: "Folders & storage" },
{ id: "importExport", label: "Import & export" },
{ id: "noteTitles", label: "Note titles" },
{ id: "datesTime", label: "Dates & time" },
{ id: "listDisplay", label: "List & display" },
{ id: "movingNotes", label: "Moving notes" },
{ id: "deleting", label: "Deleting" },
{ id: "composerCopy", label: "Composer & copying" },
{ id: "windowsTabs", label: "Windows & tabs" },
{ id: "notifications", label: "Notifications" },
{ id: "encryption", label: "Encryption" },
{ id: "authorship", label: "Authorship" },
{ id: "templates", label: "Templates" },
{ id: "jdindex", label: "JD Index" },
{ id: "okf", label: "Open Knowledge Format (OKF)" },
{ id: "maintenance", label: "Maintenance" },
{ id: "diagnostics", label: "Diagnostics" },
{ id: "misc", label: "Misc" },
{ id: "hotkeys", label: "Hotkeys" },
{ id: "foldersStorage", label: "📁 Folders & Storage" },
{ id: "importExport", label: "🔄 Import & Export" },
{ id: "noteTitles", label: "🏷️ Note Titles" },
{ id: "datesTime", label: "🕒 Dates & Time" },
{ id: "listDisplay", label: "📋 List & Display" },
{ id: "movingNotes", label: "↕️ Moving Notes" },
{ id: "deleting", label: "🗑️ Deleting" },
{ id: "composerCopy", label: "✍️ Composer & Copying" },
{ id: "windowsTabs", label: "🪟 Windows & Tabs" },
{ id: "notifications", label: "🔔 Notifications" },
{ id: "encryption", label: "🔒 Encryption" },
{ id: "authorship", label: "✒️ Authorship" },
{ id: "templates", label: "📄 Templates" },
{ id: "jdindex", label: "🔢 JD Index" },
{ id: "okf", label: "📚 Open Knowledge Format (OKF)" },
{ id: "maintenance", label: "🛠️ Maintenance" },
{ 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));
] as Array<{ id: SettingsTabId; label: string }>).sort((a, b) => {
// Sort by the label TEXT, ignoring the leading emoji prefix (else the order would
// scramble by emoji codepoint).
const strip = (s: string) => s.replace(/^[^\p{L}\p{N}]+/u, "");
return strip(a.label).localeCompare(strip(b.label));
});
/** 0.94.0: a declarative sub-page that renders one of Stashpad's settings tabs
* via the existing imperative `renderTabContent`. Used by
@ -1042,13 +1052,21 @@ export class StashpadSettingTab extends PluginSettingTab {
* associations). */
private renderFolderEncPanel(host: HTMLElement, folder: string): void {
const enc = this.plugin.encryption;
const hasKey = enc.hasFolderKey(folder);
const hasOwn = enc.hasOwnFolderKey(folder); // this exact folder has a key
const owner = enc.folderKeyEntry(folder)?.folderPath ?? null; // key-owning folder (self or ancestor)
const inherited = !!owner && !hasOwn; // subfolder inheriting an ancestor's key
const unlocked = enc.isFolderUnlocked(folder);
const prefs = (this.plugin.settings.folderEncPrefs ?? {})[folder] ?? {};
const status = hasKey ? (unlocked ? "Has its own password · unlocked" : "Has its own password · locked") : "Uses the vault password";
const status = hasOwn
? (unlocked ? "Has its own password · unlocked" : "Has its own password · locked")
: inherited
? `Inherits “${(owner!.split("/").pop()) || owner}”'s password · ${unlocked ? "unlocked" : "locked"}`
: "Uses the vault password";
const head = new Setting(host).setName("Password").setDesc(status);
if (!hasKey) {
if (inherited) {
head.addButton((b) => b.setButtonText("Manage on parent folder").onClick(() => { this.pfeSelected = owner; this.update?.(); }));
} 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)));
@ -1111,6 +1129,31 @@ export class StashpadSettingTab extends PluginSettingTab {
});
pair("Encrypt archived notes", "archiveEncryptContent", "archiveEncryptFilenames");
pair("Encrypt trashed notes", "trashEncryptContent", "trashEncryptFilenames");
// Subfolders: each shares this folder's key (inheritance), but you can encrypt
// their notes / hide their filenames individually.
const subs = this.plugin.discoverStashpadFolders().filter((sf) => sf !== folder && sf.startsWith(folder + "/")).sort();
if (subs.length) {
new Setting(host).setName("Subfolders").setDesc(`${subs.length} subfolder${subs.length === 1 ? "" : "s"} — they use “${folder.split("/").pop()}”'s password. Encrypt each individually.`).setHeading();
for (const sub of subs) {
const sp = (this.plugin.settings.folderEncPrefs ?? {})[sub] ?? {};
const setSubPref = async (patch: Partial<FolderEncPrefs>) => {
this.plugin.settings.folderEncPrefs = { ...(this.plugin.settings.folderEncPrefs ?? {}), [sub]: { ...((this.plugin.settings.folderEncPrefs ?? {})[sub] ?? {}), ...patch } };
await this.plugin.saveSettings();
};
const subLocked = (this.plugin.settings.lockedSubtrees ?? []).some((e) => (e.folder || "").replace(/\/+$/, "") === sub);
const rel = sub.slice(folder.length + 1);
new Setting(host).setName(rel)
.addToggle((t) => t.setValue(subLocked).onChange(async (v) => {
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?.();
}));
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 })); });
}
}
}
/** 0.94.3: General tab decomposed into per-setting items (render at DISPLAY
@ -1186,6 +1229,10 @@ export class StashpadSettingTab extends PluginSettingTab {
this.plugin.settings.exportFolder = (v || "").trim().replace(/^\/+|\/+$/g, "") || DEFAULT_SETTINGS.exportFolder;
await set();
})), ["export", "stash", "subfolder"]));
cats.importExport.push(this.renderDef("Exclude subfolders by prefix", "Comma-separated name prefixes (default “_”). A subfolder whose name starts with any of these — at any depth — is NOT surfaced as a Stashpad folder or imported (it stays local). To encrypt such a folder, right-click it in Obsidian's file explorer → “🔒 Encrypt with Stashpad”.", (s) =>
s.addText((t) => t.setValue(this.plugin.settings.importExcludePrefixes ?? "_").setPlaceholder("_").onChange(async (v) => {
this.plugin.settings.importExcludePrefixes = v; await set();
})), ["import", "exclude", "prefix", "subfolder", "underscore", "ignore"]));
cats.maintenance.push(this.renderDef("Rebootstrap existing Stashpad folders", "Walk every folder that has a home note: ensure infrastructure (_imports, _exports, drafts file), backfill the redundant parentLink + children frontmatter fields, rename any note whose filename slug no longer matches its body's first line, AND migrate legacy attachment filenames to the new name-first format (`photo-<id>.png`). Safe to run anytime; skip-if-equal means already-synced notes are no-op writes.", (s) =>
s.addButton((b) =>
@ -1304,10 +1351,10 @@ export class StashpadSettingTab extends PluginSettingTab {
}));
}, ["slug", "stopwords", "filename", "title"]));
cats.foldersStorage.push(this.sectionDef("Cross-Stashpad search scope", "Toggle each Stashpad's pill to choose whether its notes contribute to cross-folder search. Excluded folders are still valid move destinations. Also: create a new Stashpad.", (host) => {
cats.foldersStorage.push(this.sectionDef("Cross-Stashpad Search Scope", "Toggle each Stashpad's pill to choose whether its notes contribute to cross-folder search. Excluded folders are still valid move destinations. Also: create a new Stashpad.", (host) => {
const folders = this.plugin.discoverStashpadFolders();
new Setting(host)
.setName("Cross-Stashpad search scope")
.setName("Cross-Stashpad Search Scope")
.setDesc("Toggle each Stashpad's pill to choose whether its notes contribute to cross-folder search. Excluded folders are still valid move destinations — their notes just don't appear in search results from elsewhere.");
if (folders.length === 0) {
host.createEl("p", { cls: "setting-item-description" }).setText(
@ -1338,9 +1385,9 @@ export class StashpadSettingTab extends PluginSettingTab {
}));
}, ["search", "scope", "exclude", "include", "create", "new", "stashpad", "folder"]));
cats.foldersStorage.push(this.sectionDef("Folder panel placement", "Pin, downrank, or hide folders in the Stashpad folder panel. Restore hidden folders here or from the panel's “Hidden” section.", (host) => {
cats.foldersStorage.push(this.sectionDef("Folder Panel Placement", "Pin, downrank, or hide folders in the Stashpad folder panel. Restore hidden folders here or from the panel's “Hidden” section.", (host) => {
new Setting(host)
.setName("Folder panel placement")
.setName("Folder Panel Placement")
.setDesc("Folders you've pinned, downranked, or hidden in the Stashpad folder panel. Pin/downrank from a folder's right-click menu in the panel; restore here or from the panel's “Hidden” section.");
this.renderFolderPlacementList(host);
}, ["folder", "panel", "pin", "pinned", "downrank", "hide", "hidden", "restore", "placement"]));
@ -1364,7 +1411,7 @@ export class StashpadSettingTab extends PluginSettingTab {
const enc = this.plugin.encryption;
const items: SettingDefinitionItem[] = [];
items.push(this.sectionDef("Vault encryption", "Set one password to encrypt content in this vault. Stored only on this device — there is no recovery if you lose it.", (host) => {
items.push(this.sectionDef("Vault Encryption", "Set one password to encrypt content in this vault. Stored only on this device — there is no recovery if you lose it.", (host) => {
host.addClass("stashpad-encryption-section");
const betaRow = host.createDiv({ cls: "stashpad-beta-row" });
betaRow.createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
@ -1700,7 +1747,7 @@ export class StashpadSettingTab extends PluginSettingTab {
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) =>
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();
@ -1719,7 +1766,7 @@ export class StashpadSettingTab extends PluginSettingTab {
}, ["archive", "default", "move", "encrypt", "folder"]));
// ---- 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) => {
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) => {
this.renderPerFolderEncryption(host);
}, ["folder", "per-folder", "password", "encrypt", "key", "share", "collaborator", "archive", "unlock", "lock"]));
@ -2054,11 +2101,11 @@ export class StashpadSettingTab extends PluginSettingTab {
items.push(footerToggle("Show author in note footer", () => this.plugin.settings.showAuthor, (v) => { this.plugin.settings.showAuthor = v; }, ["author", "footer", "show"]));
items.push(footerToggle("Show contributors in note footer", () => this.plugin.settings.showContributors, (v) => { this.plugin.settings.showContributors = v; }, ["contributors", "footer", "show"]));
items.push(footerToggle("Show last edit time in note footer", () => this.plugin.settings.showLastEdit, (v) => { this.plugin.settings.showLastEdit = v; }, ["last edit", "modified", "footer", "time"]));
items.push(this.sectionDef("Folders you've worked in",
items.push(this.sectionDef("Folders You've Worked In",
"Folders where you've authored or contributed notes. Click one to open it.",
(host) => this.renderAuthoredFolders(host),
["folders", "authored", "contributed", "worked"]));
items.push(this.sectionDef("Known authors",
items.push(this.sectionDef("Known Authors",
"Everyone the plugin has seen, with role/department + rename history; rebuild/restore the registry.",
(host) => this.renderKnownAuthorsSection(host),
["authors", "registry", "rename", "known", "rebuild"]));
@ -2085,11 +2132,11 @@ export class StashpadSettingTab extends PluginSettingTab {
/** 0.99.15: Templates tab — the two per-folder editors as searchable sections. */
private templatesItems(): SettingDefinitionItem[] {
return [
this.sectionDef("Color aliases",
this.sectionDef("Color Aliases",
"Give your note colors friendly names, per Stashpad folder.",
(host) => this.renderColorAliasesSection(host),
["color", "colour", "alias", "name", "swatch", "palette", "label"]),
this.sectionDef("Note templates",
this.sectionDef("Note Templates",
"Per-Stashpad note templates — content stamped into new notes.",
(host) => this.renderNoteTemplatesSection(host),
["template", "note", "default", "boilerplate", "snippet"]),
@ -2299,7 +2346,7 @@ export class StashpadSettingTab extends PluginSettingTab {
// Stashpad tab via the per-leaf folderOverride mechanism.
const folders = this.plugin.collectAuthoredFolders();
if (folders.length > 0) {
new Setting(parent).setName("Folders you've worked in").setHeading();
new Setting(parent).setName("Folders You've Worked In").setHeading();
const list = parent.createDiv({ cls: "stashpad-authored-folders-list" });
for (const f of folders) {
const row = list.createDiv({ cls: "stashpad-authored-folder-row" });
@ -2321,7 +2368,7 @@ export class StashpadSettingTab extends PluginSettingTab {
* The registry is NOT authoritative (the id baked into note frontmatter
* is); this is recovery + an audit trail. */
private renderKnownAuthorsSection(parent: HTMLElement): void {
new Setting(parent).setName("Known authors (registry)").setHeading();
new Setting(parent).setName("Known Authors (registry)").setHeading();
parent.createEl("div", {
cls: "setting-item-description",
text: "A rebuildable cache of every author Stashpad has seen, with rename history. Not a source of truth — the author id stored in each note is authoritative. Use it to recover deleted author pages or audit name changes.",

View file

@ -6101,7 +6101,31 @@ export class StashpadView extends ItemView {
const cleanDest = dest.replace(/\/+$/, "");
const encryptDest = (this.plugin.settings.folderEncPrefs ?? {})[cleanDest]?.archiveEncryptContent ?? true;
if (!encryptDest) {
new Notice(`${cleanDest.split("/").pop()}” is a plaintext archive (encryption off). Moving notes into a plaintext archive from the command isn't wired yet — drag them in, or turn on “Encrypt archived notes” for that folder.`, 9000);
// Plaintext archive (encryption off): MOVE the subtree's files into the
// destination (de-indexed via the archive flag, but not encrypted). Stashpad's
// re-home listener reparents the moved roots in the destination folder.
const ids0 = new Set(sources.map((t) => t.id));
const roots0 = sources.filter((t) => { let p = t.parent; while (p) { if (ids0.has(p)) return false; p = this.tree.get(p)?.parent ?? null; } return true; });
if (roots0.length === 0) return;
const files: TFile[] = [];
const seen0 = new Set<string>();
const walk0 = (n: TreeNode) => { if (seen0.has(n.id)) return; seen0.add(n.id); if (n.file) files.push(n.file); for (const c of this.tree.getChildren(n.id)) walk0(c); };
for (const r of roots0) walk0(r);
const moves: Array<{ from: string; to: string }> = [];
for (const f of files) {
const fromPath = f.path;
const name = f.name; const dot = name.lastIndexOf("."); const stem = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : "";
let to = `${cleanDest}/${name}`;
for (let n = 1; await this.app.vault.adapter.exists(to); n++) to = `${cleanDest}/${stem}-${n}${ext}`;
try { await this.app.fileManager.renameFile(f, to); moves.push({ from: fromPath, to }); } catch (e) { console.warn("[Stashpad] plaintext archive move failed", fromPath, e); }
}
this.selection.clear(); this.lastSelected = null; this.tree.rebuild(src); this.render();
const nm = cleanDest.split("/").pop() || cleanDest;
this.plugin.notifications.show({ message: `Moved ${moves.length} note${moves.length === 1 ? "" : "s"} → plaintext archive “${nm}” (de-indexed, not encrypted). Undo to bring ${moves.length === 1 ? "it" : "them"} back.`, kind: "success", category: "system", folder: src });
this.plugin.getUndoStack(src).push({
label: `Archive (plaintext, ${roots0.length})`,
undo: async () => { for (const m of moves) { const fl = this.app.vault.getAbstractFileByPath(m.to); if (fl instanceof TFile) { try { await this.app.fileManager.renameFile(fl, m.from); } catch (e) { console.warn("[Stashpad] plaintext archive undo failed", m.to, e); } } } this.tree.rebuild(src); this.render(); },
});
return;
}
if (!this.plugin.encryption?.isConfigured?.()) {