Compare commits

..

5 commits

17 changed files with 683 additions and 145 deletions

View file

@ -109,8 +109,8 @@ and out of the way when you don't:
- **Import / export**`.stash` bundles (optionally encrypted) round-trip notes +
attachments between vaults.
- **Encryption (beta)** — lock notes or folders into encrypted `.stashenc` files, with
archive folders, encrypted trash, and shared-key collaboration. *(Please read the
caveat below.)*
archive folders, encrypted trash, and shared-key collaboration. Beta: data can be
lost with or without your key. *(Please read the caveat below.)*
- **Authorship / multiplayer** — stamp notes with your name; shared vaults track
contributors via a vault-wide author registry.
- **Johnny.Decimal index** — build a JD-style hierarchy from dotted-prefix titles.
@ -119,10 +119,33 @@ and out of the way when you don't:
(`.zip` / `.tar.gz` / `.stash`) — Google's vendor-neutral Markdown format for handing
curated knowledge to LLMs and agents. See [`docs/okf-guide.md`](docs/okf-guide.md).
> 🔒 **On encryption:** it was written by an AI assistant and has **not** been
> human-audited or security-tested. Treat it as a way to deter a casual snoop, **not**
> a guarantee — don't rely on it for anything sensitive, and keep unencrypted backups.
> It's optional and off by default.
> 🔒 **On encryption — read before you encrypt anything.**
>
> **Save your folder password in a password manager first.** "Remember on this device
> (keychain)" is a convenience, **not a backup**. An OS or keychain reset, a
> wiped/restored profile, a reinstall, a new machine, or an IT event can erase it at
> any time, without warning. If that was the only copy of your password, everything
> encrypted under it is permanently unreadable — by you, by us, by anyone.
>
> A **Recovery password** adds a second way in, but only helps if you keep it somewhere
> separate from the main one — two entries in the same password manager both vanish
> with that manager. Do set one for **shared** folders: it survives a collaborator
> changing the main password.
>
> The encryption was also written by an AI assistant and has **not** been human-audited
> or security-tested. Treat it as a way to deter a casual snoop, **not** a guarantee —
> don't rely on it for anything sensitive, and keep unencrypted backups. It's optional
> and off by default.
>
> Because it's beta, **data loss is possible with or without your key** — a bug, an
> interrupted lock/unlock, or a sync writing the vault mid-operation can damage
> encrypted files even when your password is perfectly safe. Keep unencrypted backups.
>
> *No warranty of any kind. This encryption has never been audited, so it is not proven
> to protect anything. Stashpad cannot recover keys or passwords, and is not liable for
> the loss of keys, passwords or data, for a fault in this beta encryption, or for any
> harm that follows if it fails to keep your data private. Keeping your own backups —
> and deciding what is safe to trust it with — are yours to do.*
## Feedback

216
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.193.0",
"version": "0.198.0",
"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.193.0",
"version": "0.198.0",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

19
release-notes/0.194.0.md Normal file
View file

@ -0,0 +1,19 @@
# 0.194.0
## Encryption warnings
Prompted by a real loss: a server/profile reset wiped a machine's OS keychain and took
the only copy of a folder password with it.
- **The keychain is called out as a convenience, not a backup.** "Remember on this
device (keychain)" now says so at the moment you tick it: an OS or keychain reset, a
wiped or restored profile, a reinstall, a new machine, or an IT event can erase it at
any time, without warning — and if that was the only copy of your password,
everything encrypted under it is permanently unreadable.
- **"Save it in a password manager first" leads the warning**, in larger, bolder type,
ahead of everything else — along with a nudge to set a **Recovery password** as a
second way in. Both live outside the keychain, so they survive the events above.
- The encryption warning callout is **larger and more prominent** overall, and the same
warning now appears in the README and in the password-setup dialog.
- Added plainly: **Stashpad cannot recover keys or passwords, and is not liable for the
loss of keys, passwords, or any data encrypted with them.**

14
release-notes/0.195.0.md Normal file
View file

@ -0,0 +1,14 @@
# 0.195.0
## Encryption warnings
- **"Beta" now says what beta means.** Everywhere encryption is labelled beta, it adds
that **data can be lost with or without your key** — a bug, an interrupted
lock/unlock, or a sync writing the vault mid-operation can damage encrypted files
even when your password is perfectly safe. Keep unencrypted backups.
- That warning, and the liability note with it, now travels on **every password
dialog** (it previously showed a bare "BETA" badge with no explanation), in Settings,
and in the README.
- The liability note is broadened to cover **both** causes: *Stashpad cannot recover
keys or passwords, and is not liable for the loss of keys, passwords, or any data —
whether the cause is a lost key or a fault in this beta encryption itself.*

16
release-notes/0.195.1.md Normal file
View file

@ -0,0 +1,16 @@
# 0.195.1
## Encryption warnings
- The liability note now covers the **security** outcome as well as data loss. It
previously disclaimed losing your keys or data, but said nothing about what happens
if the encryption simply fails to protect something:
> *No warranty of any kind. This encryption has never been audited, so it is not
> proven to protect anything. Stashpad cannot recover keys or passwords, and is not
> liable for the loss of keys, passwords or data, for a fault in this beta
> encryption, or for any harm that follows if it fails to keep your data private.
> Keeping your own backups — and deciding what is safe to trust it with — are yours
> to do.*
It appears in Settings, on every password dialog, and in the README.

21
release-notes/0.196.0.md Normal file
View file

@ -0,0 +1,21 @@
# 0.196.0
## Encryption — honest guidance on recovery passwords
The previous release told everyone to set a Recovery password. That advice was too
blunt: if both passwords end up as two entries in the same password manager, the
second one buys you almost nothing — losing that manager loses both. The guidance now
says when a recovery password actually helps, and when it doesn't.
- **Shared folders are the real case.** Anyone who knows the main password can change
it, and the old password stops working. A recovery password survives that change, so
it's your way back in. The folder-password setup dialog now says this up front, and
the Recovery password setting recommends it specifically for shared folders.
- **"Somewhere separate" is the whole point.** Every mention now spells out that a
recovery password only helps if it lives in a different place from the main one — a
different password manager, printed and filed, or with someone you trust.
- **The main password in a password manager is what saves you.** That's stated as the
primary advice, with the recovery password as a conditional extra rather than a
blanket recommendation.
- A recovery password also can't go stale the way a saved copy of a rotated password
can, since changing the main password leaves it untouched.

24
release-notes/0.197.0.md Normal file
View file

@ -0,0 +1,24 @@
# 0.197.0
## Recurring tasks that actually recur
Repeating a task used to do exactly one thing: completing it re-dated the same note
and cleared the checkbox. That repeats, but leaves no trace — no record you did it
last week, no sign you missed three in a row. You can now choose what repeating
means, per task, in the **🔁 Repeat & reminders** section of the due-date picker:
- **Roll forward (no history)** — the original behaviour, still the default so nothing
changes for existing tasks.
- **Keep each occurrence** — completing leaves that one done in your list and creates a
fresh one for the next interval, so finished work stays visible as history.
- **One per interval (mark misses)** — a new occurrence every interval whether or not
you finished the last. An occurrence that runs past its interval unfinished is closed
out and flagged **✗ missed**, so skipped work is visible instead of quietly sliding
forward. Missed occurrences stay in the list even with "hide completed" on — they
aren't finished work.
- **Roll forward + archive** — one live task as before, but each completion is filed
into the folder's `archive/` so the history exists without cluttering the list.
The recurring **reminder nagging** (`Remind every`) is unchanged and works with all of
them. A missed occurrence records `missed: true` in frontmatter rather than editing
your text, so it survives edits and can be filtered on.

15
release-notes/0.198.0.md Normal file
View file

@ -0,0 +1,15 @@
# 0.198.0
## Tasks
- **Choose what a repeat counts from.** The due-date picker now has a **"Count next
from"** toggle: **Due date** keeps a task on its original cadence no matter when you
finish it, while **Completion** counts the next occurrence from the moment you tick
it off (the "30 days after I actually did it" case). This was always possible by
typing "when done" on the end of the rule — now it's a visible control, which is
what you want when rescheduling something that's slipped.
- **Skip to next occurrence.** A new command and right-click item on repeating tasks
pushes one to its next occurrence *without* completing it — for "not this time".
Completing would either claim you did work you didn't, or leave a history entry
saying so. Skipping just moves the schedule, respects the anchor above, and is
undoable.

View file

