mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.115.6: per-folder encryption polish + subfolders, plaintext archives, encrypt-from-explorer
This commit is contained in:
parent
d24f4a0892
commit
60b7634519
9 changed files with 419 additions and 177 deletions
160
main.js
160
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
36
release-notes/0.115.6.md
Normal 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.
|
||||
|
|
@ -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/`. */
|
||||
|
|
|
|||
|
|
@ -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(/\/+$/, ""); }
|
||||
|
|
|
|||
110
src/main.ts
110
src/main.ts
|
|
@ -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") {
|
||||
|
|
|
|||
119
src/settings.ts
119
src/settings.ts
|
|
@ -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.",
|
||||
|
|
|
|||
26
src/view.ts
26
src/view.ts
|
|
@ -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?.()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue