grub-basket_SP/src/modals.ts

2362 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Modal, Platform, moment, Notice, setIcon, type SecretStorage } from "obsidian";
import { splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode } from "./view-helpers";
import { buildTimePickerInto } from "./time-picker";
import { siftMatch } from "./types";
import { generatePassphrase, estimatePasswordStrength } from "./passphrase";
import { newId } from "./id-service";
import type { ImportLogEntry } from "./import-log";
export interface AssigneeRef { id: string; name: string }
export interface DuePickResult {
iso: string | null;
assignees: AssigneeRef[];
/** 0.140.0: recurrence + reminder rules (empty string = clear the field).
* Present only when the picker showed the "Repeat & reminders" section. */
repeat?: string;
autoDoneAfter?: string;
remindEvery?: string;
}
export interface DuePickerOptions {
/** 0.140.0: show the "Repeat & reminders" section, pre-filled from these. */
showRecurrence?: boolean;
currentRepeat?: string;
currentAutoDoneAfter?: string;
currentRemindEvery?: string;
/** Known authors to offer in the assignee picker (from the registry). */
knownAuthors?: AssigneeRef[];
/** Assignees already on the note, to pre-fill the chips. */
currentAssignees?: AssigneeRef[];
/** Modal title. Defaults to "Set due date". The "Assign to" command
* opens this same modal with a different title. */
title?: string;
/** 0.125.0: hide the "Assign to" section — used by Snooze, which only
* reschedules the due date and must not touch assignees. */
hideAssignees?: boolean;
/** 0.125.1: quick relative time-adjust presets (e.g. ["5m","1h","1d"]). When
* non-empty, a row of +/- buttons nudges the entered date/time by each
* amount; a flip toggle switches between adding and subtracting. */
quickAdjusts?: string[];
}
/** 0.125.1: parse a compact duration token ("5m", "15m", "1h", "2d", "1w") into
* minutes. Returns null when unparseable so callers can skip bad presets. */
export function parseAdjustMinutes(raw: string): number | null {
const m = /^\s*(\d+)\s*(m|h|d|w)\s*$/i.exec(raw);
if (!m) return null;
const n = parseInt(m[1], 10);
if (!Number.isFinite(n) || n <= 0) return null;
const unit = { m: 1, h: 60, d: 1440, w: 10080 }[m[2].toLowerCase()] ?? 1;
return n * unit;
}
import type { NotificationCategory, NotificationRecord, NotificationService } from "./notifications";
// Obsidian types `moment` as the namespace (not callable); a callable view.
const momentFn = moment as unknown as (...args: unknown[]) => moment.Moment;
interface LogEv { ts: string; type: string; id: string; payload?: any; author?: string; }
export class LogModal extends Modal {
private events: LogEv[] = [];
/** Currently-displayed slice of events (events filtered by typeFilter,
* if set). Pagination + render counts always go through this. */
private visible: LogEv[] = [];
private shownCount = 0;
private typeFilter: string | null = null;
private listEl: HTMLDivElement | null = null;
private footerEl: HTMLDivElement | null = null;
private countEl: HTMLSpanElement | null = null;
private filterSelEl: HTMLSelectElement | null = null;
private static PAGE = 100;
constructor(app: App, private text: string, private jsonlPath: string) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Stashpad log");
this.modalEl.addClass("stashpad-log-modal");
this.events = [];
for (const line of this.text.trim().split(/\r?\n/)) {
if (!line) continue;
try { this.events.push(JSON.parse(line)); } catch { /* ignore */ }
}
this.events.reverse();
const toolbar = this.contentEl.createDiv({ cls: "stashpad-log-toolbar" });
this.countEl = toolbar.createSpan({ cls: "stashpad-log-count" });
this.updateCount();
// Type filter dropdown. Built lazily here, repopulated by
// refreshTypeFilter whenever events change (e.g. after Clear log).
this.filterSelEl = toolbar.createEl("select", { cls: "stashpad-log-type-filter" });
this.filterSelEl.onchange = () => this.setTypeFilter(this.filterSelEl!.value || null);
this.refreshTypeFilter();
// Reveal/Open shell out via electron, which doesn't exist on mobile — only
// offer them on desktop (the Copy button below covers mobile). 0.140.17
if (!Platform.isMobile) {
const revealBtn = toolbar.createEl("button", { text: "Reveal JSONL" });
revealBtn.onclick = () => this.shellAct("reveal");
const openBtn = toolbar.createEl("button", { text: "Open in default app" });
openBtn.onclick = () => this.shellAct("open");
}
const copyBtn = toolbar.createEl("button", { text: "Copy raw JSONL" });
let copyResetTimer: number | null = null;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(this.text);
copyBtn.setText("Copied");
if (copyResetTimer != null) window.clearTimeout(copyResetTimer);
copyResetTimer = window.setTimeout(() => { copyBtn.setText("Copy raw JSONL"); copyResetTimer = null; }, 1500);
};
const exportBtn = toolbar.createEl("button", { text: "Export copy" });
exportBtn.onclick = () => this.exportCopy();
const clearBtn = toolbar.createEl("button", { cls: "mod-warning", text: "Clear log" });
clearBtn.onclick = () => this.clearLog();
this.listEl = this.contentEl.createDiv({ cls: "stashpad-log-list" });
this.refreshList();
this.footerEl = this.contentEl.createDiv({ cls: "stashpad-log-footer" });
this.renderFooter();
}
/** Recompute `visible` from `events` + `typeFilter`, then re-render
* the list from scratch (resetting pagination). */
private refreshList(): void {
if (!this.listEl) return;
this.visible = this.typeFilter
? this.events.filter((ev) => ev.type === this.typeFilter)
: this.events.slice();
this.shownCount = 0;
this.listEl.empty();
if (!this.visible.length) {
this.listEl.createDiv({
cls: "stashpad-log-empty",
text: this.typeFilter ? `No "${this.typeFilter}" events.` : "No events yet.",
});
this.updateCount();
return;
}
this.appendMore(LogModal.PAGE);
}
private setTypeFilter(type: string | null): void {
if ((this.typeFilter ?? null) === (type ?? null)) return;
this.typeFilter = type;
this.refreshList();
this.renderFooter();
}
/** Rebuild the type-filter dropdown options from the current events.
* Called on first render and after Clear log. */
private refreshTypeFilter(): void {
if (!this.filterSelEl) return;
const sel = this.filterSelEl;
sel.empty();
const counts = new Map<string, number>();
for (const ev of this.events) counts.set(ev.type, (counts.get(ev.type) ?? 0) + 1);
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const all = sel.createEl("option", { text: `All types (${this.events.length})` });
all.value = "";
for (const [type, n] of entries) {
const opt = sel.createEl("option", { text: `${type} (${n})` });
opt.value = type;
}
// If the previously-selected filter no longer applies (e.g. its
// type was cleared away), drop it.
if (this.typeFilter && !counts.has(this.typeFilter)) {
this.typeFilter = null;
}
sel.value = this.typeFilter ?? "";
}
private updateCount(): void {
if (!this.countEl) return;
const total = this.visible.length;
const labelTotal = this.typeFilter
? `${total} ${this.typeFilter} event${total === 1 ? "" : "s"}`
: `${total} event${total === 1 ? "" : "s"}`;
if (this.shownCount === 0 || this.shownCount >= total) {
this.countEl.setText(labelTotal);
} else {
this.countEl.setText(`Showing ${this.shownCount} of ${labelTotal}`);
}
}
private appendMore(n: number): void {
if (!this.listEl) return;
const stop = Math.min(this.visible.length, this.shownCount + n);
for (let i = this.shownCount; i < stop; i++) {
this.listEl.appendChild(this.renderEvent(this.visible[i]));
}
this.shownCount = stop;
this.updateCount();
}
private renderFooter(): void {
if (!this.footerEl) return;
this.footerEl.empty();
// Pagination math now runs against the FILTERED set (visible).
const remaining = this.visible.length - this.shownCount;
if (remaining <= 0) return;
const moreBtn = this.footerEl.createEl("button", {
text: `Load ${Math.min(LogModal.PAGE, remaining)} more`,
});
moreBtn.onclick = () => { this.appendMore(LogModal.PAGE); this.renderFooter(); };
if (remaining > LogModal.PAGE) {
const allBtn = this.footerEl.createEl("button", { text: `Load all (${remaining})` });
allBtn.onclick = () => { this.appendMore(remaining); this.renderFooter(); };
}
}
private shellAct(kind: "reveal" | "open"): void {
try {
const full = (this.app.vault.adapter as any).getFullPath?.(this.jsonlPath);
if (!full) throw new Error("no full path");
const { shell } = (window as any).require("electron");
if (kind === "reveal") shell.showItemInFolder(full);
else shell.openPath(full);
} catch (e) {
new Notice(`Couldn't ${kind}: ${(e as Error).message}`);
}
}
/** Write the current log content to <log-dir>/<timestamp>-log.jsonl —
* same directory as the active log so the copy lives next to its
* source. Derived from this.jsonlPath rather than hardcoded. */
private async exportCopy(): Promise<string | null> {
try {
const stamp = (moment as any)().format("YYYY-MM-DD_HHmmss");
const dir = this.jsonlPath.replace(/\/[^/]+$/, "") || "";
const exportPath = dir ? `${dir}/${stamp}-log.jsonl` : `${stamp}-log.jsonl`;
await this.app.vault.adapter.write(exportPath, this.text);
new Notice(`Exported log → ${exportPath}`);
return exportPath;
} catch (e) {
new Notice(`Export failed: ${(e as Error).message}`);
return null;
}
}
private clearLog(): void {
new ConfirmModal(
this.app,
"Clear log?",
"A timestamped copy will be saved alongside the active log before it's truncated.",
"Export & clear",
async (ok) => {
if (!ok) return;
const exported = await this.exportCopy();
if (!exported) return; // bail if export failed; don't risk data loss
try {
await this.app.vault.adapter.write(this.jsonlPath, "");
} catch (e) {
new Notice(`Clear failed: ${(e as Error).message}`);
return;
}
this.text = "";
this.events = [];
this.typeFilter = null;
this.shownCount = 0;
// Rebuild the dropdown so it shows just "All types (0)" again
// and resets to that option.
this.refreshTypeFilter();
// refreshList renders the empty state and resets counts.
this.refreshList();
if (this.footerEl) this.footerEl.empty();
new Notice("Log cleared.");
},
"Cancel",
/*dangerous*/ true,
).open();
}
private renderEvent(ev: LogEv): HTMLElement {
const row = document.createElement("div");
row.className = `stashpad-log-row stashpad-log-${ev.type}`;
const when = (moment as any)(ev.ts).isValid()
? (moment as any)(ev.ts).format("YYYY-MM-DD HH:mm:ss")
: ev.ts;
const ts = document.createElement("span");
ts.className = "stashpad-log-ts";
ts.textContent = when;
row.appendChild(ts);
const type = document.createElement("span");
type.className = "stashpad-log-type";
type.textContent = ev.type;
row.appendChild(type);
// Author column — author of THIS log entry (not the note's author).
// For "create" actions this is the same as the note's author; for
// "parent_change", "rename", "delete", etc., it tells you who
// performed the action regardless of who originally created the
// note. Older log lines without this field render as "—" so the
// column stays aligned.
const author = document.createElement("span");
author.className = "stashpad-log-author";
author.textContent = ev.author ? ev.author : "—";
if (!ev.author) author.classList.add("is-empty");
row.appendChild(author);
const msg = document.createElement("span");
msg.className = "stashpad-log-msg";
msg.textContent = this.describe(ev);
row.appendChild(msg);
return row;
}
private describe(ev: LogEv): string {
const p = ev.payload ?? {};
switch (ev.type) {
case "create": return `Created "${p.path ?? ev.id}" under ${p.parent ?? "?"}`;
case "delete": {
const atts = Array.isArray(p.attachmentsRemoved) ? p.attachmentsRemoved.length : 0;
const merged = p.mergedInto ? ` (merged into ${p.mergedInto})` : "";
return `Deleted ${ev.id}${merged}${atts ? ` — also removed ${atts} attachment${atts === 1 ? "" : "s"}` : ""}`;
}
case "missing": return `Missing: ${p.lastPath ?? ev.id}`;
case "parent_change": {
const reason = p.reason ? ` (${p.reason})` : "";
return `Moved ${ev.id} from ${p.from ?? "null"}${p.to ?? "null"}${reason}`;
}
case "rename": return `Renamed ${p.from ?? "?"}${p.to ?? "?"}`;
case "reorder": {
const n = p.count ?? 1;
const dir = p.dir ?? "?";
const parent = p.parent ?? ev.id ?? "?";
return `Reordered ${n} note${n === 1 ? "" : "s"} (${dir}) under ${parent}`;
}
case "complete": {
const n = p.count ?? 1;
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
return `Marked ${n} note${n === 1 ? "" : "s"} complete${tag}`;
}
case "uncomplete": {
const n = p.count ?? 1;
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
return `Unmarked ${n} note${n === 1 ? "" : "s"}${tag}`;
}
case "stash_export": {
const n = p.noteCount ?? "?";
return `Exported ${n} note${n === 1 ? "" : "s"}${p.path ?? "?"}`;
}
case "stash_import": {
const n = p.noteCount ?? "?";
const extras: string[] = [];
if (p.attachmentsWritten) extras.push(`${p.attachmentsWritten} attachment${p.attachmentsWritten === 1 ? "" : "s"}`);
if (p.collisionsRenamed) extras.push(`${p.collisionsRenamed} renamed`);
const tail = extras.length ? ` (${extras.join(", ")})` : "";
return `Imported ${n} note${n === 1 ? "" : "s"} from ${p.from ?? "?"}${p.into ?? "?"}${tail}`;
}
case "attachment_add": return `Added attachment ${p.path ?? ev.id}`;
case "attachment_remove": return `Removed attachment ${p.path ?? ev.id}`;
case "palette_color_add": return `Added palette color ${p.color ?? ev.id}`;
case "palette_color_remove": return `Removed palette color ${p.color ?? ev.id}`;
default: return JSON.stringify(p);
}
}
onClose(): void { this.contentEl.empty(); }
}
export class ConfirmDeleteModal extends Modal {
constructor(
app: App,
private noteTitle: string,
private descendantCount: number,
private attachmentCount: number,
/** When false, the "Also delete attachments" checkbox is suppressed
* and onChoose is invoked with deleteAttachments=false. Lets the
* caller open the modal purely for descendant/multi-select
* confirmation while honoring the "Offer to delete attachments"
* setting being OFF. */
private offerAttachmentDelete: boolean,
private onChoose: (deleteAttachments: boolean) => void,
) { super(app); }
onOpen(): void {
// 0.76.18: size the modal to its content instead of Obsidian's
// default tall box — the delete confirm is just a sentence + two
// buttons, and the empty space read as oversized on mobile.
this.modalEl?.addClass("stashpad-compact-modal");
this.contentEl.empty();
this.titleEl.setText(`Delete "${this.noteTitle}"?`);
const parts: string[] = [];
if (this.descendantCount > 0) parts.push(`${this.descendantCount} descendant note${this.descendantCount === 1 ? "" : "s"} will also be deleted.`);
if (this.attachmentCount > 0) parts.push(`${this.attachmentCount} attachment${this.attachmentCount === 1 ? "" : "s"} found across these notes.`);
this.contentEl.createEl("p", { text: parts.join(" ") });
let deleteAtts = this.offerAttachmentDelete && this.attachmentCount > 0;
if (this.offerAttachmentDelete && this.attachmentCount > 0) {
const label = this.contentEl.createEl("label", { cls: "stashpad-modal-check" });
const cb = label.createEl("input", { type: "checkbox" });
cb.checked = deleteAtts;
cb.onchange = () => { deleteAtts = cb.checked; };
label.createSpan({ text: " Also delete attachments" });
}
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: "Cancel" });
cancel.onclick = () => this.close();
const del = row.createEl("button", { cls: "mod-warning", text: "Delete" });
del.onclick = () => { this.close(); this.onChoose(deleteAtts); };
}
onClose(): void { this.contentEl.empty(); }
}
export class SplitNoteModal extends Modal {
private lines: string[];
/** Line mode: index of the FIRST line of the second part (1..lines.length-1). */
private lineCursorIdx: number;
private mode: "line" | "cursor" = "line";
private cursorTextarea: HTMLTextAreaElement | null = null;
constructor(
app: App,
private body: string,
private onSplitAtLine: (firstLineOfSecondPart: number) => void,
private onSplitAtChar: (charIndex: number) => void,
private onSplitMany: (parts: string[]) => void,
) {
super(app);
this.lines = body.replace(/\r\n/g, "\n").split("\n");
this.lineCursorIdx = Math.max(1, Math.min(this.lines.length - 1, Math.floor(this.lines.length / 2)));
// Single-line notes can only be split via cursor mode.
if (this.lines.length < 2) this.mode = "cursor";
}
onOpen(): void {
this.titleEl.setText("Split note");
this.modalEl.addClass("stashpad-split-modal");
this.render();
// Tab toggles modes when both are available.
this.scope.register([], "Tab", (e) => {
if (this.lines.length < 2) return;
e.preventDefault();
this.mode = this.mode === "line" ? "cursor" : "line";
this.render();
});
// Enter (no mods): commit in line mode; in cursor mode, let textarea insert a newline.
this.scope.register([], "Enter", (e) => {
if (this.mode !== "line") return; // pass through to textarea
e.preventDefault();
this.commitLine();
});
// Mod+Enter: commit in cursor mode.
this.scope.register(["Mod"], "Enter", (e) => {
if (this.mode !== "cursor") return;
e.preventDefault();
this.commitCursor();
});
// Arrows: in line mode, move the divider. In cursor mode, let textarea handle them.
this.scope.register([], "ArrowUp", (e) => {
if (this.mode !== "line") return;
e.preventDefault();
this.lineCursorIdx = Math.max(1, this.lineCursorIdx - 1);
this.render();
});
this.scope.register([], "ArrowDown", (e) => {
if (this.mode !== "line") return;
e.preventDefault();
this.lineCursorIdx = Math.min(this.lines.length - 1, this.lineCursorIdx + 1);
this.render();
});
}
private commitLine(): void {
const idx = this.lineCursorIdx;
this.close();
this.onSplitAtLine(idx);
}
private commitCursor(): void {
const ta = this.cursorTextarea;
if (!ta) return;
const ch = ta.selectionStart;
if (ch <= 0 || ch >= ta.value.length) {
new Notice("Move the cursor inside the text — neither end can be empty.");
return;
}
this.close();
this.onSplitAtChar(ch);
}
private render(): void {
this.contentEl.empty();
// Quick multi-split: break the whole note into many parts at once, instead
// of placing a single divider. One button per delimiter; disabled when it
// wouldn't yield 2+ parts. The count preview shows how many you'd get.
const quick = this.contentEl.createDiv({ cls: "stashpad-split-quick" });
quick.createSpan({ cls: "stashpad-split-quick-label", text: "Split by:" });
(["lines", "paragraphs", "headings"] as SplitMode[]).forEach((m) => {
const parts = splitIntoChunks(this.body, m);
const btn = quick.createEl("button", {
cls: "stashpad-split-quick-btn",
text: `${SPLIT_MODE_LABELS[m]} (${parts.length})`,
});
btn.disabled = parts.length < 2;
btn.onmousedown = (e) => e.preventDefault();
btn.onclick = () => {
const chunks = splitIntoChunks(this.body, m);
if (chunks.length < 2) { new Notice("That delimiter wouldn't split this note."); return; }
this.close();
this.onSplitMany(chunks);
};
});
// Top bar: mode toggle on the left, Confirm button on the right.
// The confirm button is essential on mobile where Enter is hijacked
// by the textarea (cursor mode) or doesn't have a physical key
// (some on-screen keyboards send "Done" instead).
const bar = this.contentEl.createDiv({ cls: "stashpad-split-toggle-bar" });
if (this.lines.length >= 2) {
const lineBtn = bar.createEl("button", { text: "Line split", cls: "stashpad-split-mode-btn" });
if (this.mode === "line") lineBtn.addClass("is-active");
lineBtn.onclick = () => { this.mode = "line"; this.render(); };
}
const curBtn = bar.createEl("button", { text: "Cursor split", cls: "stashpad-split-mode-btn" });
if (this.mode === "cursor") curBtn.addClass("is-active");
curBtn.onclick = () => { this.mode = "cursor"; this.render(); };
const confirmBtn = bar.createEl("button", {
text: "Split", cls: "stashpad-split-confirm-btn mod-cta",
});
confirmBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
confirmBtn.onclick = () => {
if (this.mode === "line") this.commitLine();
else this.commitCursor();
};
if (this.mode === "line") this.renderLineMode();
else this.renderCursorMode();
const help = this.contentEl.createDiv({ cls: "stashpad-split-help" });
if (Platform.isMobile) {
help.setText(this.mode === "line"
? "Tap a line to position the divider, then Split."
: "Tap inside the text to position the cursor, then Split.");
} else {
help.setText(this.mode === "line"
? "↑/↓ pick split line · Enter or Split confirm · Tab → cursor mode · Esc cancel · Children stay with the first part"
: "Click or arrow to position cursor · Mod+Enter or Split confirm · Tab → line mode · Esc cancel · Children stay with the first part");
}
}
private renderLineMode(): void {
const list = this.contentEl.createDiv({ cls: "stashpad-split-list" });
let divider: HTMLElement | null = null;
for (let i = 0; i < this.lines.length; i++) {
if (i === this.lineCursorIdx) {
divider = list.createDiv({ cls: "stashpad-split-divider", text: "── split here ──" });
}
const ln = list.createDiv({ cls: "stashpad-split-line" });
ln.createSpan({ cls: "stashpad-split-lineno", text: String(i + 1) });
ln.createSpan({ cls: "stashpad-split-text", text: this.lines[i] || " " });
// Tap-to-position: clicking a line puts the divider BELOW it — the
// clicked line ends the first part, so the second part starts at the
// next line (i + 1). Nicer than only arrows on desktop, and the natural
// reading on mobile ("split after this line").
ln.onclick = () => {
const target = Math.max(1, Math.min(this.lines.length - 1, i + 1));
if (target === this.lineCursorIdx) return;
this.lineCursorIdx = target;
this.render();
};
}
// Center the divider in the list after a (re)render so you see context both
// above AND below the split point — and so moving it with ↑/↓ or a click
// doesn't snap scroll to the top and push the divider off-screen.
if (divider) window.requestAnimationFrame(() => divider.scrollIntoView({ block: "center" }));
}
private renderCursorMode(): void {
const wrap = this.contentEl.createDiv({ cls: "stashpad-split-cursor-wrap" });
const ta = wrap.createEl("textarea", { cls: "stashpad-split-cursor-ta" });
ta.value = this.body;
ta.readOnly = false;
this.cursorTextarea = ta;
// Auto-size the textarea to fit content. Cap at 3 lines on mobile,
// 12 lines on desktop. Recomputed on input in case the user edits.
const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 22;
const maxLines = Platform.isMobile ? 3 : 12;
const minLines = 2;
const fit = (): void => {
ta.setCssStyles({ height: "auto" });
const needed = Math.min(ta.scrollHeight, lineHeight * maxLines + 16);
ta.setCssStyles({ height: `${Math.max(needed, lineHeight * minLines + 16)}px` });
};
requestAnimationFrame(() => {
fit();
const mid = Math.floor(ta.value.length / 2);
ta.focus();
ta.setSelectionRange(mid, mid);
});
ta.addEventListener("input", fit);
}
onClose(): void {
this.cursorTextarea = null;
this.contentEl.empty();
}
}
/** Submenu opened from the "+" tile in ColorPickerModal. Lets the user pick
* any hex color, then either save (apply once) or add (apply + persist to
* the palette). Closing without a button applies as "save" (consistent
* with the user's hasty-apply expectation). */
/** 0.84.2: shown before a .stash export runs. Lets the user rename the export
* (the field is prefilled with the auto-generated base name and selected on
* open, so typing overwrites it; native Cmd/Ctrl+Z undo works because we
* don't intercept it). The final on-disk name is `<base>-<timestamp>.stash`
* — the timestamp is appended automatically so reusing a name never clobbers
* a prior export. Returns the chosen base name (sanitized by the caller), or
* nothing if cancelled. */
export class ExportStashModal extends Modal {
private delivered = false;
constructor(
app: App,
private defaultBaseName: string,
private noteCount: number,
private onConfirm: (baseName: string, password: string | null, remember: boolean) => void,
/** Probe (cached) for whether Argon2id can run here, so the modal can state
* up front whether this export will use the strong suite or the fallback.
* Optional so callers/tests can omit it (the text just stays generic). */
private kdfProbe?: () => Promise<boolean>,
) {
super(app);
}
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Export to .stash");
this.modalEl.addClass("stashpad-export-modal");
this.contentEl.createEl("p", {
cls: "stashpad-export-desc",
text: `${this.noteCount} note${this.noteCount === 1 ? "" : "s"} will be bundled into a single .stash file.`,
});
const field = this.contentEl.createDiv({ cls: "stashpad-export-field" });
field.createEl("label", { cls: "stashpad-export-label", text: "File name" });
const input = field.createEl("input", { type: "text" });
input.addClass("stashpad-export-name");
input.value = this.defaultBaseName;
// Live preview of the final on-disk filename (timestamp appended at
// export; "-encrypted" tag added when encryption is on — see below).
const preview = this.contentEl.createEl("div", { cls: "stashpad-export-preview" });
// --- 0.84.3: optional password encryption (opt-in, default off) ---
const encWrap = this.contentEl.createDiv({ cls: "stashpad-export-encrypt" });
const toggleRow = encWrap.createDiv({ cls: "stashpad-export-toggle" });
const cb = toggleRow.createEl("input", { type: "checkbox" });
cb.id = "stashpad-export-encrypt-cb";
const cbLabel = toggleRow.createEl("label", { text: "Encrypt with a password" });
cbLabel.htmlFor = cb.id;
const pwArea = encWrap.createDiv({ cls: "stashpad-export-pw-area" });
pwArea.setCssStyles({ display: "none" });
// 0.85.7: each field gets an inline button on its right; the passphrase
// stays hidden by default (Show reveals it). 0.85.8: the button is
// **Paste** while the field is empty (one-click drop-in from a password
// manager) and flips to **Copy** once it has a value — clearing the field
// flips it back. Copy works while masked.
const pwSyncers: Array<() => void> = [];
const makePwRow = (placeholder: string): HTMLInputElement => {
const row = pwArea.createDiv({ cls: "stashpad-export-pw-row" });
const inp = row.createEl("input", { type: "password" });
inp.addClass("stashpad-export-name"); inp.placeholder = placeholder;
const btn = row.createEl("button", { cls: "stashpad-export-copy" });
const syncBtn = () => {
const empty = inp.value.length === 0;
btn.setText(empty ? "Paste" : "Copy");
btn.toggleClass("is-paste", empty);
btn.setAttr("aria-label", `${empty ? "Paste into" : "Copy"} ${placeholder.toLowerCase()}`);
};
btn.onclick = async (e) => {
e.preventDefault();
if (inp.value.length === 0) {
try {
const txt = (await navigator.clipboard?.readText())?.trim();
if (!txt) { new Notice("Clipboard is empty."); return; }
inp.value = txt;
inp.dispatchEvent(new Event("input")); // → refresh (validation, meter, button sync)
new Notice("Pasted from clipboard.");
} catch { new Notice("Couldn't read the clipboard."); }
} else {
void navigator.clipboard?.writeText(inp.value).then(
() => new Notice("Passphrase copied to clipboard."),
() => new Notice("Couldn't access the clipboard."),
);
}
};
syncBtn();
pwSyncers.push(syncBtn);
return inp;
};
const pw1 = makePwRow("Password");
const pw2 = makePwRow("Confirm password");
// 0.85.4: live strength meter (a nudge, never a gate) + a generate button.
const meter = pwArea.createDiv({ cls: "stashpad-export-strength" });
const meterBar = meter.createDiv({ cls: "stashpad-strength-bar" });
const meterSegs = [0, 1, 2, 3].map(() => meterBar.createDiv({ cls: "stashpad-strength-seg" }));
const meterLabel = meter.createEl("span", { cls: "stashpad-strength-label" });
const genRow = pwArea.createDiv({ cls: "stashpad-export-genrow" });
const genBtn = genRow.createEl("button", { cls: "stashpad-export-gen", text: "Generate strong passphrase" });
const showBtn = genRow.createEl("button", { cls: "stashpad-export-show", text: "Show" });
const hint = pwArea.createEl("div", { cls: "stashpad-export-pw-hint" });
// 0.84.15: name the scheme so it's clear up front. 0.85.3: probe this device
// and state explicitly which suite WILL be used — the strong default or the
// fallback — rather than describing both abstractly.
const suite = pwArea.createEl("div", { cls: "stashpad-export-pw-suite" });
suite.setText("Encryption: AES-256-GCM. Checking key-derivation suite for this device…");
if (this.kdfProbe) {
void this.kdfProbe().then((argonOk) => {
suite.toggleClass("is-weak", !argonOk);
suite.setText(
argonOk
? "Encryption: Argon2id + AES-256-GCM — the strongest suite (used on this device)."
: "⚠️ Argon2id can't run on this device, so this export will use the weaker PBKDF2 (600k) + AES-256-GCM fallback.",
);
}).catch(() => {
// Probe failed unexpectedly — keep the neutral text, don't over-claim.
suite.setText("Encryption: AES-256-GCM with a password-derived key.");
});
} else {
suite.setText("Encryption: Argon2id + AES-256-GCM (falls back to PBKDF2 if Argon2 can't run here).");
}
// 0.85.4: optional "remember in this vault" — saves the passphrase to
// Obsidian's secret storage (OS keychain) keyed by the export filename, so
// re-importing on THIS device skips the prompt. Only offered when the API
// exists (≥1.11.4); secrets are device-local, so recipients still need the
// passphrase typed/copied.
const secretStorage = (this.app as App & { secretStorage?: SecretStorage }).secretStorage;
const rememberRow = pwArea.createDiv({ cls: "stashpad-export-remember" });
const rememberCb = rememberRow.createEl("input", { type: "checkbox" });
rememberCb.id = "stashpad-export-remember-cb";
const rememberLabel = rememberRow.createEl("label", {
text: "Remember in this vault (this device) — skips the prompt when you re-import here.",
});
rememberLabel.htmlFor = rememberCb.id;
// Shown only while "remember" is ticked: make the device-local scope explicit
// so nobody assumes the saved passphrase travels with the file or syncs.
const rememberNote = pwArea.createDiv({ cls: "stashpad-export-remember-note" });
rememberNote.setText(
"Saved only in this device's keychain — it doesn't sync to your other devices and isn't shared with anyone you send this file to. Keep the passphrase somewhere safe if you'll open this export elsewhere.",
);
rememberNote.setCssStyles({ display: "none" });
rememberCb.onchange = () => {
rememberNote.setCssStyles({ display: rememberCb.checked ? "" : "none" });
};
if (!secretStorage) rememberRow.setCssStyles({ display: "none" });
// 0.84.13: encrypted exports get an "-encrypted" tag in the filename so
// secure bundles are identifiable at a glance. The preview reflects it live
// as the checkbox toggles.
const effectiveBase = (): string => {
const b = input.value.trim() || this.defaultBaseName;
return cb.checked ? `${b}-encrypted` : b;
};
const renderPreview = () => preview.setText(`Saves as: ${effectiveBase()}-<timestamp>.stash`);
input.oninput = renderPreview;
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
const cancel = footer.createEl("button", { text: "Cancel" });
cancel.onclick = () => this.close();
const go = footer.createEl("button", { cls: "mod-cta", text: "Export" });
// Gate the Export button on a valid (matching, non-empty) password when
// encryption is enabled. People are warned that a lost password = lost
// export, but the button itself only blocks the typo/empty cases.
const renderStrength = () => {
const s = estimatePasswordStrength(pw1.value);
meter.style.visibility = pw1.value ? "visible" : "hidden";
meterSegs.forEach((seg, i) => seg.toggleClass("is-on", pw1.value !== "" && i <= s.level));
meterBar.dataset.level = String(s.level);
meterLabel.setText(s.label);
};
const refresh = () => {
const enc = cb.checked;
pwArea.setCssStyles({ display: enc ? "" : "none" });
let ok = true;
if (enc) {
if (!pw1.value) { hint.setText("Enter a password to encrypt this export."); hint.removeClass("is-error"); ok = false; }
else if (pw1.value !== pw2.value) { hint.setText("Passwords don't match."); hint.addClass("is-error"); ok = false; }
else { hint.setText("⚠️ If you lose this password, the export can't be recovered."); hint.removeClass("is-error"); }
}
renderStrength();
pwSyncers.forEach((fn) => fn()); // Paste↔Copy per field as values change
go.disabled = !ok;
go.toggleClass("is-disabled", !ok);
renderPreview(); // keep the filename preview in sync with the toggle
};
// Reveal/mask both fields together (so a generated passphrase is readable).
let shown = false;
const setShown = (v: boolean) => {
shown = v;
pw1.type = pw2.type = v ? "text" : "password";
showBtn.setText(v ? "Hide" : "Show");
};
showBtn.onclick = (e) => { e.preventDefault(); setShown(!shown); };
// Generate fills both fields but keeps them HIDDEN (0.85.7): the user
// reveals with Show or grabs it with a Copy button. Save it somewhere — you
// need it to open this export and it can't be recovered if lost.
genBtn.onclick = (e) => {
e.preventDefault();
pw1.value = pw2.value = generatePassphrase();
setShown(false);
new Notice("Passphrase generated (hidden) — Show to view, or Copy to save it.");
refresh();
};
cb.onchange = refresh;
pw1.oninput = refresh;
pw2.oninput = refresh;
refresh();
const deliver = () =>
this.commit(effectiveBase(), cb.checked ? pw1.value : null, cb.checked && rememberCb.checked);
go.onclick = deliver;
// Enter confirms (when not blocked); Esc / click-out cancels (modal
// default). No Mod+Z registration → native input undo handles clears.
this.scope.register([], "Enter", (e) => {
e.preventDefault();
if (!go.disabled) deliver();
});
// Focus + select-all so the prefilled name is ready to type over.
requestAnimationFrame(() => { input.focus(); input.select(); });
}
private commit(raw: string, password: string | null, remember: boolean): void {
const base = raw.trim() || this.defaultBaseName;
const pw = password && password.length ? password : null;
this.delivered = true;
this.close();
this.onConfirm(base, pw, !!pw && remember);
}
onClose(): void {
// No delivery on cancel/Esc/click-out — the export simply doesn't run.
this.contentEl.empty();
}
}
/** Outcome of the encrypted-.stash password prompt. */
export type StashPasswordResult =
| { kind: "password"; value: string }
| { kind: "later" } // reschedule — snooze the auto-import notification
| { kind: "cancel" }; // back out for now (Esc / click-out / Cancel)
/** 0.84.3: prompt for the password of an encrypted .stash on import. `errorMsg`
* shows a retry hint after a wrong password. 0.84.16: when `allowLater` is set
* (the auto-import "Import now" flow), a "Remind me later" button is offered so
* Cancel can mean just "not right now" while "Remind me later" reschedules. */
export class StashPasswordModal extends Modal {
private delivered = false;
constructor(
app: App,
private errorMsg: string | undefined,
private allowLater: boolean,
private onResult: (result: StashPasswordResult) => void,
) {
super(app);
}
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Encrypted .stash");
this.modalEl.addClass("stashpad-export-modal");
this.contentEl.createEl("p", {
cls: "stashpad-export-desc",
text: "This export is password-protected. Enter its password to import it.",
});
if (this.errorMsg) {
this.contentEl.createEl("div", { cls: "stashpad-export-error", text: this.errorMsg });
}
const input = this.contentEl.createEl("input", { type: "password" });
input.addClass("stashpad-export-name");
input.placeholder = "Password";
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
if (this.allowLater) {
const later = footer.createEl("button", { text: "Remind me later" });
later.title = "Don't import now — surface the reminder again later.";
later.onclick = () => this.finish({ kind: "later" });
}
const go = footer.createEl("button", { cls: "mod-cta", text: "Decrypt & import" });
go.onclick = () => this.submit(input.value);
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.submit(input.value); });
requestAnimationFrame(() => input.focus());
}
private submit(pw: string): void {
if (!pw) return; // empty password can't be right; keep the modal open
this.finish({ kind: "password", value: pw });
}
private finish(result: StashPasswordResult): void {
this.delivered = true;
this.close();
this.onResult(result);
}
onClose(): void {
// Esc / click-out / Cancel button → "cancel" (back out for now, no snooze).
if (!this.delivered) { this.delivered = true; this.onResult({ kind: "cancel" }); }
this.contentEl.empty();
}
}
/** 0.97.0 / 0.97.2: set / unlock / change the vault encryption password. One
* modal, three modes. The caller passes an async `onSubmit` that does the actual
* crypto and returns an error string to show IN the modal (keeping it open for a
* retry) or null on success (closes). setup/change also get a generate button,
* a live strength meter + length counter, and an optional "remember in this
* device's keychain" checkbox. */
export type EncryptionPromptMode = "setup" | "unlock" | "change";
export interface EncryptionPromptOpts {
mode: EncryptionPromptMode;
/** Show the "remember in this device's keychain" checkbox (setup/change). */
offerKeychain?: boolean;
/** Probe whether Argon2id runs here, to name the suite. */
kdfProbe?: () => Promise<boolean>;
/** Do the crypto. Return an error string → show in modal + keep open for retry;
* return null → success, close the modal. */
onSubmit: (vals: { current?: string; next?: string; remember: boolean }) => Promise<string | null>;
/** Called if the modal closes without a successful submit (Cancel/Esc). */
onCancel?: () => void;
/** Override the modal title (e.g. "Set shared password"). */
title?: string;
/** Override / add an intro paragraph (replaces the default setup blurb). */
intro?: string;
}
export class EncryptionPasswordModal extends Modal {
private succeeded = false;
constructor(app: App, private opts: EncryptionPromptOpts) { super(app); }
onOpen(): void {
const { mode } = this.opts;
this.contentEl.empty();
this.modalEl.addClass("stashpad-export-modal", "stashpad-encryption-modal");
this.titleEl.setText(
this.opts.title ?? (mode === "setup" ? "Set up encryption password"
: mode === "change" ? "Change encryption password"
: "Unlock encryption"),
);
this.contentEl.createDiv({ cls: "stashpad-beta-row" }).createEl("span", { cls: "stashpad-beta-badge", text: "BETA" });
if (this.opts.intro) {
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: this.opts.intro });
} else if (mode === "setup") {
this.contentEl.createEl("p", {
cls: "stashpad-export-desc",
text: "This single password protects everything you encrypt in this vault. It is stored only on this device. There is NO recovery — if you lose it, anything you've encrypted is gone for good.",
});
}
// Each field gets an inline Copy/Paste button (Paste while empty, Copy once
// it has a value), matching the .stash export modal.
const pwSyncers: Array<() => void> = [];
const field = (placeholder: string): HTMLInputElement => {
const row = this.contentEl.createDiv({ cls: "stashpad-export-pw-row stashpad-encryption-row" });
const i = row.createEl("input", { type: "password" });
i.addClass("stashpad-export-name", "stashpad-encryption-field");
i.placeholder = placeholder;
const btn = row.createEl("button", { cls: "stashpad-export-copy" });
const sync = () => {
const empty = i.value.length === 0;
btn.setText(empty ? "Paste" : "Copy");
btn.toggleClass("is-paste", empty);
btn.setAttr("aria-label", `${empty ? "Paste into" : "Copy"} ${placeholder.toLowerCase()}`);
};
btn.onclick = async (e) => {
e.preventDefault();
if (i.value.length === 0) {
try {
const txt = (await navigator.clipboard?.readText())?.trim();
if (!txt) { new Notice("Clipboard is empty."); return; }
i.value = txt; i.dispatchEvent(new Event("input")); new Notice("Pasted from clipboard.");
} catch { new Notice("Couldn't read the clipboard."); }
} else {
void navigator.clipboard?.writeText(i.value).then(
() => new Notice("Copied to clipboard."),
() => new Notice("Couldn't access the clipboard."),
);
}
};
i.addEventListener("input", sync);
sync();
pwSyncers.push(sync);
return i;
};
let currentEl: HTMLInputElement | null = null;
let nextEl: HTMLInputElement | null = null;
let confirmEl: HTMLInputElement | null = null;
if (mode === "unlock" || mode === "change") currentEl = field("Current password");
if (mode === "setup" || mode === "change") {
nextEl = field(mode === "change" ? "New password" : "Password");
confirmEl = field("Confirm password");
}
// setup/change extras: strength meter + length counter, generate + show, suite.
if (nextEl) {
const meter = this.contentEl.createDiv({ cls: "stashpad-export-strength" });
const bar = meter.createDiv({ cls: "stashpad-strength-bar" });
const segs = [0, 1, 2, 3].map(() => bar.createDiv({ cls: "stashpad-strength-seg" }));
const label = meter.createEl("span", { cls: "stashpad-strength-label" });
const counter = this.contentEl.createDiv({ cls: "stashpad-encryption-counter" });
const refresh = () => {
const v = nextEl.value;
const s = estimatePasswordStrength(v);
segs.forEach((seg, i) => seg.toggleClass("is-on", i < s.level));
label.setText(v ? s.label : "");
counter.setText(`${v.length} character${v.length === 1 ? "" : "s"}${v.length > 0 && v.length < 6 ? " — use at least 6" : ""}`);
counter.toggleClass("is-weak", v.length > 0 && v.length < 6);
};
nextEl.addEventListener("input", refresh);
refresh();
const genRow = this.contentEl.createDiv({ cls: "stashpad-export-genrow" });
genRow.createEl("button", { cls: "stashpad-export-gen", text: "Generate strong passphrase" }).onclick = (e) => {
e.preventDefault();
const pw = generatePassphrase(5);
nextEl.value = pw;
if (confirmEl) confirmEl.value = pw;
// Keep it masked — the user reveals with Show if they want; it's already
// copied to the clipboard below, and Copy works while masked.
refresh();
pwSyncers.forEach((s) => s()); // flip Copy/Paste buttons
new Notice("Generated — copy it somewhere safe; there's no recovery.");
void navigator.clipboard?.writeText(pw).catch(() => {});
};
const showBtn = genRow.createEl("button", { cls: "stashpad-export-show", text: "Show" });
showBtn.onclick = (e) => {
e.preventDefault();
const show = nextEl.type === "password";
nextEl.type = show ? "text" : "password";
if (confirmEl) confirmEl.type = show ? "text" : "password";
showBtn.setText(show ? "Hide" : "Show");
};
const suite = this.contentEl.createEl("div", { cls: "stashpad-export-pw-suite" });
suite.setText("Encryption: AES-256-GCM. Checking key-derivation suite…");
if (this.opts.kdfProbe) {
void this.opts.kdfProbe().then((ok) => {
suite.toggleClass("is-weak", !ok);
suite.setText(ok
? "Encryption: Argon2id + AES-256-GCM — the strongest suite (used on this device)."
: "⚠️ Argon2id can't run here, so this will use the weaker PBKDF2 (600k) + AES-256-GCM fallback.");
}).catch(() => suite.setText("Encryption: AES-256-GCM with a password-derived key."));
} else {
suite.setText("Encryption: Argon2id + AES-256-GCM (PBKDF2 fallback if Argon2 can't run here).");
}
}
// Optional: remember in this device's keychain.
let rememberCb: HTMLInputElement | null = null;
const secretStorage = (this.app as App & { secretStorage?: SecretStorage }).secretStorage;
if (this.opts.offerKeychain && secretStorage) {
const row = this.contentEl.createDiv({ cls: "stashpad-export-remember" });
rememberCb = row.createEl("input", { type: "checkbox" });
rememberCb.id = "stashpad-enc-remember";
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.");
note.setCssStyles({ display: "none" });
rememberCb.onchange = () => { note.setCssStyles({ display: rememberCb!.checked ? "" : "none" }); };
}
const errEl = this.contentEl.createEl("div", { cls: "stashpad-export-error" });
errEl.setCssStyles({ display: "none" });
const showErr = (m: string) => { errEl.setText(m); errEl.setCssStyles({ display: "" }); };
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
const go = footer.createEl("button", {
cls: "mod-cta",
text: mode === "setup" ? "Set up" : mode === "change" ? "Change" : "Unlock",
});
let busy = false;
const submit = async () => {
if (busy) return;
const current = currentEl?.value ?? undefined;
const next = nextEl?.value ?? undefined;
if ((mode === "unlock" || mode === "change") && !current) { showErr("Enter your current password."); return; }
if (mode === "setup" || mode === "change") {
if (!next) { showErr("Enter a password."); return; }
if (next.length < 6) { showErr("Use at least 6 characters."); return; }
if (next !== confirmEl?.value) { showErr("Passwords don't match."); return; }
}
busy = true; go.disabled = true; errEl.setCssStyles({ display: "none" });
const prevLabel = go.textContent;
go.setText("Working…");
try {
const err = await this.opts.onSubmit({ current, next, remember: !!rememberCb?.checked });
if (err) { showErr(err); busy = false; go.disabled = false; go.setText(prevLabel ?? "OK"); return; }
this.succeeded = true;
this.close(); // success
} catch (e) {
showErr(`Failed: ${(e as Error).message}`);
busy = false; go.disabled = false; go.setText(prevLabel ?? "OK");
}
};
go.onclick = () => void submit();
this.scope.register([], "Enter", (e) => { e.preventDefault(); void submit(); });
requestAnimationFrame(() => (currentEl ?? nextEl)?.focus());
}
onClose(): void {
this.contentEl.empty();
if (!this.succeeded) this.opts.onCancel?.();
}
}
/** 0.97.2: a destructive-action confirm that requires typing an exact phrase
* (game/GitHub-style) before the action button enables. */
export class TypeToConfirmModal extends Modal {
constructor(
app: App,
private opts: {
title: string; body: string; phrase: string; confirmText: string;
/** When set, the user must ALSO enter a password that this verifies before
* the action runs (proves they know it, not just that the session's open). */
requirePassword?: (pw: string) => Promise<boolean>;
onConfirm: () => void | Promise<void>;
},
) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.modalEl.addClass("stashpad-export-modal", "stashpad-encryption-modal");
this.titleEl.setText(this.opts.title);
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: this.opts.body });
let pwInput: HTMLInputElement | null = null;
if (this.opts.requirePassword) {
this.contentEl.createEl("p", { cls: "stashpad-export-desc" }).setText("Enter your encryption password:");
pwInput = this.contentEl.createEl("input", { type: "password" });
pwInput.addClass("stashpad-export-name", "stashpad-encryption-field");
pwInput.placeholder = "Password";
}
this.contentEl.createEl("p", { cls: "stashpad-export-desc" }).setText(`Type "${this.opts.phrase}" to confirm.`);
const input = this.contentEl.createEl("input", { type: "text" });
input.addClass("stashpad-export-name", "stashpad-encryption-field");
input.placeholder = this.opts.phrase;
const errEl = this.contentEl.createEl("div", { cls: "stashpad-export-error" });
errEl.setCssStyles({ display: "none" });
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
const go = footer.createEl("button", { cls: "mod-cta mod-warning", text: this.opts.confirmText });
// Phrase match is case-INSENSITIVE (typing "remove encryption" is as good as
// "REMOVE ENCRYPTION") — the phrase is a speed-bump, not a secret. The
// password (when required) is still matched exactly by requirePassword().
const phraseOk = () => input.value.trim().toLowerCase() === this.opts.phrase.trim().toLowerCase();
const sync = () => { go.disabled = !phraseOk() || (!!this.opts.requirePassword && !pwInput?.value); };
input.addEventListener("input", sync);
pwInput?.addEventListener("input", sync);
sync();
let busy = false;
const run = async () => {
if (busy || !phraseOk()) return;
if (this.opts.requirePassword) {
busy = true; go.disabled = true; errEl.setCssStyles({ display: "none" });
const ok = await this.opts.requirePassword(pwInput!.value);
if (!ok) { errEl.setText("Wrong password."); errEl.setCssStyles({ display: "" }); busy = false; sync(); return; }
}
this.close();
await this.opts.onConfirm();
};
go.onclick = () => void run();
this.scope.register([], "Enter", (e) => { e.preventDefault(); void run(); });
requestAnimationFrame(() => (pwInput ?? input).focus());
}
onClose(): void { this.contentEl.empty(); }
}
export class CustomColorModal extends Modal {
private value: string;
private delivered = false;
/** Whether the user actually changed the color. A dismiss (Esc/click-out)
* without touching anything must NOT apply the seeded default. (0.140.6) */
private touched = false;
constructor(
app: App,
seed: string | null,
private onResult: (color: string | null, opts: { addToPalette: boolean }) => void,
) {
super(app);
this.value = seed && /^#[0-9a-f]{6}$/i.test(seed) ? seed : "#888888";
}
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Custom color");
this.modalEl.addClass("stashpad-custom-color-modal");
const row = this.contentEl.createDiv({ cls: "stashpad-custom-color-row" });
const preview = row.createDiv({ cls: "stashpad-custom-color-preview" });
preview.style.background = this.value;
// Native wheel — clicking the preview pops the OS color picker.
const wheel = row.createEl("input", { type: "color" });
wheel.value = this.value;
wheel.addClass("stashpad-custom-color-wheel");
preview.onclick = () => wheel.click();
// Hex text input for direct entry. Synced both ways with the wheel.
const hex = row.createEl("input", { type: "text" });
hex.addClass("stashpad-custom-color-hex");
hex.placeholder = "#RRGGBB";
hex.value = this.value;
hex.maxLength = 7;
const sync = (next: string) => {
const v = next.startsWith("#") ? next : "#" + next;
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
this.touched = true;
this.value = v;
preview.style.background = v;
wheel.value = v;
if (hex.value !== v) hex.value = v;
};
wheel.oninput = () => sync(wheel.value);
hex.oninput = () => sync(hex.value);
const footer = this.contentEl.createDiv({ cls: "stashpad-color-footer" });
const cancel = footer.createEl("button", { text: "Cancel" });
cancel.onclick = () => { this.delivered = true; this.close(); };
const save = footer.createEl("button", { cls: "mod-cta", text: "Save" });
save.title = "Apply this color to the selection (without adding to your palette).";
save.onclick = () => this.commit({ addToPalette: false });
const add = footer.createEl("button", { cls: "mod-cta", text: "Add to palette" });
add.title = "Apply and save this color so it appears as a tile next time.";
add.onclick = () => this.commit({ addToPalette: true });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: false }); });
this.scope.register(["Mod"], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: true }); });
requestAnimationFrame(() => hex.focus());
}
private commit(opts: { addToPalette: boolean }): void {
this.delivered = true;
this.close();
this.onResult(this.value, opts);
}
onClose(): void {
if (!this.delivered && this.touched) {
// Click-out / Esc AFTER changing the color → apply hastily (no palette
// persistence), matching the user's "skip adding" intent. An UNtouched
// dismiss is a cancel — don't paint the seeded default over the note.
this.delivered = true;
this.onResult(this.value, { addToPalette: false });
}
this.contentEl.empty();
}
}
/** Compact color picker. Presents a grid of preset swatches and the user's
* saved custom palette, plus a "+" tile that opens CustomColorModal for
* arbitrary hex picking. The "no color" tile (slash) clears the assignment.
*
* Behavior:
* - Arrow keys navigate the grid; Enter activates the highlighted tile.
* For preset/saved tiles that means apply-and-close. For the "+" tile
* it opens the custom-color submenu (this modal stays open behind it).
* - There is no Save button on this modal — clicking a preset is the
* explicit "apply" action.
*/
export class ColorPickerModal extends Modal {
static DEFAULT_PALETTE: string[] = [
"#E07A78", "#E08A47", "#E0A744", "#B0CC6E", "#6BC07A",
"#5BA9CE", "#9B82C9", "#C57AB5", "#D75AA8",
];
/** Active grid index for keyboard nav. -1 = no focus yet. */
private focusIdx = -1;
/** Guard so re-rendering the grid (handleDelete → onOpen) doesn't stack a
* second copy of the keyboard handlers on the persistent scope. (0.140.6) */
private scopeBound = false;
/** Snapshot of selectable tiles in render order. The "+" tile is `kind:
* "add"` and opens CustomColorModal rather than committing directly. */
private items: { kind: "none" | "preset" | "custom" | "add"; color: string | null; el: HTMLElement }[] = [];
constructor(
app: App,
private currentColor: string | null,
private customPalette: string[],
private onPick: (color: string | null, opts: { addToPalette?: boolean }) => void,
/** Optional: invoked when the user clicks the ✕ on a saved tile.
* The host should remove the color from settings.customPalette and log
* the deletion. Returning a fresh palette array updates this modal in
* place so the user can keep deleting. */
private onDeleteCustom?: (color: string) => Promise<string[]> | string[],
) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Pick a color");
this.modalEl.addClass("stashpad-color-modal");
const grid = this.contentEl.createDiv({ cls: "stashpad-color-grid" });
this.items = [];
// "No color" tile (slash).
const noTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-none" });
noTile.title = "No color";
noTile.onclick = () => this.activate(0);
this.items.push({ kind: "none", color: null, el: noTile });
const allPresets = [...ColorPickerModal.DEFAULT_PALETTE, ...this.customPalette];
for (const c of allPresets) {
const tile = grid.createDiv({ cls: "stashpad-color-tile" });
tile.style.background = c;
tile.title = c;
const isCustom = !ColorPickerModal.DEFAULT_PALETTE.some((d) => d.toLowerCase() === c.toLowerCase());
const idx = this.items.length;
tile.onclick = () => this.activate(idx);
// Saved-custom tiles get a ✕ that deletes them from the persisted
// palette. Defaults aren't deletable.
if (isCustom && this.onDeleteCustom) {
const del = tile.createSpan({ cls: "stashpad-color-tile-del", text: "×" });
del.title = "Remove from palette";
del.onclick = (e) => {
e.stopPropagation();
void this.handleDelete(c);
};
}
this.items.push({ kind: isCustom ? "custom" : "preset", color: c, el: tile });
}
// "+" tile that opens the custom-color submenu.
const addTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-add" });
addTile.title = "Custom color…";
const addIdx = this.items.length;
addTile.onclick = () => this.activate(addIdx);
this.items.push({ kind: "add", color: null, el: addTile });
// Initial focus: the tile matching currentColor, else the first preset.
const seedIdx = this.items.findIndex((it) => {
if (this.currentColor === null) return it.kind === "none";
if (!this.currentColor) return false;
return it.color !== null && it.color.toLowerCase() === this.currentColor.toLowerCase();
});
this.focusIdx = seedIdx >= 0 ? seedIdx : 0;
this.refreshActive();
// Keyboard nav: arrows move focus; Enter activates. Registered once — the
// handlers read live this.focusIdx/this.items, so they survive a grid
// rebuild; re-registering (handleDelete → onOpen) would stack duplicates.
if (!this.scopeBound) {
this.scope.register([], "ArrowRight", (e) => { e.preventDefault(); this.moveFocus(1); });
this.scope.register([], "ArrowLeft", (e) => { e.preventDefault(); this.moveFocus(-1); });
this.scope.register([], "ArrowDown", (e) => { e.preventDefault(); this.moveFocus(this.columns()); });
this.scope.register([], "ArrowUp", (e) => { e.preventDefault(); this.moveFocus(-this.columns()); });
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.activate(this.focusIdx); });
this.scopeBound = true;
}
// After paint, focus the modal so arrow keys land here, not in the
// background view.
requestAnimationFrame(() => (this.modalEl).focus());
}
/** Click or Enter on a tile. Preset/saved/none → apply immediately + close.
* Add → open the custom-color submenu (this modal hands off and closes). */
private activate(i: number): void {
if (i < 0 || i >= this.items.length) return;
this.focusIdx = i;
this.refreshActive();
const it = this.items[i];
if (it.kind === "add") {
// Hand off to the custom submenu. We close this modal first so we
// don't stack two on top of each other; the submenu fires onPick.
this.close();
new CustomColorModal(this.app, this.currentColor, (color, opts) => {
this.onPick(color, opts);
}).open();
return;
}
this.close();
this.onPick(it.color, { addToPalette: false });
}
/** Approximate column count from the rendered grid (for vertical arrows). */
private columns(): number {
if (!this.items.length) return 1;
const grid = this.items[0].el.parentElement;
if (!grid) return 1;
const gridRect = grid.getBoundingClientRect();
const tileRect = this.items[0].el.getBoundingClientRect();
if (tileRect.width <= 0) return 1;
// Rough: total width / (tile + gap). We use the displayed positions to
// count tiles in the first row instead — more robust to gap rounding.
const firstTop = tileRect.top;
let cols = 0;
for (const it of this.items) {
const r = it.el.getBoundingClientRect();
if (Math.abs(r.top - firstTop) < 1) cols++;
else break;
}
void gridRect;
return Math.max(1, cols);
}
private async handleDelete(color: string): Promise<void> {
if (!this.onDeleteCustom) return;
try {
const next = await this.onDeleteCustom(color);
// Re-render the grid in place with the updated palette so the user
// can keep tidying.
this.customPalette = Array.isArray(next) ? next : this.customPalette.filter((c) => c.toLowerCase() !== color.toLowerCase());
this.onOpen();
} catch (e) {
// Swallow: host can show its own notice.
console.warn("Stashpad: palette delete failed", e);
}
}
private moveFocus(delta: number): void {
if (!this.items.length) return;
const next = Math.max(0, Math.min(this.items.length - 1, this.focusIdx + delta));
this.focusIdx = next;
this.refreshActive();
}
private refreshActive(): void {
for (let i = 0; i < this.items.length; i++) {
this.items[i].el.toggleClass("is-active", i === this.focusIdx);
}
}
onClose(): void { this.contentEl.empty(); }
}
export class ConfirmModal extends Modal {
/** Tracks whether the user made an explicit choice via the button
* row. If the modal closes any other way (Escape, click on the
* background overlay), onClose treats it as Cancel so callers
* don't hang waiting for a choice. */
private didChoose = false;
constructor(
app: App,
private titleText: string,
private message: string,
private confirmText: string,
private onChoose: (confirmed: boolean) => void,
private cancelText: string = "Cancel",
/** When true (destructive confirms like "Clear log"), focus Cancel instead
* of Confirm so a stray Enter doesn't fire the irreversible action. */
private dangerous: boolean = false,
) { super(app); }
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
this.contentEl.empty();
this.titleEl.setText(this.titleText);
// 0.63.2: render each newline-separated sentence on its own line.
// Earlier the entire message was crammed into one <p> which
// collapsed newlines into single spaces — callers passing
// multi-sentence prose lost the formatting.
const block = this.contentEl.createDiv({ cls: "stashpad-confirm-body" });
for (const line of this.message.split("\n")) {
block.createDiv({ cls: "stashpad-confirm-line", text: line });
}
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const cancel = row.createEl("button", { text: this.cancelText });
cancel.onclick = () => { this.didChoose = true; this.close(); this.onChoose(false); };
const ok = row.createEl("button", { cls: "mod-cta", text: this.confirmText });
ok.onclick = () => { this.didChoose = true; this.close(); this.onChoose(true); };
// Focus the confirm button so Enter accepts — but for destructive confirms
// focus Cancel instead, so a stray Enter cancels rather than fires. (0.140.6)
requestAnimationFrame(() => (this.dangerous ? cancel : ok).focus());
}
onClose(): void {
this.contentEl.empty();
// If the modal closed without an explicit Cancel/Confirm (e.g.
// user clicked the background overlay or pressed Escape), treat
// as Cancel so callers don't hang.
if (!this.didChoose) {
this.didChoose = true;
this.onChoose(false);
}
}
}
/** 0.117.0: breadcrumb "all levels" picker. The breadcrumb row squishes
* (and clips its rightmost crumbs) when the pane is narrow or the path is
* deep, so this modal lists EVERY level top-to-bottom — full titles, no
* truncation — and lets the user jump to any one. Mobile-responsive: rows
* are full-width tap targets; indentation conveys depth. The current level
* is marked and non-navigating. */
export interface BreadcrumbLevel {
id: string;
/** Full, untruncated title for this level. */
label: string;
/** 0 = Home; 1..n = depth down the path. Drives the indent. */
level: number;
isCurrent: boolean;
isHome?: boolean;
}
export interface BreadcrumbLevelsModalOpts {
/** Open the same context menu the inline crumbs use, for a given level.
* `evt` is the MouseEvent for right-click, or null for long-press;
* `anchorEl` is the row (long-press anchor); `close` dismisses the modal. */
onContext?: (id: string, evt: MouseEvent | null, anchorEl: HTMLElement, close: () => void) => void;
/** Mobile long-press wiring (the view's attachLongPress). */
attachLongPress?: (el: HTMLElement, cb: () => void) => void;
}
export class BreadcrumbLevelsModal extends Modal {
constructor(
app: App,
private levels: BreadcrumbLevel[],
private onPick: (id: string) => void,
private opts: BreadcrumbLevelsModalOpts = {},
) { super(app); }
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal");
this.modalEl?.addClass("stashpad-breadcrumb-modal");
this.contentEl.empty();
this.titleEl.setText("Jump to level");
const list = this.contentEl.createDiv({ cls: "stashpad-bc-levels" });
for (const lvl of this.levels) {
const row = list.createDiv({ cls: "stashpad-bc-level-row" });
if (lvl.isCurrent) row.addClass("is-current");
// Flat, left-aligned rows (no indentation) so deep levels stay
// full-width on a narrow phone. Depth is conveyed by a leading level
// number — Home is 0, then 1..n down the path.
row.createSpan({ cls: "stashpad-bc-level-num", text: String(lvl.level) });
row.createSpan({ cls: "stashpad-bc-level-label", text: lvl.label });
// The current level is shown by its accent number chip + highlighted
// row (no "current" text label — it just padded the width).
if (!lvl.isCurrent) {
row.setAttribute("role", "button");
row.setAttribute("tabindex", "0");
const go = (): void => { this.close(); this.onPick(lvl.id); };
row.onclick = go;
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } };
}
// Context-menu parity with the inline crumbs — every row (incl. the
// current one) gets right-click + long-press → the same crumb menu.
if (this.opts.onContext) {
row.oncontextmenu = (evt) => {
evt.preventDefault();
this.opts.onContext!(lvl.id, evt, row, () => this.close());
};
this.opts.attachLongPress?.(row, () => this.opts.onContext!(lvl.id, null, row, () => this.close()));
}
}
}
onClose(): void { this.contentEl.empty(); }
}
/** 0.76.1: pick a due date + time for a task. Uses native
* <input type="date"> + <input type="time"> so mobile gets the OS
* date/time pickers for free. Quick-preset buttons (Today, Tomorrow,
* Next week) fill the inputs without typing. Returns an ISO string
* (date + time, local) or null to clear the due date. The callback
* receives `undefined` if the modal was dismissed without choosing. */
/** 0.78.3: shared assignee-picker widget — removable chips + an
* autocomplete that Sift-matches known authors and accepts free entry
* ("Create 'Name'" → mints a fresh author id). Maintains its own working
* list and calls onChange(list) on every mutation. Used by both the due
* picker's "Assign to" section and the standalone AssignModal. */
export function buildAssigneePicker(
wrap: HTMLElement,
opts: { knownAuthors: AssigneeRef[]; initial: AssigneeRef[]; onChange: (list: AssigneeRef[]) => void },
): void {
let assignees: AssigneeRef[] = [...opts.initial];
const known = opts.knownAuthors;
const sec = wrap.createDiv({ cls: "stashpad-assign" });
sec.createDiv({ cls: "stashpad-assign-label", text: "Assign to" });
const chips = sec.createDiv({ cls: "stashpad-assign-chips" });
const inputWrap = sec.createDiv({ cls: "stashpad-assign-input-wrap" });
const input = inputWrap.createEl("input", {
type: "text", cls: "stashpad-assign-input",
attr: { placeholder: "Add a person — type a name…" },
});
const sugg = inputWrap.createDiv({ cls: "stashpad-assign-suggest" });
sugg.setCssStyles({ display: "none" });
const commit = () => opts.onChange([...assignees]);
const renderChips = (): void => {
chips.empty();
if (assignees.length === 0) chips.createSpan({ cls: "stashpad-assign-empty", text: "No one yet" });
for (const a of assignees) {
const chip = chips.createSpan({ cls: "stashpad-assign-chip" });
chip.createSpan({ cls: "stashpad-assign-chip-name", text: a.name });
const x = chip.createSpan({ cls: "stashpad-assign-chip-x", text: "×" });
x.title = `Remove ${a.name}`;
x.onclick = () => { assignees = assignees.filter((p) => p.id !== a.id); commit(); renderChips(); };
}
};
const addAssignee = (a: AssigneeRef): void => {
if (!a.name.trim()) return;
if (!assignees.some((p) => p.id === a.id)) assignees.push(a);
input.value = ""; sugg.setCssStyles({ display: "none" }); commit(); renderChips(); input.focus();
};
const refresh = (): void => {
const q = input.value.trim();
sugg.empty();
const taken = new Set(assignees.map((p) => p.id));
const matches = known.filter((a) => !taken.has(a.id) && siftMatch(q, a.name)).slice(0, 6);
const rows: Array<{ label: string; onPick: () => void }> = matches.map((a) => ({
label: a.name, onPick: () => addAssignee(a),
}));
if (q && !known.some((a) => a.name.toLowerCase() === q.toLowerCase())) {
rows.push({ label: `Create “${q}`, onPick: () => addAssignee({ id: newId(6), name: q }) });
}
if (rows.length === 0) { sugg.setCssStyles({ display: "none" }); return; }
sugg.setCssStyles({ display: "" });
for (const r of rows) {
const item = sugg.createDiv({ cls: "stashpad-assign-suggest-item", text: r.label });
item.onmousedown = (e) => { e.preventDefault(); r.onPick(); };
}
};
input.addEventListener("input", refresh);
input.addEventListener("focus", refresh);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const first = sugg.querySelector(".stashpad-assign-suggest-item");
if (first) first.dispatchEvent(new MouseEvent("mousedown"));
} else if (e.key === "Escape" && sugg.style.display !== "none") {
e.stopPropagation(); sugg.setCssStyles({ display: "none" });
}
});
input.addEventListener("blur", () => { window.setTimeout(() => { sugg.setCssStyles({ display: "none" }); }, 120); });
renderChips();
}
/** 0.78.3: standalone "Assign to" modal — assignment without touching the
* due date. onPick gets the chosen assignee set; not called on dismiss. */
export class DueDatePickerModal extends Modal {
private didChoose = false;
/** Working set of assignees, mutated by the chips UI. */
private assignees: AssigneeRef[] = [];
constructor(
app: App,
/** Existing due value (ISO) to pre-fill, or null/undefined. */
private current: string | null | undefined,
/** Called with the chosen due ISO (or null to clear) AND the chosen
* assignee set. Not called on dismiss-without-choice. Back-compat:
* a caller may still pass a `(iso) => void` — we detect arity and
* adapt — but new callers should take the DuePickResult. */
private onPick: (result: DuePickResult) => void,
private opts: DuePickerOptions = {},
) {
super(app);
this.assignees = [...(opts.currentAssignees ?? [])];
}
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
this.contentEl.empty();
this.titleEl.setText(this.opts.title ?? "Set due date");
// Pre-fill from the current value when parseable.
let initial: Date | null = null;
if (this.current) {
const t = Date.parse(this.current);
if (!Number.isNaN(t)) initial = new Date(t);
}
const wrap = this.contentEl.createDiv({ cls: "stashpad-due-picker" });
// 0.76.5: each field is [leading icon] + input, constrained width
// (the native inputs default to absurdly wide). Date row gets a
// calendar icon; time row gets a clock icon at its START.
const fields = wrap.createDiv({ cls: "stashpad-due-fields" });
const dateField = fields.createDiv({ cls: "stashpad-due-field" });
const dateIcon = dateField.createSpan({ cls: "stashpad-due-field-icon" });
setIcon(dateIcon, "calendar");
const dateInput = dateField.createEl("input", { type: "date", cls: "stashpad-due-date" });
// 0.104.x: dedicated × to clear this field independently.
const dateClear = dateField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear date" } });
setIcon(dateClear, "x");
dateClear.onclick = () => { dateInput.value = ""; dateInput.focus(); };
const timeField = fields.createDiv({ cls: "stashpad-due-field" });
const timeIcon = timeField.createSpan({ cls: "stashpad-due-field-icon" });
setIcon(timeIcon, "clock");
const timeInput = timeField.createEl("input", { type: "time", cls: "stashpad-due-time" });
const timeClear = timeField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear time" } });
setIcon(timeClear, "x");
timeClear.onclick = () => { timeInput.value = ""; timeInput.focus(); };
// 0.76.8: the leading icon IS the picker button. The native
// ::-webkit-calendar-picker-indicator (on the input's right) is
// hidden via CSS; clicking our left icon opens the OS picker via
// showPicker(). Wrapped in try — showPicker throws outside a user
// gesture or on platforms that lack it (the input is still
// directly editable / clickable as a fallback).
dateIcon.onclick = () => { try { (dateInput as any).showPicker?.(); } catch { /* noop */ } };
// 0.76.23: the clock opens Stashpad's numpad time picker (the same
// control as the search When-builder) instead of the OS time
// picker — consistent UX + works the same everywhere. The time
// input stays directly editable too.
timeIcon.onclick = () => this.openTimeNumpad(timeIcon, timeInput);
if (initial) {
dateInput.value = this.toDateValue(initial);
timeInput.value = this.toTimeValue(initial);
}
// 0.125.1: quick relative adjust row — a +/- flip toggle plus one button per
// configured preset. Clicking nudges the entered date+time by ±amount; if no
// date/time is entered yet, it bases off "now" so a single tap schedules
// e.g. "+1h from now". Reschedule-friendly for Snooze.
const adjusts = (this.opts.quickAdjusts ?? [])
.map((s) => ({ raw: s, min: parseAdjustMinutes(s) }))
.filter((a): a is { raw: string; min: number } => a.min != null);
if (adjusts.length > 0) {
let sign = 1; // +1 add, -1 subtract
const row = wrap.createDiv({ cls: "stashpad-due-quickadjust" });
const flip = row.createEl("button", { cls: "stashpad-due-adjust-flip", attr: { type: "button" } });
const syncFlip = (): void => {
flip.setText(sign > 0 ? "+" : "");
flip.toggleClass("is-minus", sign < 0);
flip.title = sign > 0 ? "Adding time (click to subtract)" : "Subtracting time (click to add)";
};
syncFlip();
flip.onclick = () => { sign = -sign; syncFlip(); };
const adjustBy = (deltaMin: number): void => {
// Base: entered date+time, else today/now filled in for the missing part.
const now = new Date();
let y: number, mo: number, d: number;
if (dateInput.value) { const [yy, mm, dd] = dateInput.value.split("-").map((n) => parseInt(n, 10)); y = yy; mo = mm - 1; d = dd; }
else { y = now.getFullYear(); mo = now.getMonth(); d = now.getDate(); }
let hh: number, mi: number;
if (timeInput.value) { const [h, m] = timeInput.value.split(":").map((n) => parseInt(n, 10)); hh = h; mi = m; }
else { hh = now.getHours(); mi = now.getMinutes(); }
const base = new Date(y, mo, d, hh, mi, 0, 0);
base.setMinutes(base.getMinutes() + sign * deltaMin);
dateInput.value = this.toDateValue(base);
timeInput.value = this.toTimeValue(base);
};
for (const a of adjusts) {
const b = row.createEl("button", { cls: "stashpad-due-adjust-btn", text: a.raw, attr: { type: "button" } });
b.onclick = () => adjustBy(a.min);
}
}
// 0.78.1: "Assign to" section — chips for current assignees + an
// autocomplete input to add known authors (Sift) or a free-entry name
// (mints a new author id). Multiple assignees supported.
// 0.125.0: Snooze passes hideAssignees — it only reschedules, so omit it.
if (!this.opts.hideAssignees) this.renderAssignSection(wrap);
// 0.76.5: presets (top row) + actions (bottom row) share ONE
// 3-column grid so the six buttons line up in two tidy rows.
const grid = wrap.createDiv({ cls: "stashpad-due-grid" });
const addPreset = (label: string, build: () => Date) => {
const b = grid.createEl("button", { cls: "stashpad-due-btn stashpad-due-preset", text: label });
b.onclick = () => {
const d = build();
dateInput.value = this.toDateValue(d);
if (!timeInput.value) timeInput.value = this.toTimeValue(d);
};
};
const atNine = (d: Date): Date => { d.setHours(9, 0, 0, 0); return d; };
addPreset("Today", () => atNine(this.startOfTodayLocal()));
addPreset("Tomorrow", () => { const d = this.startOfTodayLocal(); d.setDate(d.getDate() + 1); return atNine(d); });
addPreset("Next week", () => { const d = this.startOfTodayLocal(); d.setDate(d.getDate() + 7); return atNine(d); });
// 0.76.22: "Clear" only empties the fields and stays open — so you
// can clear a misapplied date and pick a new one without
// re-opening. To actually REMOVE the due, clear then Set (empty
// Set commits null). To keep the existing due, Cancel.
const clear = grid.createEl("button", { cls: "stashpad-due-btn", text: "Clear" });
clear.onclick = () => {
dateInput.value = "";
timeInput.value = "";
dateInput.focus();
};
// 0.140.0: optional "Repeat & reminders" section (collapsible). Three
// free-text fields; recurrence uses natural language ("every weekday",
// "every 30 days when done"). Only shown/returned when opts.showRecurrence.
let repeatIn: HTMLInputElement | null = null;
let autoIn: HTMLInputElement | null = null;
let remindIn: HTMLInputElement | null = null;
if (this.opts.showRecurrence) {
const det = wrap.createEl("details", { cls: "stashpad-due-recur" });
if (this.opts.currentRepeat || this.opts.currentAutoDoneAfter || this.opts.currentRemindEvery) det.open = true;
det.createEl("summary", { text: "🔁 Repeat & reminders" });
const mkRow = (label: string, ph: string, val?: string): HTMLInputElement => {
const r = det.createDiv({ cls: "stashpad-due-recur-row" });
r.createEl("label", { text: label });
const inp = r.createEl("input", { type: "text", attr: { placeholder: ph } });
if (val) inp.value = val;
return inp;
};
repeatIn = mkRow("Repeat", 'e.g. "every weekday", "every 30 days when done"', this.opts.currentRepeat);
autoIn = mkRow("Auto-complete after", 'e.g. "1d" — mark done once this overdue', this.opts.currentAutoDoneAfter);
remindIn = mkRow("Remind every", 'e.g. "2h" — re-notify until done', this.opts.currentRemindEvery);
}
const recur = (): Pick<DuePickResult, "repeat" | "autoDoneAfter" | "remindEvery"> =>
this.opts.showRecurrence
? { repeat: repeatIn!.value.trim(), autoDoneAfter: autoIn!.value.trim(), remindEvery: remindIn!.value.trim() }
: {};
const cancel = grid.createEl("button", { cls: "stashpad-due-btn", text: "Cancel" });
cancel.onclick = () => { this.didChoose = true; this.close(); };
const ok = grid.createEl("button", { cls: "stashpad-due-btn mod-cta", text: "Set" });
ok.onclick = () => {
// Empty Set = remove the due date (assignees still committed, so you
// can assign someone without a due date).
if (!dateInput.value) {
this.didChoose = true;
this.close();
this.onPick({ iso: null, assignees: this.assignees, ...recur() });
return;
}
// Default time to 09:00 when only a date was chosen.
const [y, m, d] = dateInput.value.split("-").map((n) => parseInt(n, 10));
let hh = 9, mm = 0;
if (timeInput.value) { const [h, mi] = timeInput.value.split(":").map((n) => parseInt(n, 10)); hh = h; mm = mi; }
const due = new Date(y, m - 1, d, hh, mm, 0, 0);
this.didChoose = true;
this.close();
this.onPick({ iso: due.toISOString(), assignees: this.assignees, ...recur() });
};
requestAnimationFrame(() => dateInput.focus());
}
onClose(): void {
this.tinyClosePopover?.();
this.contentEl.empty();
void this.didChoose;
}
/** 0.78.1: the "Assign to" block. Delegates to the shared
* buildAssigneePicker so the standalone AssignModal reuses it. */
private renderAssignSection(wrap: HTMLElement): void {
buildAssigneePicker(wrap, {
knownAuthors: this.opts.knownAuthors ?? [],
initial: this.assignees,
onChange: (list) => { this.assignees = list; },
});
}
/** 0.76.23: open the shared numpad time picker anchored under the
* clock icon, writing the result back to the native time input as
* 24-hour HH:MM. Plain-DOM popover host (the modal isn't a
* SuggestModal, so no Obsidian Scope) with click-outside + Escape +
* Enter handling. */
private tinyClosePopover: (() => void) | null = null;
private openTimeNumpad(anchor: HTMLElement, timeInput: HTMLInputElement): void {
this.tinyClosePopover?.();
// 0.122.3 (#4): build the popover + bind its listeners in the ANCHOR's
// document so it lands in the same (possibly popout) window as the modal —
// not always the main window.
const doc = anchor.ownerDocument ?? document;
const win = doc.defaultView ?? window;
// Seed from the current time value, else the current clock time.
let h24 = 9, mm = 0;
if (timeInput.value) {
const [h, mi] = timeInput.value.split(":").map((n) => parseInt(n, 10));
if (Number.isFinite(h)) h24 = h;
if (Number.isFinite(mi)) mm = mi;
} else {
const now = new Date();
h24 = now.getHours();
mm = now.getMinutes();
}
const period: "am" | "pm" = h24 >= 12 ? "pm" : "am";
const seedH = h24 === 0 ? 12 : (h24 > 12 ? h24 - 12 : h24);
// 0.122.10: host the popover INSIDE the modal element, not doc.body. When
// it lived in body (outside the modal's focusable subtree) the modal's
// focus management pulled focus back to the first field (the date input)
// the instant the time picker focused its hour field — so clicking the
// clock landed you in the date box. Keeping it inside modalEl mirrors the
// search When-builder (whose picker is hosted in its own focusable region)
// and lets the hour field keep focus. position:fixed still anchors to the
// viewport. Falls back to doc.body if modalEl is somehow unavailable.
const host = (this.modalEl as HTMLElement | undefined) ?? doc.body;
const pop = host.createDiv({ cls: "stashpad-when-popover stashpad-due-time-pop" });
// Above the modal (Obsidian modals sit ~var(--layer-modal)).
pop.setCssStyles({ position: "fixed", zIndex: "9999" });
let onEnter: (() => void) | null = null;
const close = (): void => {
pop.remove();
doc.removeEventListener("mousedown", outside, true);
doc.removeEventListener("keydown", onKey, true);
if (this.tinyClosePopover === close) this.tinyClosePopover = null;
};
const outside = (e: MouseEvent): void => {
if (!pop.contains(e.target as Node) && e.target !== anchor && !anchor.contains(e.target as Node)) close();
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === "Enter" && onEnter) { e.preventDefault(); e.stopPropagation(); onEnter(); }
};
this.tinyClosePopover = close;
buildTimePickerInto(pop, {
seedH, seedM: mm, seedPeriod: period,
close,
setOnEnter: (cb) => { onEnter = cb; },
onFinalize: (r) => {
timeInput.value = `${String(r.hours24).padStart(2, "0")}:${String(r.minutes).padStart(2, "0")}`;
},
});
const rect = anchor.getBoundingClientRect();
pop.setCssStyles({
left: `${Math.max(8, Math.min(rect.left, win.innerWidth - 220))}px`,
top: `${rect.bottom + 4}px`,
});
setTimeout(() => {
doc.addEventListener("mousedown", outside, true);
doc.addEventListener("keydown", onKey, true);
// 0.122.10: re-assert focus on the hour field after the modal's own
// focus handling has settled, so the picker opens ready for numeric entry.
pop.querySelector<HTMLInputElement>(".stashpad-when-time-field")?.focus();
}, 0);
}
private startOfTodayLocal(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
private toDateValue(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
private toTimeValue(d: Date): string {
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${h}:${mi}`;
}
}
/** Browses the in-memory ring of NotificationRecords held by the
* plugin's NotificationService. Live-updates via service.onChange so
* new notifications appear without re-opening the modal. Mirrors
* LogModal's toolbar + filter + paginated list shape so the two
* feel cohesive. */
export class NotificationHistoryModal extends Modal {
private records: NotificationRecord[] = [];
private visible: NotificationRecord[] = [];
private shownCount = 0;
private categoryFilter: NotificationCategory | null = null;
private listEl: HTMLDivElement | null = null;
private footerEl: HTMLDivElement | null = null;
private countEl: HTMLSpanElement | null = null;
private filterSelEl: HTMLSelectElement | null = null;
private unsubscribe: (() => void) | null = null;
private static PAGE = 100;
/** Author filter dimension orthogonal to the category filter:
* - "all": no filter on author.
* - "me": records whose authorId === currentAuthorId.
* - "cross": records where the actor's authorId differs from at
* least one affected note's author (covers "someone else
* touched my notes" AND "I touched someone else's notes").
* - "<id>": records authored by the given authorId. */
private authorFilter: "all" | "me" | "cross" | string = "all";
private authorSelEl: HTMLSelectElement | null = null;
constructor(
app: App,
private service: NotificationService,
private openLog?: (folder: string | undefined) => void,
/** Local user's authorId. Used by the "Me" filter; if null, the
* "Me" option is hidden. */
private currentAuthorId: string | null = null,
/** Resolver: given a Stashpad id, returns all author + contributor
* ids for that note (read from frontmatter.author +
* frontmatter.contributors by the caller). Used by the
* "Cross-author" filter. Note that for DESTROYED notes the
* resolver can't help (the note's gone from the metadata cache)
* — those records pre-stamp `affectedAuthorIds` at the time of
* the action instead. */
private getNoteAuthorIds?: (id: string) => string[],
) {
super(app);
}
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Stashpad notification history");
this.modalEl.addClass("stashpad-log-modal"); // Reuse the existing log-modal sizing.
this.modalEl.addClass("stashpad-notif-history-modal");
this.records = this.service.recent();
const toolbar = this.contentEl.createDiv({ cls: "stashpad-log-toolbar" });
this.countEl = toolbar.createSpan({ cls: "stashpad-log-count" });
this.updateCount();
this.filterSelEl = toolbar.createEl("select", { cls: "stashpad-log-type-filter" });
this.filterSelEl.onchange = () => this.setCategoryFilter((this.filterSelEl!.value || null) as NotificationCategory | null);
this.refreshCategoryFilter();
// Author filter dropdown — only renders when there's at least one
// authored record. Multiplayer filter: All / Me / Cross-author /
// per-author entries.
this.authorSelEl = toolbar.createEl("select", { cls: "stashpad-log-type-filter stashpad-notif-author-filter" });
this.authorSelEl.onchange = () => this.setAuthorFilter(this.authorSelEl!.value || "all");
this.refreshAuthorFilter();
if (this.openLog) {
const logBtn = toolbar.createEl("button", { text: "Open log" });
logBtn.title = "Open the per-folder Stashpad log for the most recent notification's folder.";
logBtn.onclick = () => {
const mostRecentWithFolder = this.records.find((r) => !!r.folder);
this.openLog?.(mostRecentWithFolder?.folder);
};
}
const clearBtn = toolbar.createEl("button", { cls: "mod-warning", text: "Clear history" });
clearBtn.onclick = () => {
// Confirm before wiping — same pattern as LogModal's "Clear log"
// button. ConfirmModal treats click-off-the-overlay as Cancel.
new ConfirmModal(
this.app,
"Clear notification history?",
`This will permanently remove all ${this.records.length} stored notifications from the history. The current toasts on screen are unaffected. This can't be undone.`,
"Clear history",
(ok) => {
if (!ok) return;
this.service.clearHistory();
// service.clearHistory emits — our subscriber refreshes.
},
"Cancel",
/*dangerous*/ true,
).open();
};
this.listEl = this.contentEl.createDiv({ cls: "stashpad-log-list" });
this.refreshList();
this.footerEl = this.contentEl.createDiv({ cls: "stashpad-log-footer" });
this.renderFooter();
// Live-update: re-pull records on every service change.
this.unsubscribe = this.service.onChange(() => {
this.records = this.service.recent();
this.refreshCategoryFilter();
this.refreshAuthorFilter();
this.refreshList();
this.renderFooter();
});
}
private setAuthorFilter(value: string): void {
if (this.authorFilter === value) return;
this.authorFilter = value;
this.refreshList();
this.renderFooter();
}
/** Build the author <select> options from distinct authorIds in the
* history, plus the synthetic "All / Me / Cross-author" entries. */
private refreshAuthorFilter(): void {
if (!this.authorSelEl) return;
const sel = this.authorSelEl;
sel.empty();
sel.createEl("option", { text: "All authors" }).value = "all";
if (this.currentAuthorId) {
sel.createEl("option", { text: "Me" }).value = "me";
}
// "Cross-author" is always available — even without the resolver,
// pre-stamped affectedAuthorIds may suffice for destructive ops.
sel.createEl("option", { text: "Cross-author" }).value = "cross";
// Distinct authors present in the recorded set, excluding the
// local user (already covered by "Me"). Limited to authors who
// actually appear in history so the list stays meaningful.
const distinct = new Set<string>();
for (const r of this.records) {
if (r.authorId && r.authorId !== this.currentAuthorId) distinct.add(r.authorId);
}
if (distinct.size > 0) {
const sep = sel.createEl("option", { text: "──────────" });
sep.disabled = true;
for (const id of [...distinct].sort()) {
sel.createEl("option", { text: id }).value = id;
}
}
// If the active filter is no longer applicable, drop it.
const valid = new Set(["all", "cross", ...(this.currentAuthorId ? ["me"] : []), ...distinct]);
if (!valid.has(this.authorFilter)) this.authorFilter = "all";
sel.value = this.authorFilter;
}
/** Returns true when `record` is involved in cross-author activity:
* any author / contributor of an affected note differs from the
* actor (record.authorId). Either direction qualifies — "someone
* else touched my notes" OR "I touched someone else's notes" or
* "I touched a note that has other contributors".
*
* Two sources are consulted, in priority order:
* 1. Pre-stamped `affectedAuthorIds` on the record — the only
* way to detect cross-author DELETES (the deleted note is no
* longer in the metadata cache).
* 2. The `getNoteAuthorIds` resolver — queries live frontmatter
* at filter time. Covers all non-destructive actions.
*/
private isCrossAuthor(record: NotificationRecord): boolean {
const actor = record.authorId ?? null;
if (!actor) return false;
for (const id of record.affectedAuthorIds ?? []) {
if (id && id !== actor) return true;
}
if (!this.getNoteAuthorIds) return false;
for (const noteId of record.affectedIds) {
const ids = this.getNoteAuthorIds(noteId);
for (const id of ids) {
if (id && id !== actor) return true;
}
}
return false;
}
onClose(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.contentEl.empty();
}
private setCategoryFilter(cat: NotificationCategory | null): void {
if ((this.categoryFilter ?? null) === (cat ?? null)) return;
this.categoryFilter = cat;
this.refreshList();
this.renderFooter();
}
private refreshCategoryFilter(): void {
if (!this.filterSelEl) return;
const sel = this.filterSelEl;
sel.empty();
const counts = new Map<NotificationCategory, number>();
for (const r of this.records) counts.set(r.category, (counts.get(r.category) ?? 0) + 1);
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const all = sel.createEl("option", { text: `All categories (${this.records.length})` });
all.value = "";
for (const [cat, n] of entries) {
const opt = sel.createEl("option", { text: `${cat} (${n})` });
opt.value = cat;
}
if (this.categoryFilter && !counts.has(this.categoryFilter)) this.categoryFilter = null;
sel.value = this.categoryFilter ?? "";
}
private refreshList(): void {
if (!this.listEl) return;
this.visible = this.records.filter((r) => {
if (this.categoryFilter && r.category !== this.categoryFilter) return false;
switch (this.authorFilter) {
case "all": return true;
case "me": return !!this.currentAuthorId && r.authorId === this.currentAuthorId;
case "cross": return this.isCrossAuthor(r);
default: return r.authorId === this.authorFilter;
}
});
this.shownCount = 0;
this.listEl.empty();
if (!this.visible.length) {
this.listEl.createDiv({
cls: "stashpad-log-empty",
text: this.categoryFilter ? `No "${this.categoryFilter}" notifications.` : "No notifications yet.",
});
this.updateCount();
return;
}
this.appendMore(NotificationHistoryModal.PAGE);
}
private appendMore(n: number): void {
if (!this.listEl) return;
const stop = Math.min(this.visible.length, this.shownCount + n);
for (let i = this.shownCount; i < stop; i++) this.renderRow(this.listEl, this.visible[i]);
this.shownCount = stop;
this.updateCount();
}
private renderRow(parent: HTMLElement, r: NotificationRecord): void {
const row = parent.createDiv({ cls: `stashpad-notif-row stashpad-notif-row-${r.kind}` });
const meta = row.createDiv({ cls: "stashpad-notif-meta" });
const time = meta.createSpan({ cls: "stashpad-notif-time" });
const m = momentFn(r.ts);
time.setText(m.fromNow());
time.title = m.format("YYYY-MM-DD HH:mm:ss");
const cat = meta.createSpan({ cls: `stashpad-notif-cat stashpad-notif-cat-${r.category}` });
cat.setText(r.category);
const msg = row.createDiv({ cls: "stashpad-notif-msg" });
msg.setText(r.message);
if (r.actionLabels.length > 0) {
const acts = row.createDiv({ cls: "stashpad-notif-actions-snapshot" });
for (const label of r.actionLabels) {
const chip = acts.createSpan({ cls: "stashpad-notif-action-chip" });
chip.setText(label);
chip.title = "Action button was shown on the original toast (handler not retained).";
}
}
}
private updateCount(): void {
if (!this.countEl) return;
const total = this.visible.length;
const label = this.categoryFilter
? `${total} ${this.categoryFilter} notification${total === 1 ? "" : "s"}`
: `${total} notification${total === 1 ? "" : "s"}`;
if (this.shownCount === 0 || this.shownCount >= total) {
this.countEl.setText(label);
} else {
this.countEl.setText(`Showing ${this.shownCount} of ${label}`);
}
}
private renderFooter(): void {
if (!this.footerEl) return;
this.footerEl.empty();
const remaining = this.visible.length - this.shownCount;
if (remaining <= 0) return;
const moreBtn = this.footerEl.createEl("button", { text: `Load ${Math.min(NotificationHistoryModal.PAGE, remaining)} more` });
moreBtn.onclick = () => { this.appendMore(NotificationHistoryModal.PAGE); this.renderFooter(); };
if (remaining > NotificationHistoryModal.PAGE) {
const allBtn = this.footerEl.createEl("button", { text: `Load all (${remaining})` });
allBtn.onclick = () => { this.appendMore(remaining); this.renderFooter(); };
}
}
}
/** 0.79.3: read-only viewer for the import log. Lists imports newest-first
* so the user can see / reference what they've imported. */
export class ImportLogModal extends Modal {
constructor(app: App, private entries: ImportLogEntry[]) { super(app); }
onOpen(): void {
this.contentEl.empty();
this.titleEl.setText("Stashpad import log");
if (this.entries.length === 0) {
this.contentEl.createDiv({ cls: "stashpad-log-empty", text: "Nothing imported yet." });
return;
}
const list = this.contentEl.createDiv({ cls: "stashpad-import-log-list" });
for (const e of this.entries) {
const row = list.createDiv({ cls: "stashpad-import-log-row" });
const when = (moment as any)(e.ts).format("YYYY-MM-DD HH:mm");
row.createSpan({ cls: "stashpad-import-log-when", text: when });
const kindLabel = e.kind === "folder" ? "folder" : e.kind === "md" ? "note" : "file";
row.createSpan({ cls: `stashpad-import-log-kind is-${e.kind}`, text: kindLabel });
row.createSpan({ cls: "stashpad-import-log-name", text: e.originalName });
const meta: string[] = [e.folder.split("/").pop() || e.folder];
if (e.notePaths.length > 1) meta.push(`${e.notePaths.length} notes`);
row.createSpan({ cls: "stashpad-import-log-meta", text: meta.join(" · ") });
}
}
onClose(): void { this.contentEl.empty(); }
}
/** 0.79.7: three-way choice for a likely-duplicate import. Escape / close
* resolves to "skip" (the safe default for an accidental re-drop). */
export class ImportDupChoiceModal extends Modal {
private chose = false;
constructor(app: App, private message: string, private onChoose: (c: "anyway" | "replace" | "skip") => void) { super(app); }
onOpen(): void {
this.modalEl?.addClass("stashpad-compact-modal");
this.contentEl.empty();
this.titleEl.setText("Possible duplicate import");
const block = this.contentEl.createDiv({ cls: "stashpad-confirm-body" });
for (const line of this.message.split("\n")) block.createDiv({ cls: "stashpad-confirm-line", text: line });
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
const skip = row.createEl("button", { text: "Skip duplicates" });
skip.onclick = () => { this.chose = true; this.close(); this.onChoose("skip"); };
const replace = row.createEl("button", { text: "Replace existing" });
replace.onclick = () => { this.chose = true; this.close(); this.onChoose("replace"); };
const anyway = row.createEl("button", { cls: "mod-cta", text: "Import anyway" });
anyway.onclick = () => { this.chose = true; this.close(); this.onChoose("anyway"); };
}
onClose(): void { if (!this.chose) this.onChoose("skip"); this.contentEl.empty(); }
}
/** OKF export: pick a name + one or more container formats. Delegates the actual
* bundle build to plugin.exportOkf (see okf-export.ts). */
export class OkfExportModal extends Modal {
private base: string;
constructor(app: App, defaultBase: string, private noteCount: number, private onConfirm: (base: string, formats: { zip: boolean; targz: boolean; stash: boolean }) => void) {
super(app); this.base = defaultBase;
}
onOpen(): void {
this.contentEl.empty();
this.modalEl.addClass("stashpad-export-modal");
this.titleEl.setText("Export as OKF");
this.contentEl.createEl("p", { cls: "stashpad-export-desc", text: `Export ${this.noteCount} note${this.noteCount === 1 ? "" : "s"} as an Open Knowledge Format bundle. Pick one or more formats.` });
const name = this.contentEl.createEl("input", { type: "text" });
name.addClass("stashpad-export-name"); name.value = this.base; name.placeholder = "Export name";
const mk = (label: string, checked: boolean): HTMLInputElement => {
const row = this.contentEl.createDiv({ cls: "stashpad-okf-fmt" });
const cb = row.createEl("input", { type: "checkbox" }); cb.checked = checked;
row.createEl("label", { text: label });
return cb;
};
const zip = mk(".zip — OKF bundle (portable)", true);
const targz = mk(".tar.gz — OKF bundle (tarball)", false);
const stash = mk(".stash — Stashpad format (re-importable)", false);
const footer = this.contentEl.createDiv({ cls: "stashpad-export-footer" });
footer.createEl("button", { text: "Cancel" }).onclick = () => this.close();
const go = footer.createEl("button", { cls: "mod-cta", text: "Export" });
go.onclick = () => {
if (!zip.checked && !targz.checked && !stash.checked) { new Notice("Pick at least one format."); return; }
this.close();
this.onConfirm(name.value.trim() || this.base, { zip: zip.checked, targz: targz.checked, stash: stash.checked });
};
requestAnimationFrame(() => name.focus());
}
onClose(): void { this.contentEl.empty(); }
}
/** 0.138.0 (re-encrypt sweep): ONE review modal listing everything that should
* be encrypted but is plaintext — per-subtree rows, checkboxes pre-ticked.
* Confirm passes the CHECKED indices; nothing encrypts without it. */
export class ReEncryptReviewModal extends Modal {
constructor(
app: App,
private items: Array<{ label: string; detail: string }>,
private onConfirm: (chosenIndices: number[]) => void | Promise<void>,
) { super(app); }
onOpen(): void {
this.titleEl.setText("Re-encrypt everything applicable?");
const { contentEl } = this;
contentEl.createEl("p", {
cls: "setting-item-description",
text: "These notes should be encrypted (they were unlocked/restored, or their folder is set to encrypt) but are currently plaintext. Untick anything you want to leave readable.",
});
const list = contentEl.createDiv({ cls: "stashpad-reenc-list" });
const boxes: HTMLInputElement[] = [];
this.items.forEach((it) => {
const row = list.createEl("label", { cls: "stashpad-reenc-row" });
const cb = row.createEl("input", { type: "checkbox" });
cb.checked = true;
boxes.push(cb);
const body = row.createDiv({ cls: "stashpad-reenc-body" });
body.createDiv({ cls: "stashpad-reenc-label", text: it.label });
body.createDiv({ cls: "stashpad-reenc-detail", text: it.detail });
});
const btns = contentEl.createDiv({ cls: "modal-button-container" });
const go = btns.createEl("button", { cls: "mod-cta", text: `Re-encrypt checked` });
go.onclick = () => {
const chosen = boxes.map((b, i) => (b.checked ? i : -1)).filter((i) => i >= 0);
this.close();
if (chosen.length) void this.onConfirm(chosen);
};
btns.createEl("button", { text: "Cancel" }).onclick = () => this.close();
}
onClose(): void { this.contentEl.empty(); }
}