@ -29,7 +29,8 @@ import { resolveStashBytes, isEncryptedStash } from "./stash-crypto";
import { StashpadLog } from "./log";
import { parseRunActions, parseStashpadLink, STASHPAD_PROTOCOL_ACTION } from "./deep-link";
import { ROOT_ID, parseAssignees } from "./types";
import { parseRecurrence, nextDueOnComplete, parseDuration } from "./recurrence";
import { parseRecurrence, nextDueOnComplete, parseDuration, parseRepeatMode } from "./recurrence";
import { spawnNextOccurrence, markOccurrenceMissed } from "./recurrence-spawn";
import { OrderStore } from "./order-store";
import { UndoStack } from "./undo-stack";
import { rebootstrapFolderFrontmatter } from "./frontmatter-sync";
@ -1480,6 +1481,11 @@ export default class StashpadPlugin extends Plugin {
name: "Import pasted text (paste → nested notes)…",
callback: () => call("cmdImportText"),
});
this.addCommand({
id: "stashpad-skip-occurrence",
name: "Skip repeating task to its next occurrence",
callback: () => call("cmdSkipOccurrence"),
});
this.addCommand({
id: "stashpad-copy-link",
name: "Copy Stashpad link (deep link / URL) to note",
@ -5834,6 +5840,7 @@ export default class StashpadPlugin extends Plugin {
const persistLog = { ...(this.settings.persistReminderLog ?? {}) };
let persistDirty = false;
const activePersistIds = new Set<string>(); // 0.140.1: for pruning the log
let missedRolled = 0; // 0.197.0: interval-mode occurrences closed out as missed
for (const f of this.app.vault.getMarkdownFiles()) {
if (f.path.includes("/_authors/")) continue;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as { id?: unknown; due?: unknown; completed?: unknown; repeat?: unknown; autoDoneAfter?: unknown; remindEvery?: unknown } | undefined;
@ -5855,6 +5862,28 @@ export default class StashpadPlugin extends Plugin {
continue;
}
}
// 0.197.0: repeatMode "interval" — a new occurrence every interval whether or
// not the last was finished. Once THIS occurrence's window has elapsed, close
// it out flagged as missed and spawn the next, so the list shows what was
// skipped instead of one note quietly sliding forward.
if (fm.completed !== true) {
const mode = parseRepeatMode((fm as { repeatMode?: unknown }).repeatMode);
const recI = mode === "interval" ? parseRecurrence(fm.repeat as string | undefined) : null;
if (recI) {
const windowEnd = recI.next(dueMs);
if (now >= windowEnd) {
await markOccurrenceMissed(this.app, f, now);
// Anchor the next occurrence to the schedule, not to "now", so a task
// ignored for weeks lands back on its real cadence rather than drifting.
let nextMs = windowEnd;
let guard = 0;
while (nextMs <= now && guard++ < 500) nextMs = recI.next(nextMs);
await spawnNextOccurrence(this.app, f, new Date(nextMs).toISOString());
missedRolled++;
continue;
}
}
}
if (fm.completed === true) continue; // completed one-offs: nothing to do
const assignees = parseAssignees(fm);
@ -5890,6 +5919,12 @@ export default class StashpadPlugin extends Plugin {
this.settings.notifiedDueKeys = [...(this.settings.notifiedDueKeys ?? []), ...due.filter((d) => !d.key.endsWith("@persist")).map((d) => d.key)].slice(-2000);
await this.saveSettings();
await this.showDueToasts(due);
if (missedRolled > 0) {
this.notifications.show({
message: `🔁 ${missedRolled} recurring task${missedRolled === 1 ? " was" : "s were"} missed and rolled to the next interval.`,
kind: "warning", category: "reminder", folder: "",
});
}
}
/** 0.185.0: render the due-task toasts. Up to 3 due tasks each get their own

View file

@ -4,6 +4,7 @@ import { buildTimePickerInto } from "./time-picker";
import { siftMatch } from "./types";
import { generatePassphrase, estimatePasswordStrength } from "./passphrase";
import { newId } from "./id-service";
import { REPEAT_MODES, parseRepeatMode } from "./recurrence";
import { ComposerAutocomplete } from "./composer-autocomplete";
import type { ExportContent } from "./stash-package";
import type { ImportLogEntry } from "./import-log";
@ -15,6 +16,7 @@ export interface DuePickResult {
/** 0.140.0: recurrence + reminder rules (empty string = clear the field).
* Present only when the picker showed the "Repeat & reminders" section. */
repeat?: string;
repeatMode?: string;
autoDoneAfter?: string;
remindEvery?: string;
}
@ -22,6 +24,7 @@ export interface DuePickerOptions {
/** 0.140.0: show the "Repeat & reminders" section, pre-filled from these. */
showRecurrence?: boolean;
currentRepeat?: string;
currentRepeatMode?: string;
currentAutoDoneAfter?: string;
currentRemindEvery?: string;
/** Known authors to offer in the assignee picker (from the registry). */
@ -1851,7 +1854,11 @@ export class EncryptionPasswordModal extends Modal {
: mode === "change" ? "Change encryption password"
: "Unlock encryption"),
);
this.contentEl.createDiv({ cls: "stashpad-beta-row" }).createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
// 0.195.0: the badge alone said nothing. Beta encryption can lose data even when
// the key is safe, so the risk + liability travel with every password dialog.
const betaRow = this.contentEl.createDiv({ cls: "stashpad-beta-row" });
betaRow.createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
betaRow.createEl("span", { cls: "stashpad-beta-note", text: "Beta, unaudited encryption — not proven to protect anything, and data can be lost with or without your key. Keep unencrypted backups. Stashpad isn't liable for data loss or for a failure to keep your data private." });
if (this.opts.intro) {
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: this.opts.intro });
@ -1972,7 +1979,10 @@ export class EncryptionPasswordModal extends Modal {
const lbl = row.createEl("label", { text: "Remember on this device (keychain) — auto-unlock here without re-typing." });
lbl.htmlFor = rememberCb.id;
const note = this.contentEl.createDiv({ cls: "stashpad-export-remember-note" });
note.setText("Stored only in this device's keychain — doesn't sync to your other devices. Anyone with access to this unlocked device + keychain could decrypt.");
// 0.194.0: this checkbox is where people accidentally make the keychain the
// ONLY copy of their password. Say so here, at the moment of the decision.
note.setText("Stored only in this device's keychain — it doesn't sync, and anyone with access to this unlocked device could decrypt. This is a convenience, NOT a backup: an OS/keychain reset, a restored profile, a reinstall or a new machine can erase it without warning. Keep the password in a password manager too, or you will lose access to everything encrypted under it.");
note.addClass("stashpad-keychain-note");
note.setCssStyles({ display: rememberCb.checked ? "" : "none" });
rememberCb.onchange = () => { note.setCssStyles({ display: rememberCb!.checked ? "" : "none" }); };
}
@ -2759,6 +2769,7 @@ export class DueDatePickerModal extends Modal {
let repeatIn: HTMLInputElement | null = null;
let autoIn: HTMLInputElement | null = null;
let remindIn: HTMLInputElement | null = null;
let modeSel: HTMLSelectElement | null = null;
if (this.opts.showRecurrence) {
const det = wrap.createEl("details", { cls: "stashpad-due-recur" });
if (this.opts.currentRepeat || this.opts.currentAutoDoneAfter || this.opts.currentRemindEvery) det.open = true;
@ -2773,10 +2784,49 @@ export class DueDatePickerModal extends Modal {
repeatIn = mkRow("Repeat", 'e.g. "every weekday", "every 30 days when done"', this.opts.currentRepeat);
autoIn = mkRow("Auto-complete after", 'e.g. "1d" — mark done once this overdue', this.opts.currentAutoDoneAfter);
remindIn = mkRow("Remind every", 'e.g. "2h" — re-notify until done', this.opts.currentRemindEvery);
// 0.198.0: the anchor was only reachable by typing "when done" on the end of
// the rule. Same setting, now a visible toggle — it's the difference between
// "every Monday" and "30 days after I actually did it", and it's the control
// you want when rescheduling something you've let slip.
const ANCHOR_RE = /\s*(when done|after completion)\s*$/i;
const arow = det.createDiv({ cls: "stashpad-due-recur-row" });
arow.createEl("label", { text: "Count next from" });
const seg = arow.createDiv({ cls: "stashpad-anchor-seg" });
const bDue = seg.createEl("button", { text: "Due date", attr: { type: "button" } });
const bDone = seg.createEl("button", { text: "Completion", attr: { type: "button" } });
const anchorHelp = det.createDiv({ cls: "stashpad-due-recur-help" });
const isDone = (): boolean => ANCHOR_RE.test(repeatIn!.value);
const paintAnchor = (): void => {
const done = isDone();
bDue.toggleClass("is-active", !done);
bDone.toggleClass("is-active", done);
anchorHelp.setText(done
? "Next occurrence counts from when you COMPLETE it — miss a few and the schedule shifts with you."
: "Next occurrence counts from the DUE date — the task keeps its original cadence no matter when you finish.");
};
bDue.onclick = () => { repeatIn!.value = repeatIn!.value.replace(ANCHOR_RE, "").trimEnd(); paintAnchor(); };
bDone.onclick = () => { if (!isDone()) repeatIn!.value = `${repeatIn!.value.trim()} when done`.trim(); paintAnchor(); };
repeatIn.addEventListener("input", paintAnchor);
paintAnchor();
// 0.197.0: what "repeat" actually DOES. Roll-forward (the historic behaviour)
// repeats but keeps no record; the other modes leave per-occurrence history.
const mrow = det.createDiv({ cls: "stashpad-due-recur-row" });
mrow.createEl("label", { text: "When repeating" });
modeSel = mrow.createEl("select");
for (const m of REPEAT_MODES) modeSel.createEl("option", { value: m.id, text: m.label });
modeSel.value = parseRepeatMode(this.opts.currentRepeatMode);
const modeHelp = det.createDiv({ cls: "stashpad-due-recur-help" });
const paintHelp = () => {
modeHelp.setText(REPEAT_MODES.find((m) => m.id === modeSel!.value)?.desc ?? "");
};
modeSel.onchange = paintHelp;
paintHelp();
}
const recur = (): Pick<DuePickResult, "repeat" | "autoDoneAfter" | "remindEvery"> =>
const recur = (): Pick<DuePickResult, "repeat" | "autoDoneAfter" | "remindEvery" | "repeatMode"> =>
this.opts.showRecurrence
? { repeat: repeatIn!.value.trim(), autoDoneAfter: autoIn!.value.trim(), remindEvery: remindIn!.value.trim() }
? { repeat: repeatIn!.value.trim(), autoDoneAfter: autoIn!.value.trim(), remindEvery: remindIn!.value.trim(), repeatMode: modeSel?.value ?? "" }
: {};
const cancel = grid.createEl("button", { cls: "stashpad-due-btn", text: "Cancel" });

92
src/recurrence-spawn.ts Normal file
View file

@ -0,0 +1,92 @@
import { App, TFile } from "obsidian";
import { newId } from "./id-service";
/** 0.197.0 clone a repeating task into its NEXT occurrence.
*
* Lives apart from the view on purpose: occurrences are spawned both from
* cmdToggleComplete (a view) and from the interval sweep in main.ts (no view).
*
* Implemented as vault.copy + processFrontMatter rather than rebuilding the note
* by hand Obsidian then owns the YAML, so nested values (assignees, arrays)
* survive a round trip that hand-written frontmatter would mangle. */
export async function spawnNextOccurrence(
app: App,
src: TFile,
nextDueIso: string,
): Promise<TFile | null> {
try {
const id = newId();
// Keep the human part of the filename, swap the trailing -<id>.
const stem = src.basename.replace(/-[a-z0-9]{4,12}$/, "") || "task";
const dir = src.parent?.path ? `${src.parent.path}/` : "";
let path = `${dir}${stem}-${id}.md`;
for (let i = 2; await app.vault.adapter.exists(path); i++) path = `${dir}${stem}-${id}-${i}.md`;
const copy = await app.vault.copy(src, path);
await app.fileManager.processFrontMatter(copy, (fm: Record<string, unknown>) => {
fm.id = id;
fm.created = new Date().toISOString();
fm.due = nextDueIso;
// A fresh occurrence starts clean: not done, not missed, and without the
// previous instance's edit/authorship trail.
delete fm.completed;
delete fm.missed;
delete fm.missedAt;
delete fm.modified;
delete fm.contributors;
});
return copy;
} catch (e) {
console.error("[Stashpad] failed to spawn the next occurrence", e);
return null;
}
}
/** Close out an occurrence that ran past its interval without being done: keep it
* in place but mark it complete AND flagged, so it leaves the active list while
* staying visible as a miss. Frontmatter rather than a body prefix it survives
* the user editing the text, and it can be filtered on. */
export async function markOccurrenceMissed(app: App, file: TFile, whenMs: number): Promise<void> {
await app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => {
fm.completed = true;
fm.missed = true;
fm.missedAt = new Date(whenMs).toISOString();
});
}
/** 0.197.0 `repeatMode: "archive"`: the live note rolls forward as before, but a
* COMPLETED snapshot of the occurrence just finished is filed into the folder's
* `archive/` subfolder. History without cluttering the active list. */
export async function archiveOccurrenceSnapshot(
app: App,
src: TFile,
dueIso: string | null,
): Promise<TFile | null> {
try {
const dir = `${src.parent?.path ? `${src.parent.path}/` : ""}archive`;
if (!(await app.vault.adapter.exists(dir))) await app.vault.createFolder(dir);
const id = newId();
const stem = src.basename.replace(/-[a-z0-9]{4,12}$/, "") || "task";
let path = `${dir}/${stem}-${id}.md`;
for (let i = 2; await app.vault.adapter.exists(path); i++) path = `${dir}/${stem}-${id}-${i}.md`;
const copy = await app.vault.copy(src, path);
await app.fileManager.processFrontMatter(copy, (fm: Record<string, unknown>) => {
fm.id = id;
fm.completed = true;
// The snapshot records the occurrence that was just finished, so it keeps the
// due date it was completed AGAINST — not the rolled-forward one.
if (dueIso) fm.due = dueIso;
fm.completedAt = new Date().toISOString();
// A snapshot must never repeat again, or the archive would breed.
delete fm.repeat;
delete fm.repeatMode;
delete fm.remindEvery;
delete fm.autoDoneAfter;
});
return copy;
} catch (e) {
console.error("[Stashpad] failed to archive the completed occurrence", e);
return null;
}
}

View file

@ -133,3 +133,25 @@ export function nextDueOnComplete(rec: Recurrence, oldDueMs: number | null, nowM
if (next <= nowMs) { next = rec.next(nowMs); let g = 0; while (next <= nowMs && g++ < 5000) next = rec.next(next); }
return next;
}
/** 0.197.0 how a repeating task produces its next occurrence.
*
* Until now there was exactly one behaviour: completing a repeating task re-dated
* the SAME note and cleared `completed` ("roll forward"). That repeats fine but
* leaves no trace no record you did it last week, no sign you missed three. These
* modes add per-occurrence history; `rollForward` stays the default so existing
* repeating tasks don't silently change behaviour. */
export type RepeatMode = "rollForward" | "complete" | "interval" | "archive";
export const REPEAT_MODES: Array<{ id: RepeatMode; label: string; desc: string }> = [
{ id: "rollForward", label: "Roll forward (no history)", desc: "One note whose due date moves. Nothing is kept for past occurrences." },
{ id: "complete", label: "Keep each occurrence", desc: "Completing leaves that one done in the list and creates a fresh one for the next interval." },
{ id: "interval", label: "One per interval (mark misses)", desc: "A new occurrence every interval whether or not you finished the last. An unfinished one is closed out and flagged as missed." },
{ id: "archive", label: "Roll forward + archive", desc: "One live note as today, but each completion is archived so the history exists without cluttering the list." },
];
export function parseRepeatMode(v: unknown): RepeatMode {
const s = typeof v === "string" ? v.trim().toLowerCase() : "";
for (const m of REPEAT_MODES) if (m.id.toLowerCase() === s) return m.id;
return "rollForward"; // legacy / unset
}

View file

@ -1108,7 +1108,7 @@ export class StashpadSettingTab extends PluginSettingTab {
}
private promptSetFolderPassword(folder: string): void {
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: true, title: `Set password for “${folder.split("/").pop()}`,
intro: "A separate password just for this folder. Share it with collaborators out-of-band (a password manager, Signal, in person). There is NO recovery if it's lost.",
intro: "A separate password just for this folder. Save it in a password manager BEFORE you continue — the device keychain is a convenience that can be wiped at any time, not a backup. There is NO recovery if it's lost: Stashpad cannot recover it, and everything encrypted under it becomes permanently unreadable. If you plan to SHARE this folder, add a Recovery password afterwards and keep it somewhere separate — anyone you share the main password with can change it, and only a recovery password gets you back in.",
onSubmit: async ({ next, remember }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.setupFolderKey(folder, next, this.folderKeyLabel(folder), remember); } catch (e) { return (e as Error).message; } new Notice("Folder password set — share it securely."); this.update?.(); this.pfeRerender?.(); return null; } }).open();
}
private promptChangeFolderPassword(folder: string): void {
@ -1119,7 +1119,7 @@ export class StashpadSettingTab extends PluginSettingTab {
private promptSetFolderRecovery(folder: string): void {
const name = folder.split("/").pop() || folder;
new EncryptionPasswordModal(this.app, { mode: "setup", offerKeychain: false, title: `Recovery password for “${name}`,
intro: "A SECOND password that also unlocks this folder — a backup in case the main password is lost. It wraps the same key, so it's exactly as sensitive: store it somewhere safe and separate. Setting it again replaces any existing recovery password.",
intro: "A SECOND password that also unlocks this folder. Either one works, so it is exactly as sensitive as the main password. It only buys you anything if you store it SOMEWHERE ELSE — a different password manager, printed and filed, or with someone you trust; two entries in the same manager both vanish with that manager. Its other job: it survives a change of the main password, so on a SHARED folder it's your way back in if a collaborator rotates the password, and it can't go stale the way a saved copy of a rotated password does. Setting it again replaces any existing recovery password.",
onSubmit: async ({ next }) => { if (!next) return "Enter a password."; try { await this.plugin.encryption.setFolderRecoveryPassword(folder, next); } catch (e) { return (e as Error).message; } new Notice("Recovery password set — keep it somewhere safe."); this.update?.(); this.pfeRerender?.(); return null; } }).open();
}
private promptRemoveFolderRecovery(folder: string): void {
@ -1225,8 +1225,8 @@ export class StashpadSettingTab extends PluginSettingTab {
const hasRec = this.plugin.encryption.folderHasRecovery(folder);
const rec = new Setting(host).setName("Recovery password")
.setDesc(hasRec
? "A second password that also unlocks this folder. Keep it somewhere safe and separate."
: "Optional. Add a second password that also unlocks this folder, as a backup if the main one is lost.");
? "A second password that also unlocks this folder. Keep it somewhere SEPARATE from the main password — a different password manager, printed, or with someone you trust."
: "Optional second password that also unlocks this folder. Only worth it if you store it somewhere SEPARATE from the main password — two entries in the same password manager don't protect you from losing that manager. Recommended for SHARED folders: it survives someone else changing the main password.");
rec.addButton((b) => b.setButtonText(hasRec ? "Change recovery…" : "Add recovery…").onClick(() => this.promptSetFolderRecovery(folder)));
if (hasRec) rec.addButton((b) => { b.setButtonText("Remove recovery").onClick(() => this.promptRemoveFolderRecovery(folder)); b.buttonEl.addClass("mod-warning"); });
}
@ -1656,15 +1656,31 @@ 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", "Encrypt notes per folder with a password you choose. Keep that password in a password manager — there is no recovery if it is lost.", (host) => {
host.addClass("stashpad-encryption-section");
const betaRow = host.createDiv({ cls: "stashpad-beta-row" });
betaRow.createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
betaRow.createEl("span", { cls: "stashpad-beta-note", text: "Encryption is in beta — keep your own backups of anything important." });
// 0.195.0: say the quiet part — beta code can lose data even when your key is
// perfectly safe (a bug, an interrupted lock/unlock, a sync writing mid-op).
betaRow.createEl("span", { cls: "stashpad-beta-note", text: "Encryption is in beta — data can be lost with or without your key. Keep your own unencrypted backups." });
// 0.134.3: ONE merged warning callout (was three: the AI disclaimer box,
// a ⚠️ description paragraph here, and a "no recovery" callout at the top
// of the per-folder section). Same .stashpad-enc-warning callout style.
const warn = host.createEl("div", { cls: "stashpad-enc-warning" });
// 0.194.0: keychain loss leads, because it's the failure that actually happens.
// (Real incident: a server/profile reset wiped the OS keychain and took the only
// copy of a folder password with it.) The key itself lives in the folder's
// `.stashkey`; the keychain only ever held a CONVENIENCE copy of the password —
// so if that copy was the only one, the content is gone.
warn.createEl("p", { cls: "stashpad-enc-lead" }).setText(
"⚠️ Write your password down somewhere outside this computer — a password manager — BEFORE you encrypt anything.",
);
warn.createEl("p").setText(
"“Remember on this device (keychain)” is a convenience, NOT a backup. An OS or keychain reset, a wiped/restored profile, a reinstall, a new machine, or an IT/server event can erase it at any time, without warning and without asking you. If the only copy of your password was in the keychain, everything encrypted under it is permanently unreadable — by you, by us, by anyone.",
);
warn.createEl("p").setText(
"Store the password in a password manager (1Password, Bitwarden, KeePass…) — that alone is what saves you, and it lives outside the keychain so it survives the events above. A Recovery password is a SECOND way in, but only helps if you keep it somewhere separate from the main one (a different manager, printed, or with someone you trust); two entries in the same manager add little. Do set one for SHARED folders — it survives a collaborator changing the main password.",
);
warn.createEl("p").setText(
"⚠️ AI-built, NOT human-audited. This encryption was written by an AI assistant — not reviewed or security-audited by a human. Treat it as best-effort protection against a casual snoop, not a guarantee — and always keep your own unencrypted backups of anything important.",
);
@ -1674,6 +1690,9 @@ export class StashpadSettingTab extends PluginSettingTab {
warn.createEl("p").setText(
"Each device unlocks with its own password (it never leaves the device); collaborators get access by device approval, not a shared password. If everyone with access loses their password, the content is unrecoverable. While encrypting or decrypting, avoid having a sync/cloud service write the vault mid-operation — it can corrupt files.",
);
warn.createEl("p", { cls: "stashpad-enc-liability" }).setText(
"No warranty of any kind. This encryption has never been audited, so it is not proven to protect anything. Stashpad cannot recover keys or passwords, and is not liable for the loss of keys, passwords or data, for a fault in this beta encryption, or for any harm that follows if it fails to keep your data private. Keeping your own backups — and deciding what is safe to trust it with — are yours to do.",
);
// 0.137.4: ONE collapsible, toggle-free "how it works" reference block, so
// the individual setting rows below can stay one-liners instead of each

View file

@ -11,7 +11,8 @@ import {
import { TreeIndex } from "./tree-index";
import { perf } from "./perf";
import { formatDateTime, formatDateOnly, formatTimeOnly } from "./format";
import { parseRecurrence, nextDueOnComplete } from "./recurrence";
import { parseRecurrence, nextDueOnComplete, parseRepeatMode } from "./recurrence";
import { spawnNextOccurrence, archiveOccurrenceSnapshot } from "./recurrence-spawn";
import { OrderStore } from "./order-store";
import { SortStore, SORT_MODE_LABELS, SORT_MODES_ORDER } from "./sort-store";
import { FrontmatterSyncQueue, rebootstrapFolderFrontmatter } from "./frontmatter-sync";
@ -2159,7 +2160,11 @@ export class StashpadView extends ItemView {
// only when its subtree has no remaining work — so a category
// checked off but still containing an unchecked task stays
// visible until the last task is done.
if (hideCompleted && this.isCompleted(n) && !this.hasIncompleteDescendant(n)) return false;
// 0.197.0: a MISSED repeat is marked completed only so it drops out of the
// active cadence — it is not finished work. Hiding it with "hide completed"
// would make the miss invisible, which is the whole thing we're trying to
// surface, so missed occurrences stay visible.
if (hideCompleted && this.isCompleted(n) && !this.isMissed(n) && !this.hasIncompleteDescendant(n)) return false;
// Attachments-only: keep a node if it (or any descendant) has an
// attachment, so the attachment-bearing child stays reachable.
if (attachmentsOnly && !this.hasAttachmentInSubtree(n)) return false;
@ -5138,6 +5143,13 @@ export class StashpadView extends ItemView {
if (isCursor && this.cursorHasMoved && this.plugin.settings.autoExpandCursorRow && !this.cursorExpandOverride.has(node.id)) row.addClass("is-cursor-expanded");
if (isPickTarget) row.addClass("is-pick-target");
if (this.isCompleted(node)) row.addClass("is-completed");
// 0.197.0: a repeating occurrence that ran out its interval unfinished is marked
// completed so it leaves the active list — but it was MISSED, not done. Without
// this it would be indistinguishable from work you actually finished.
if (this.isMissed(node)) {
row.addClass("is-missed");
row.title = "Missed — this repeat ran past its interval without being completed.";
}
if (this.isListPinned(node.id)) row.addClass("is-list-pinned");
// 0.99.5: ghost rows that are sitting on a pending CUT (note clipboard),
// mirroring a file manager — they're about to move/be-extracted on paste.
@ -8298,6 +8310,59 @@ export class StashpadView extends ItemView {
return null;
}
/** 0.198.0: push a repeating task to its NEXT occurrence without completing it
* "not this time". Completing would either mark work done you didn't do or spawn a
* history entry claiming you did; skipping just advances the schedule. Honours the
* rule's anchor, so a "when done" task counts from now and a due-anchored one keeps
* its cadence. */
async cmdSkipOccurrence(node?: TreeNode): Promise<void> {
const targets = node ? [node] : this.getActionTargets();
const skipped: Array<{ title: string; when: number }> = [];
const prior: Array<{ path: string; due: unknown }> = [];
for (const t of targets) {
if (!t.file) continue;
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter;
const rec = parseRecurrence(fm?.repeat as string | undefined);
if (!rec) continue;
const oldDue = fm?.due != null ? Date.parse(String(fm.due)) : NaN;
const next = nextDueOnComplete(rec, Number.isFinite(oldDue) ? oldDue : null, Date.now());
prior.push({ path: t.file.path, due: fm?.due });
this.markFmSelfWrite(t.file.path);
await this.app.fileManager.processFrontMatter(t.file, (m) => {
m.due = new Date(next).toISOString();
// Skipping is not completing, and not a miss — it's a deliberate pass.
delete m.completed;
delete m.missed;
delete m.missedAt;
});
skipped.push({ title: this.titleForNode(t), when: next });
}
if (!skipped.length) { new Notice("Nothing to skip — select a repeating task."); return; }
this.tree.rebuild(this.noteFolder);
this.render();
this.plugin.notifications.show({
message: skipped.length === 1
? `⏭️ Skipped “${skipped[0].title}” → next on ${formatDateTime(skipped[0].when, this.plugin.settings)}.`
: `⏭️ Skipped ${skipped.length} repeating tasks to their next occurrence.`,
kind: "success", category: "system", folder: this.noteFolder,
});
const folder = this.noteFolder;
this.plugin.getUndoStack(folder).push({
label: `Skip occurrence (${skipped.length})`,
undo: async () => {
for (const p of prior) {
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
if (!f) continue;
await this.app.fileManager.processFrontMatter(f, (m) => {
if (p.due === undefined) delete m.due; else m.due = p.due;
});
}
this.tree.rebuild(folder);
this.render();
},
});
}
/** 0.192.0: open the paste-text importer the standalone Stashpad Importer web
* app's job, done in-plugin (no `.stash` round-trip). Level-1 notes land under
* whatever is currently focused, so you can import straight into a subtree. */
@ -9882,6 +9947,10 @@ export class StashpadView extends ItemView {
const changedIds: StashpadId[] = [];
const rolled: Array<{ title: string; when: number }> = []; // recurring tasks rescheduled
// 0.197.0: occurrences spawned for the next interval, and notes whose completion
// should also be filed into archive/ (repeatMode "archive").
let spawned = 0;
const archivedSnapshots: Array<{ file: TFile; dueIso: string | null }> = [];
for (const t of targets) {
if (!t.file) continue;
const was = this.isCompleted(t);
@ -9891,29 +9960,53 @@ export class StashpadView extends ItemView {
priorStates.push(ps);
if (was === newState) continue;
let didRoll = false;
// 0.197.0: what completing a repeating task does now depends on its repeat
// MODE. rollForward re-dates this note (historic behaviour, no history);
// complete/interval leave this occurrence done and spawn the next one;
// archive re-dates the live note but files a completed copy in archive/.
let spawnNextIso: string | null = null;
let archiveThis = false;
this.markFmSelfWrite(t.file.path); // body unchanged → no placeholder flash
await this.app.fileManager.processFrontMatter(t.file, (fm) => {
// 0.140.0: completing a REPEATING task rolls it forward (stays an active
// task with a new due) instead of just going strikethrough.
const rec = newState ? parseRecurrence(fm.repeat as string | undefined) : null;
const mode = parseRepeatMode(fm.repeatMode);
if (rec) {
const oldDue = fm.due != null ? Date.parse(String(fm.due)) : NaN;
const next = nextDueOnComplete(rec, Number.isFinite(oldDue) ? oldDue : null, Date.now());
fm.due = new Date(next).toISOString();
delete fm.completed;
ps.rolledTo = fm.due as string;
const nextIso = new Date(next).toISOString();
if (mode === "complete" || mode === "interval") {
// This occurrence is genuinely finished — it stays completed, and a
// fresh incomplete one is created for the next interval.
fm.completed = true;
delete fm.missed;
spawnNextIso = nextIso;
} else {
// rollForward / archive: the same note moves to the next date.
fm.due = nextIso;
delete fm.completed;
ps.rolledTo = nextIso;
didRoll = true;
if (mode === "archive") archiveThis = true;
}
rolled.push({ title: (t.file!.basename.replace(/-[a-z0-9]{4,12}$/, "").replace(/-/g, " ").trim()) || "task", when: next });
didRoll = true;
} else if (newState) fm.completed = true;
else delete fm.completed;
});
if (spawnNextIso) {
const made = await spawnNextOccurrence(this.app, t.file, spawnNextIso);
if (made) spawned++;
}
if (archiveThis) archivedSnapshots.push({ file: t.file, dueIso: ps.dueBefore });
// A rolled task is NOT completed — reflect its real (incomplete) state.
this.completedState.set(t.file.path, didRoll ? false : newState);
changedIds.push(t.id);
}
// repeatMode "archive": file the completed snapshot after the live note rolled.
for (const a of archivedSnapshots) await archiveOccurrenceSnapshot(this.app, a.file, a.dueIso);
this.render();
for (const r of rolled) {
this.plugin.notifications.show({ message: `🔁 Rescheduled “${r.title}” → ${formatDateTime(r.when, this.plugin.settings)}.`, kind: "success", category: "system", folder: this.noteFolder });
const verb = spawned > 0 ? "Next up" : "Rescheduled";
this.plugin.notifications.show({ message: `🔁 ${verb}: “${r.title}” → ${formatDateTime(r.when, this.plugin.settings)}.`, kind: "success", category: "system", folder: this.noteFolder });
}
if (changedIds.length > 0) {
await this.log.append({
@ -10021,6 +10114,14 @@ export class StashpadView extends ItemView {
* there's no override. */
private listPinnedState = new Map<string, { pinned: boolean; at: number }>();
/** 0.197.0: was this occurrence closed out as MISSED (interval elapsed unfinished)
* rather than actually completed? Frontmatter-backed so it survives body edits. */
isMissed(node: TreeNode): boolean {
if (!node.file) return false;
const fm = this.app.metadataCache.getFileCache(node.file)?.frontmatter as { missed?: unknown } | undefined;
return fm?.missed === true;
}
private isCompleted(node: TreeNode): boolean {
if (!node.file) return false;
const override = this.completedState.get(node.file.path);
@ -10060,13 +10161,14 @@ export class StashpadView extends ItemView {
const currentAssignees = parseAssignees(curFm ?? {});
new DueDatePickerModal(this.app, current, (result) => {
void this.applyDue(targets, result.iso, result.assignees, false, {
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery,
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery, repeatMode: result.repeatMode,
});
}, { knownAuthors, currentAssignees, quickAdjusts: this.plugin.settings.dueQuickAdjusts,
// 0.140.1: recurrence is a per-note concept — only show/write it for a
// single target, else a multi-select would clobber 2..n's rules with #1's.
showRecurrence: targets.length === 1,
currentRepeat: typeof curFm?.repeat === "string" ? curFm.repeat : "",
currentRepeatMode: typeof curFm?.repeatMode === "string" ? curFm.repeatMode : "",
currentAutoDoneAfter: typeof curFm?.autoDoneAfter === "string" ? curFm.autoDoneAfter : "",
currentRemindEvery: typeof curFm?.remindEvery === "string" ? curFm.remindEvery : "",
}).open();
@ -10095,8 +10197,8 @@ export class StashpadView extends ItemView {
/** Write the chosen due value (or clear it) across `targets`, with
* undo. Setting a date also flips `task: true`; clearing leaves the
* task flag intact (clearing a due "no longer a task"). */
private async applyDue(targets: TreeNode[], iso: string | null, assignees: Array<{ id: string; name: string }> = [], dueOnly = false, recur?: { repeat?: string; autoDoneAfter?: string; remindEvery?: string }): Promise<void> {
const prior: { id: StashpadId; path: string; due: unknown; task: unknown; assignedTo: unknown; assignedBy: unknown; wasTagged: boolean; repeat: unknown; autoDoneAfter: unknown; remindEvery: unknown }[] = [];
private async applyDue(targets: TreeNode[], iso: string | null, assignees: Array<{ id: string; name: string }> = [], dueOnly = false, recur?: { repeat?: string; autoDoneAfter?: string; remindEvery?: string; repeatMode?: string }): Promise<void> {
const prior: { id: StashpadId; path: string; due: unknown; task: unknown; assignedTo: unknown; assignedBy: unknown; wasTagged: boolean; repeat: unknown; autoDoneAfter: unknown; remindEvery: unknown; repeatMode: unknown }[] = [];
const changedIds: StashpadId[] = [];
// 0.78.1: who is doing the assigning (the local user) — stamped as
// assignedBy so the "assigned by me" filter works. Null if the user
@ -10112,7 +10214,7 @@ export class StashpadView extends ItemView {
if (!t.file) continue;
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
const wasTagged = this.isTaskTagged(t);
prior.push({ id: t.id, path: t.file.path, due: fm?.due, task: fm?.task, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, wasTagged, repeat: fm?.repeat, autoDoneAfter: fm?.autoDoneAfter, remindEvery: fm?.remindEvery });
prior.push({ id: t.id, path: t.file.path, due: fm?.due, task: fm?.task, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, wasTagged, repeat: fm?.repeat, autoDoneAfter: fm?.autoDoneAfter, remindEvery: fm?.remindEvery, repeatMode: fm?.repeatMode });
this.markFmSelfWrite(t.file.path); // body unchanged → no placeholder flash
await this.app.fileManager.processFrontMatter(t.file, (m) => {
if (iso === null) delete m.due;
@ -10120,11 +10222,13 @@ export class StashpadView extends ItemView {
// 0.140.0: recurrence + reminder fields — an empty string clears the
// field; a non-empty one sets it (and implies task-ness).
if (recur) {
const set3 = (k: "repeat" | "autoDoneAfter" | "remindEvery", v?: string) => {
const set3 = (k: "repeat" | "autoDoneAfter" | "remindEvery" | "repeatMode", v?: string) => {
const val = (v ?? "").trim();
if (val) { m[k] = val; m.task = true; } else delete m[k];
};
set3("repeat", recur.repeat);
// Only meaningful with a repeat rule; clearing repeat clears the mode too.
set3("repeatMode", recur.repeat ? recur.repeatMode : "");
set3("autoDoneAfter", recur.autoDoneAfter);
set3("remindEvery", recur.remindEvery);
}
@ -10177,6 +10281,7 @@ export class StashpadView extends ItemView {
if (p.assignedBy === undefined) delete m.assignedBy; else m.assignedBy = p.assignedBy;
// 0.140.1: restore recurrence/reminder fields too (else undo left them).
if (p.repeat === undefined) delete m.repeat; else m.repeat = p.repeat;
if (p.repeatMode === undefined) delete m.repeatMode; else m.repeatMode = p.repeatMode;
if (p.autoDoneAfter === undefined) delete m.autoDoneAfter; else m.autoDoneAfter = p.autoDoneAfter;
if (p.remindEvery === undefined) delete m.remindEvery; else m.remindEvery = p.remindEvery;
});
@ -10208,7 +10313,7 @@ export class StashpadView extends ItemView {
const currentAssignees = parseAssignees(curFm ?? {});
new DueDatePickerModal(this.app, current, (result) => {
void this.applyDue(targets, result.iso, result.assignees, false, {
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery,
repeat: result.repeat, autoDoneAfter: result.autoDoneAfter, remindEvery: result.remindEvery, repeatMode: result.repeatMode,
});
}, { knownAuthors, currentAssignees, title: "Assign / schedule task",
// 0.155.0: Assign opens the SAME picker as "Set due date" with the full
@ -10218,6 +10323,7 @@ export class StashpadView extends ItemView {
quickAdjusts: this.plugin.settings.dueQuickAdjusts,
showRecurrence: targets.length === 1,
currentRepeat: typeof curFm?.repeat === "string" ? curFm.repeat : "",
currentRepeatMode: typeof curFm?.repeatMode === "string" ? curFm.repeatMode : "",
currentAutoDoneAfter: typeof curFm?.autoDoneAfter === "string" ? curFm.autoDoneAfter : "",
currentRemindEvery: typeof curFm?.remindEvery === "string" ? curFm.remindEvery : "",
}).open();
@ -12223,6 +12329,10 @@ export class StashpadView extends ItemView {
menu.addSeparator();
menu.addItem((it: any) => it.setTitle("Edit in Stashpad").setIcon("pencil-line").onClick(() => void this.cmdEdit(node)));
menu.addItem((it: any) => it.setTitle("Split note…").setIcon("split").onClick(() => void this.cmdSplit(node)));
// Only meaningful on a repeating task; hidden otherwise so the menu stays short.
if (parseRecurrence(this.app.metadataCache.getFileCache(node.file!)?.frontmatter?.repeat as string | undefined)) {
menu.addItem((it: any) => it.setTitle("Skip to next occurrence").setIcon("skip-forward").onClick(() => void this.cmdSkipOccurrence(node)));
}
// 0.122.2 (#9): copy the note's text. `focusClicked` (defined below)
// normalises selection to the right-clicked row.
// 0.122.10: ordered above Clone so the plain "Copy text" reads first.

View file

@ -6090,11 +6090,30 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
Vault Encryption section (was three separate warnings) inset 14px to
line up with the section's setting rows. */
margin: 4px 14px 18px;
font-size: 13px;
line-height: 1.5;
/* 0.194.0: bumped 13px -> 14px and the rule 3px -> 4px. A user lost an encryption
key to a keychain wipe; this callout is the last thing standing between someone
and permanent data loss, so it gets read-me-first weight. */
font-size: 14px;
line-height: 1.55;
}
.stashpad-enc-warning p { margin: 0; }
.stashpad-enc-warning p + p { margin-top: 8px; }
.stashpad-enc-warning p + p { margin-top: 9px; }
/* The single most important sentence in the plugin larger and bolder than the
paragraphs beneath it. */
.stashpad-enc-warning .stashpad-enc-lead {
font-size: 15px;
font-weight: 600;
line-height: 1.45;
}
.stashpad-enc-warning strong { color: var(--text-normal); font-weight: 700; }
/* Liability line: quieter than the warnings, but unmissable. */
.stashpad-enc-warning .stashpad-enc-liability {
margin-top: 11px;
padding-top: 9px;
border-top: 1px solid rgba(var(--color-red-rgb, 224, 49, 49), 0.35);
font-size: 13px;
font-style: italic;
}
/* 0.137.4: collapsible "how encryption works" concepts block one toggle-free
reference so the individual setting rows can stay one-liners. Collapsed by
default (details), neutral (not a warning). */
@ -6229,3 +6248,62 @@ button.stashpad-btn-loading::after {
.stashpad-import-view .stashpad-import-preview { max-height: none; }
.stashpad-import-popicon { display: inline-flex; margin-left: 4px; vertical-align: -2px; }
.stashpad-import-popicon svg { width: 14px; height: 14px; }
/* 0.194.0 the keychain opt-in note carries a real data-loss warning now, so it
gets warning colour instead of reading as neutral fine print. */
.stashpad-keychain-note {
color: var(--text-normal);
border-left: 3px solid var(--color-red, #e03131);
background: rgba(var(--color-red-rgb, 224, 49, 49), 0.10);
padding: 8px 10px;
border-radius: 4px;
margin-top: 6px;
line-height: 1.5;
}
/* 0.197.0 — recurrence mode picker help text under the dropdown. */
.stashpad-due-recur-help {
margin: 2px 0 6px;
padding-left: 2px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: 1.4;
}
/* 0.197.0 a MISSED recurring occurrence. It's marked completed (so it drops out
of the active list) but it wasn't done, so it must not read as an accomplishment:
warning-tinted strike instead of the neutral completed grey, plus a marker. */
.stashpad-note.is-missed .stashpad-note-body,
.stashpad-note.is-missed .stashpad-note-time {
text-decoration-color: var(--color-orange, #e08a47);
color: var(--text-muted);
}
/* Marker sits in the row's own padding, next to the completed checkbox. */
.stashpad-note.is-missed .stashpad-note-body::after {
content: " ✗ missed";
color: var(--color-orange, #e08a47);
font-size: var(--font-ui-smaller);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
/* 0.198.0 — recurrence anchor toggle (due-date vs completion) in the due picker. */
.stashpad-anchor-seg { display: inline-flex; gap: 0; }
.stashpad-anchor-seg button {
padding: 3px 10px;
font-size: var(--font-ui-smaller);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-muted);
border-radius: 0;
box-shadow: none;
cursor: pointer;
}
.stashpad-anchor-seg button:first-child { border-radius: 4px 0 0 4px; }
.stashpad-anchor-seg button:last-child { border-radius: 0 4px 4px 0; border-left: 0; }
.stashpad-anchor-seg button.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}