0.77.9: author registry + claim authorship

This commit is contained in:
Human 2026-06-01 21:12:26 -07:00
parent 801ba4d8a0
commit ba6a1069c8
14 changed files with 1594 additions and 272 deletions

109
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.76.19",
"version": "0.77.9",
"minAppVersion": "1.7.0",
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
"author": "Human",

View file

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

69
release-notes/0.77.9.md Normal file
View file

@ -0,0 +1,69 @@
# 0.77.9 — Author registry + claim authorship
A multi-step pass over Stashpad's authorship model. The headline is a new
**author registry** that makes contributor/author data resilient to deleted
stubs, plus a way to **retroactively claim** notes you wrote before setting
your author name.
## Author registry (0.77.10.77.6)
A rebuildable recovery cache + append-only rename history for every author
the vault has seen. Explicitly *not* a source of truth — the truth still
lives in note frontmatter and `_authors/` stubs.
- **`authors.json`** lives in the plugin private dir. Records the local
user on load + every settings save, plus any author seen on stamp
(`ensureAuthorFile`).
- **Rebuild from a full vault scan.** New command **Rebuild author
registry** reconstructs `authors.json` by scanning author/contributor
wikilinks across every note's frontmatter (catches ids whose stub was
deleted) plus the existing `_authors/` stubs (authoritative for
name/role/department). `firstSeen` + rename history is preserved across
rebuilds.
- **Restore missing author stubs.** New command **Restore missing author
stubs (from registry)** regenerates any deleted `_authors/<name>.md` in
every Stashpad folder from the registry's remembered name / role /
department. Never overwrites or duplicates an existing one (matched by
id).
- **Settings UI.** New **Known authors (registry)** section in the
Authorship tab — Rebuild + Restore-missing-pages buttons and a list of
every recorded author with role / department, id, and rename history.
Presented as a recovery / audit tool, not a source of truth.
- **Rebootstrap refreshes the registry.** `rebootstrapAllFolders` now
refreshes the registry cache from its scan (read-only w.r.t. notes —
only plugin-private `authors.json` is rewritten). Stub-file restoration
is deliberately not folded in: a missing page may have been deleted on
purpose.
## Author stubs use Obsidian-native aliases (0.77.4)
- Display name now lives in the stub's `aliases` array — so `[[Name]]`
resolves and the quick switcher finds the author — rather than a custom
`name` key.
- `ensureAuthorFile` delegates to a single canonical writer
(`buildAuthorStub`). The legacy `name` key is read as a fallback so
existing stubs keep resolving, then migrated off on next refresh.
## Auto-seed your own author page (0.77.7)
- `seedLocalAuthorStub(folder)` creates *only* your own author page in any
folder that lacks it. No-op if your id already has a stub there.
- Runs at new-folder creation and once at startup across existing folders
(deferred ~4s after `onLayoutReady` so discovery + dedupe see a settled
metadata cache).
- Coworker pages stay lazy — they appear where they actually stamp — so
this stays an O(local user) cost instead of O(authors × folders).
## Claim authorship retroactively (0.77.80.77.9)
For notes you wrote before setting an author name in Stashpad.
- **Author-only variants** claim unowned notes (notes with no author set).
- **"+ contributor" variants** also add you to `contributors` on already-
authored notes — original author is left untouched.
- Two scopes: **selection** (current row + multi-selection) and **folder**
(confirms with a count first).
- **Never overwrites an existing author.** Only fills blanks.
- **Dedup:** already-yours notes are skipped; you're never set as both
author and contributor on the same note.
- **Drops a redundant contributor entry** when you become the author of a
note you'd previously edited (author supersedes contributor), and
remembers that so undo restores it.
- **Fully undoable.** Undo touches only the captured changed paths — never
paths that were skipped.

189
src/author-registry.ts Normal file
View file

@ -0,0 +1,189 @@
import type { App } from "obsidian";
/** One rename event in an author's history. `at` is an ISO timestamp. */
export interface AuthorRename {
from: string;
to: string;
at: string;
}
/** A single author's record in the registry. Keyed by the stable
* `authorId`. Everything except `id` is cosmetic / recoverable the id
* is the only durable join key (it's also baked into every note's
* author/contributors frontmatter wikilink and into the stub filename). */
export interface AuthorRecord {
id: string;
/** Current best-known display name. */
name: string;
role?: string;
department?: string;
/** ISO timestamp first observed by the registry. */
firstSeen: string;
/** ISO timestamp last observed/updated. */
lastSeen: string;
/** Append-only rename history (oldest → newest). */
renames: AuthorRename[];
}
interface RegistryFile {
version: number;
authors: Record<string, AuthorRecord>;
}
const REGISTRY_VERSION = 1;
/** AuthorRegistry a REBUILDABLE cache + append-only rename history of
* every author the plugin has ever seen, persisted as `authors.json` in
* the plugin's private dir (next to log.jsonl / state.json).
*
* IMPORTANT: this is explicitly NOT a source of truth. The authoritative
* identity is `settings.authorId` (for the local user) plus the id baked
* into each note's frontmatter + the `_authors/<name>-<id>.md` stub
* filenames. The registry can always be reconstructed by scanning the
* vault (see `rebuild()` in main.ts), so if it drifts, is corrupted, or
* is deleted, nothing breaks we just rebuild it. Its value is:
* - recovery: regenerate a deleted stub from a remembered name/role/dept
* - history: an audit trail of display-name renames over time
* - directory: a fast "who exists" lookup that avoids a full vault scan
*/
export class AuthorRegistry {
private readonly path: string;
private data: RegistryFile = { version: REGISTRY_VERSION, authors: {} };
private loaded = false;
private dirOk = false;
/** Serializes saves so concurrent writes don't trample each other. */
private writeChain: Promise<void> = Promise.resolve();
constructor(private app: App, baseDir: string) {
this.path = `${baseDir.replace(/\/+$/, "")}/authors.json`;
}
getPath(): string { return this.path; }
async load(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
try {
const adapter = this.app.vault.adapter;
if (await adapter.exists(this.path)) {
const parsed = JSON.parse(await adapter.read(this.path)) as Partial<RegistryFile>;
if (parsed && typeof parsed === "object" && parsed.authors) {
this.data = {
version: typeof parsed.version === "number" ? parsed.version : REGISTRY_VERSION,
authors: parsed.authors as Record<string, AuthorRecord>,
};
}
}
} catch (e) {
console.warn("[Stashpad] author registry load failed; starting empty", e);
this.data = { version: REGISTRY_VERSION, authors: {} };
}
}
/** Snapshot of all known authors, newest-activity first. */
all(): AuthorRecord[] {
return Object.values(this.data.authors)
.sort((a, b) => (b.lastSeen ?? "").localeCompare(a.lastSeen ?? ""));
}
get(id: string): AuthorRecord | null {
return this.data.authors[id] ?? null;
}
/** Upsert an author. If the display name changed, appends a rename
* event to the history. Updates lastSeen. Persists in the background.
* Returns true if anything changed (so callers can skip a redundant
* save when nothing did). */
record(info: { id: string; name?: string; role?: string; department?: string; at?: string }): boolean {
const id = (info.id ?? "").trim();
if (!id) return false;
const now = info.at ?? new Date().toISOString();
const name = (info.name ?? "").trim();
const existing = this.data.authors[id];
let changed = false;
if (!existing) {
this.data.authors[id] = {
id,
name,
role: info.role?.trim() || undefined,
department: info.department?.trim() || undefined,
firstSeen: now,
lastSeen: now,
renames: [],
};
changed = true;
} else {
if (name && name !== existing.name) {
existing.renames.push({ from: existing.name, to: name, at: now });
existing.name = name;
changed = true;
}
if (info.role !== undefined) {
const r = info.role.trim() || undefined;
if (r !== existing.role) { existing.role = r; changed = true; }
}
if (info.department !== undefined) {
const d = info.department.trim() || undefined;
if (d !== existing.department) { existing.department = d; changed = true; }
}
existing.lastSeen = now;
}
if (changed) void this.save();
return changed;
}
/** Replace the entire author set (used by rebuild()). Preserves
* firstSeen + rename history for ids that already existed. */
replaceAll(records: Array<{ id: string; name?: string; role?: string; department?: string }>, at?: string): void {
const now = at ?? new Date().toISOString();
const next: Record<string, AuthorRecord> = {};
for (const rec of records) {
const id = (rec.id ?? "").trim();
if (!id) continue;
const prior = this.data.authors[id];
const name = (rec.name ?? "").trim() || prior?.name || "";
const renames = prior?.renames ? [...prior.renames] : [];
if (prior && name && name !== prior.name) {
renames.push({ from: prior.name, to: name, at: now });
}
next[id] = {
id,
name,
role: rec.role?.trim() || prior?.role || undefined,
department: rec.department?.trim() || prior?.department || undefined,
firstSeen: prior?.firstSeen ?? now,
lastSeen: now,
renames,
};
}
this.data = { version: REGISTRY_VERSION, authors: next };
void this.save();
}
private async ensureDir(): Promise<void> {
if (this.dirOk) return;
const adapter = this.app.vault.adapter;
const dir = this.path.slice(0, this.path.lastIndexOf("/"));
const parts = dir.split("/").filter(Boolean);
let cur = "";
for (const p of parts) {
cur = cur ? `${cur}/${p}` : p;
if (!(await adapter.exists(cur))) await adapter.mkdir(cur);
}
this.dirOk = true;
}
/** Persist the registry. Chained so overlapping saves serialize. */
save(): Promise<void> {
this.writeChain = this.writeChain.then(async () => {
try {
await this.ensureDir();
await this.app.vault.adapter.write(this.path, JSON.stringify(this.data, null, 2));
} catch (e) {
console.warn("[Stashpad] author registry save failed", e);
}
});
return this.writeChain;
}
}

View file

@ -18,14 +18,19 @@ export class FolderSuggest extends AbstractInputSuggest<TFolder> {
}
protected getSuggestions(query: string): TFolder[] {
const q = query.toLowerCase();
// 0.76.26: Sift — all-tokens, any-order match (see docs/sift.md)
// so "proj notes" finds "Notes/Projects" etc., not just literal
// substrings.
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
const sift = (path: string): boolean =>
tokens.every((t) => path.toLowerCase().includes(t));
const out: TFolder[] = [];
const walk = (folder: TFolder): void => {
// Skip the vault root from the suggestion list — its path is "/"
// and selecting it sets the input to "/" which most callers
// normalize away anyway. Children are still suggested.
if (folder.path !== "/") {
if (!q || folder.path.toLowerCase().includes(q)) out.push(folder);
if (sift(folder.path)) out.push(folder);
}
for (const child of folder.children) {
if (child instanceof TFolder) walk(child);

View file

@ -15,6 +15,7 @@ import { ROOT_ID } from "./types";
import { UndoStack } from "./undo-stack";
import { rebootstrapFolderFrontmatter } from "./frontmatter-sync";
import { NotificationService, buildFileActions } from "./notifications";
import { AuthorRegistry } from "./author-registry";
export default class StashpadPlugin extends Plugin {
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
@ -47,6 +48,17 @@ export default class StashpadPlugin extends Plugin {
if (!this._notifications) this._notifications = new NotificationService(this.app);
return this._notifications;
}
/** 0.77.1: rebuildable author registry (authors.json in the plugin
* private dir). NOT a source of truth a recovery cache + rename
* history. See author-registry.ts. Lazily constructed; load() is
* awaited once during onload. */
private _authorRegistry: AuthorRegistry | null = null;
get authorRegistry(): AuthorRegistry {
if (!this._authorRegistry) {
this._authorRegistry = new AuthorRegistry(this.app, this.pluginPrivatePath());
}
return this._authorRegistry;
}
/** Vault-relative path to a file/dir inside the plugin's private
* folder (`.obsidian/plugins/<id>/.stashpad/...`). Used for the log,
@ -199,6 +211,9 @@ export default class StashpadPlugin extends Plugin {
"Home",
].join("\n");
await this.app.vault.create(homePath, body);
// 0.77.7: seed the local user's author page into the new folder so
// their links resolve everywhere from the start.
try { await this.seedLocalAuthorStub(cleaned); } catch {}
}
/** Tally per-note colors found in EVERY markdown file under `folder`.
@ -444,6 +459,28 @@ export default class StashpadPlugin extends Plugin {
this.settingTab = new StashpadSettingTab(this.app, this);
this.addSettingTab(this.settingTab);
// 0.77.1: load the author registry and seed it with the local user.
await this.authorRegistry.load();
{
const id = (this.settings.authorId ?? "").trim();
if (id) {
this.authorRegistry.record({
id,
name: this.settings.authorName,
role: this.settings.authorRole,
department: this.settings.authorDepartment,
});
}
}
// 0.77.7: backfill the local user's author page into any existing
// Stashpad folder that lacks it. Deferred + after the metadata cache
// has settled so folder discovery + the "already has my stub" check
// are accurate (avoids creating a duplicate before the cache lists
// the existing one). New folders are seeded at creation time instead.
this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => { void this.seedLocalAuthorStubsEverywhere(); }, 4000);
});
this.registerView(
STASHPAD_VIEW_TYPE,
(leaf: WorkspaceLeaf) => new StashpadView(leaf, this),
@ -663,6 +700,30 @@ export default class StashpadPlugin extends Plugin {
name: "Toggle split-on-newlines",
callback: () => call("toggleSplit"),
});
// 0.77.8: claim authorship retroactively (for notes created before the
// user set their author name). Author-only variants only fill blank
// author fields; the "+ contributor" variants also add the user as a
// contributor to notes someone else already authored. All undoable.
this.addCommand({
id: "stashpad-claim-selected-author",
name: "Claim authorship of selected notes",
callback: () => call("claimSelectedAsAuthor"),
});
this.addCommand({
id: "stashpad-claim-folder-author",
name: "Claim authorship of all unauthored notes in this folder",
callback: () => call("claimFolderAsAuthor"),
});
this.addCommand({
id: "stashpad-claim-selected-contributor",
name: "Claim selected notes (author if unowned, else add me as contributor)",
callback: () => call("claimSelectedWithContributor"),
});
this.addCommand({
id: "stashpad-claim-folder-contributor",
name: "Claim all notes in this folder (author if unowned, else add me as contributor)",
callback: () => call("claimFolderWithContributor"),
});
this.addCommand({
id: "stashpad-pick-destination",
name: "Pick destination for next note",
@ -850,6 +911,45 @@ export default class StashpadPlugin extends Plugin {
name: "Set missing parents to Home (orphan fix)",
callback: () => void this.fixOrphanParents(),
});
// 0.77.2: rebuild the author registry from a full vault scan.
this.addCommand({
id: "stashpad-rebuild-author-registry",
name: "Rebuild author registry (scan authors + note frontmatter)",
callback: async () => {
new Notice("Stashpad: rebuilding author registry…");
try {
const r = await this.rebuildAuthorRegistry();
this.notifications.show({
message: `Author registry rebuilt: ${r.total} author(s) — ${r.fromStubs} from stubs, ${r.fromNotes} from note links.`,
kind: "success",
category: "system",
});
} catch (e) {
new Notice(`Author registry rebuild failed: ${(e as Error).message}`);
}
},
});
// 0.77.3: regenerate any author stub files that were deleted, from
// the registry's remembered name/role/department.
this.addCommand({
id: "stashpad-restore-author-stubs",
name: "Restore missing author stubs (from registry)",
callback: async () => {
new Notice("Stashpad: restoring author stubs…");
try {
const r = await this.restoreMissingAuthorStubs();
this.notifications.show({
message: r.created > 0
? `Restored ${r.created} author stub(s) across ${r.folders} folder(s).`
: `No missing author stubs — all present across ${r.folders} folder(s).`,
kind: "success",
category: "system",
});
} catch (e) {
new Notice(`Restore author stubs failed: ${(e as Error).message}`);
}
},
});
// 0.58.0: rebootstrap as a command palette entry — mirrors the
// "Rebootstrap now" button in settings. Useful when troubleshooting
// / migrating without opening Settings.
@ -859,11 +959,12 @@ export default class StashpadPlugin extends Plugin {
callback: async () => {
new Notice("Stashpad: rebootstrapping…");
try {
const { touched, fmChecked, fmWritten, slugsRenamed } = await this.rebootstrapAllFolders();
const { touched, fmChecked, fmWritten, slugsRenamed, authors } = await this.rebootstrapAllFolders();
const parts: string[] = [];
parts.push(`rebootstrapped ${touched.length} folder${touched.length === 1 ? "" : "s"}`);
if (fmWritten > 0) parts.push(`updated ${fmWritten} note${fmWritten === 1 ? "" : "s"}' metadata`);
if (slugsRenamed > 0) parts.push(`renamed ${slugsRenamed} note${slugsRenamed === 1 ? "" : "s"}`);
if (authors > 0) parts.push(`${authors} author${authors === 1 ? "" : "s"} in registry`);
parts.push(`(checked ${fmChecked} total)`);
new Notice(`Stashpad: ${parts.join(" · ")}`);
} catch (e) {
@ -1156,6 +1257,75 @@ export default class StashpadPlugin extends Plugin {
if (!(file instanceof TFile)) return;
void this.maybeAdoptAuthorRename(file, oldPath);
}));
// 0.76.31: detect when a newer plugin build has synced in but
// Obsidian is still running the old code (no hot-reload). Check
// shortly after load (let Sync settle) and whenever the app
// foregrounds. Nudges the user to reload so they're not stuck on
// stale code (the "old UI after opening the app" report).
this.registerDomEvent(window, "focus", () => void this.checkForSyncedBuild());
setTimeout(() => void this.checkForSyncedBuild(), 5000);
}
/** 0.76.31: compare the version Obsidian LOADED (this.manifest, read
* from manifest.json at launch) against the manifest.json currently
* on disk. If they differ, a different build has synced in since
* launch and the user is running stale code surface a persistent
* notice with a Reload action (disable+enable re-reads main.js,
* works on mobile too). Notifies once per detected on-disk version. */
private notifiedBuildVersion: string | null = null;
private async checkForSyncedBuild(): Promise<void> {
try {
const dir = (this.manifest as any).dir as string | undefined;
if (!dir) return;
const path = `${dir.replace(/\/+$/, "")}/manifest.json`;
const adapter = this.app.vault.adapter;
if (!(await adapter.exists(path))) return;
const onDisk = JSON.parse(await adapter.read(path))?.version as string | undefined;
const loaded = this.manifest.version;
if (typeof onDisk !== "string" || !onDisk || onDisk === loaded) return;
// 0.76.35: ONLY nudge when the on-disk build is strictly newer than
// what's running. If on-disk is OLDER (e.g. Obsidian Sync pushed a
// stale manifest.json back onto disk — a known Sync regression),
// reloading wouldn't help and the nudge would recur on every window
// focus forever. Silently ignore older/equal on-disk versions.
if (!this.isSemverGreater(onDisk, loaded)) return;
if (this.notifiedBuildVersion === onDisk) return;
this.notifiedBuildVersion = onDisk;
this.notifications.show({
message: `A newer Stashpad build synced in (\`${loaded}\`\`${onDisk}\`). Reload to apply.`,
kind: "info",
category: "system",
duration: 0,
actions: [{
label: "Reload Stashpad",
onClick: async () => {
const plugins = (this.app as any).plugins;
try {
await plugins?.disablePlugin?.(this.manifest.id);
await plugins?.enablePlugin?.(this.manifest.id);
} catch (e) {
new Notice(`Couldn't reload — toggle Stashpad off/on in settings. (${(e as Error).message})`);
}
},
}],
});
} catch (e) {
console.debug("[Stashpad] synced-build check failed", e);
}
}
/** Tiny semver-ish compare: is `a` greater than `b`? Pads to equal
* length, numeric per segment. Non-numeric segments compare as 0. */
private isSemverGreater(a: string, b: string): boolean {
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0, y = pb[i] ?? 0;
if (x !== y) return x > y;
}
return false;
}
/** Author files live at "<stashpadFolder>/_authors/<safe-name>-<id>.md".
@ -1212,8 +1382,10 @@ export default class StashpadPlugin extends Plugin {
}
}
/** Rewrite an author stub file's H1 heading + name/role/department
* frontmatter to match the current settings. Idempotent. */
/** Rewrite an author stub file's H1 heading + aliases/role/department
* frontmatter to match the current settings. Idempotent. 0.77.4: the
* display name now lives in the Obsidian-native `aliases` array; the
* legacy custom `name` key is migrated away (deleted) here. */
private async refreshAuthorStub(file: TFile): Promise<void> {
const name = (this.settings.authorName ?? "").trim();
const role = (this.settings.authorRole ?? "").trim();
@ -1224,7 +1396,13 @@ export default class StashpadPlugin extends Plugin {
const replaced = raw.replace(/^# .*$/m, `# ${name}`);
if (replaced !== raw) await this.app.vault.modify(file, replaced);
await this.app.fileManager.processFrontMatter(file, (m: any) => {
m.name = name;
// Stashpad owns these stubs, so the alias list is authoritative:
// set it to exactly the current display name. This avoids
// accumulating stale names across renames (an old name would
// otherwise linger as an "extra" alias). Migrate off the legacy
// custom `name` key.
m.aliases = [name];
delete m.name;
if (role) m.role = role; else delete m.role;
if (dept) m.department = dept; else delete m.department;
});
@ -1464,7 +1642,7 @@ export default class StashpadPlugin extends Plugin {
* ensure it has the import/export subfolders, and run the redundant-frontmatter
* backfill (parentLink + children) so older notes pick up the recovery fields.
* Used by the "Rebootstrap" button in settings to retrofit older folders. */
async rebootstrapAllFolders(): Promise<{ touched: string[]; fmChecked: number; fmWritten: number; slugsRenamed: number }> {
async rebootstrapAllFolders(): Promise<{ touched: string[]; fmChecked: number; fmWritten: number; slugsRenamed: number; authors: number }> {
const ROOT_ID = "__root__";
const seen = new Set<string>();
for (const f of this.app.vault.getMarkdownFiles()) {
@ -1510,7 +1688,16 @@ export default class StashpadPlugin extends Plugin {
console.warn(`Stashpad: rebootstrap skipped ${folder}`, e);
}
}
return { touched, fmChecked, fmWritten, slugsRenamed };
// 0.77.6: rebootstrap is the catch-all full-vault repair, so refresh
// the author registry cache from the same scan. This is read-only
// w.r.t. the user's notes (it only rewrites the plugin-private
// authors.json). NOTE: we deliberately do NOT restore deleted author
// STUB files here — that creates files and a user may have deleted a
// page on purpose; stub restoration stays an explicit action.
let authors = 0;
try { authors = (await this.rebuildAuthorRegistry()).total; }
catch (e) { console.warn("Stashpad: rebootstrap author-registry rebuild failed", e); }
return { touched, fmChecked, fmWritten, slugsRenamed, authors };
}
/** Walk every Stashpad note in `folder`. For each one whose filename
@ -2020,6 +2207,190 @@ export default class StashpadPlugin extends Plugin {
.sort((a, b) => (b.authored + b.contributed) - (a.authored + a.contributed));
}
/** Pull the display-name + id out of an author wikilink as written into
* note frontmatter, e.g. `[[demo/_authors/Jane-743jcy.md|Jane Doe]]`
* { id: "743jcy", name: "Jane Doe" }. The alias (after `|`) is the
* display name; if absent we fall back to de-slugging the filename
* stem. Returns null when no id is present. */
private parseAuthorRef(raw: string): { id: string; name: string } | null {
if (typeof raw !== "string") return null;
const inner = raw.replace(/^\[\[/, "").replace(/\]\]$/, "");
const [target, alias] = inner.split("|");
const m = target.match(/_authors\/(.+?)-([a-z0-9]{4,12})(?:\.md)?$/i);
if (!m) return null;
const id = m[2];
const name = (alias ?? "").trim() || m[1].replace(/-/g, " ").trim();
return { id, name };
}
/** 0.77.2: rebuild the author registry from scratch by scanning the
* vault. The authoritative inputs are (a) the `_authors` stub files
* (id from filename, display name from `aliases`/`name`/H1, plus role/
* department frontmatter) and (b) author/contributor wikilinks across
* all note frontmatter (for ids whose stub was deleted). Stub metadata
* wins over note-link names when both exist. Preserves firstSeen +
* rename history for ids already in the registry. Returns a summary. */
async rebuildAuthorRegistry(): Promise<{ total: number; fromStubs: number; fromNotes: number }> {
const stashpads = this.discoverStashpadFolders();
const byId = new Map<string, { id: string; name?: string; role?: string; department?: string; fromStub: boolean }>();
// Pass 1: author wikilinks across all note frontmatter.
let fromNotes = 0;
for (const file of this.app.vault.getMarkdownFiles()) {
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as any;
if (!fm) continue;
const refs: string[] = [];
if (typeof fm.author === "string") refs.push(fm.author);
if (Array.isArray(fm.contributors)) {
for (const c of fm.contributors) if (typeof c === "string") refs.push(c);
}
for (const raw of refs) {
const parsed = this.parseAuthorRef(raw);
if (!parsed) continue;
if (!byId.has(parsed.id)) { byId.set(parsed.id, { id: parsed.id, name: parsed.name, fromStub: false }); fromNotes++; }
else { const e = byId.get(parsed.id)!; if (!e.name && parsed.name) e.name = parsed.name; }
}
}
// Pass 2: stub files (authoritative for name/role/department).
let fromStubs = 0;
for (const folder of stashpads) {
const dir = `${folder}/_authors`;
for (const file of this.app.vault.getMarkdownFiles()) {
if (!file.path.startsWith(dir + "/")) continue;
const parsed = this.parseAuthorFilePath(file.path);
if (!parsed) continue;
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as any;
const aliasName = Array.isArray(fm?.aliases) ? (fm.aliases.find((a: any) => typeof a === "string") ?? "")
: (typeof fm?.aliases === "string" ? fm.aliases : "");
const name = (aliasName || (typeof fm?.name === "string" ? fm.name : "") || parsed.name).trim();
const role = typeof fm?.role === "string" ? fm.role : undefined;
const department = typeof fm?.department === "string" ? fm.department : undefined;
const existing = byId.get(parsed.id);
if (!existing) fromStubs++;
byId.set(parsed.id, {
id: parsed.id,
name: name || existing?.name,
role: role ?? existing?.role,
department: department ?? existing?.department,
fromStub: true,
});
}
}
await this.authorRegistry.load();
this.authorRegistry.replaceAll([...byId.values()]);
await this.authorRegistry.save();
return { total: byId.size, fromStubs, fromNotes };
}
/** Build the markdown content for an author stub file. Uses the
* Obsidian-native `aliases` for the display name (so `[[Name]]`
* resolves to the stub and it surfaces in quick switcher) plus role/
* department + a created stamp + an H1. Stashpad-owned; safe to
* regenerate. */
buildAuthorStub(rec: { id: string; name: string; role?: string; department?: string }, created: string): string {
const esc = (s: string) => s.replace(/"/g, '\\"');
const lines = ["---", `authorId: ${rec.id}`, `aliases:`, ` - "${esc(rec.name)}"`];
if (rec.role) lines.push(`role: "${esc(rec.role)}"`);
if (rec.department) lines.push(`department: "${esc(rec.department)}"`);
lines.push(`created: ${created}`, "---", `# ${rec.name}`);
return lines.join("\n");
}
/** 0.77.3: for every author the registry knows about, ensure a stub
* file exists in every discovered Stashpad folder regenerating any
* that were deleted, from the remembered name/role/department. Never
* overwrites an existing stub (that's syncAuthorFilesToName's job).
* Returns the count of stubs created. */
async restoreMissingAuthorStubs(): Promise<{ created: number; folders: number }> {
await this.authorRegistry.load();
const authors = this.authorRegistry.all().filter((a) => a.id && a.name);
const folders = this.discoverStashpadFolders();
let created = 0;
for (const folder of folders) {
const dir = `${folder}/_authors`;
for (const rec of authors) {
const safe = this.authorNameToSafe(rec.name);
const path = `${dir}/${safe}-${rec.id}.md`;
// Skip if a stub for this id already exists anywhere in this dir
// (under any name) — don't duplicate after a rename.
const exists = this.app.vault.getMarkdownFiles().some(
(f) => f.path.startsWith(dir + "/") && this.parseAuthorFilePath(f.path)?.id === rec.id,
);
if (exists) continue;
try {
await this.ensureFolderPath(dir);
if (await this.app.vault.adapter.exists(path)) continue;
await this.app.vault.create(path, this.buildAuthorStub(rec, rec.firstSeen ?? new Date().toISOString()));
created++;
} catch (e) {
console.warn("[Stashpad] restore author stub failed", path, e);
}
}
}
return { created, folders: folders.length };
}
/** 0.77.7: ensure the LOCAL user's author page exists in `folder`,
* creating it from settings if missing. Targeted counterpart to
* restoreMissingAuthorStubs seeds only YOUR page (not every known
* author), so links/quick-switcher resolve in every folder without the
* N×M clutter of propagating coworker pages into folders they've never
* touched. No-op if your name isn't set or a stub for your id already
* exists in that folder (under any name). */
async seedLocalAuthorStub(folder: string): Promise<boolean> {
const id = (this.settings.authorId ?? "").trim();
const name = (this.settings.authorName ?? "").trim();
if (!id || !name) return false;
const dir = `${folder.replace(/\/+$/, "")}/_authors`;
const exists = this.app.vault.getMarkdownFiles().some(
(f) => f.path.startsWith(dir + "/") && this.parseAuthorFilePath(f.path)?.id === id,
);
if (exists) return false;
const safe = this.authorNameToSafe(name);
const path = `${dir}/${safe}-${id}.md`;
try {
await this.ensureFolderPath(dir);
if (await this.app.vault.adapter.exists(path)) return false;
await this.app.vault.create(path, this.buildAuthorStub(
{ id, name, role: this.settings.authorRole, department: this.settings.authorDepartment },
new Date().toISOString(),
));
this.authorRegistry.record({ id, name, role: this.settings.authorRole, department: this.settings.authorDepartment });
return true;
} catch (e) {
console.warn("[Stashpad] seedLocalAuthorStub failed", path, e);
return false;
}
}
/** Seed the local user's author page into every discovered Stashpad
* folder that lacks it. Run once at startup so existing folders get
* backfilled; new folders are handled at creation time. */
async seedLocalAuthorStubsEverywhere(): Promise<number> {
const name = (this.settings.authorName ?? "").trim();
if (!name) return 0;
let created = 0;
for (const folder of this.discoverStashpadFolders()) {
if (await this.seedLocalAuthorStub(folder)) created++;
}
return created;
}
/** mkdir a vault dir path, intermediates included. Tolerates races and
* the "already exists" error Obsidian sometimes throws. */
private async ensureFolderPath(dir: string): Promise<void> {
const adapter = this.app.vault.adapter;
const parts = dir.split("/").filter(Boolean);
let cur = "";
for (const p of parts) {
cur = cur ? `${cur}/${p}` : p;
try { if (!(await adapter.exists(cur))) await adapter.mkdir(cur); }
catch (e) { if (!/already exists/i.test((e as Error).message)) throw e; }
}
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) ?? {};
// Migrate legacy `confirmMultiDelete` (split in 0.51.12 into two flags:
@ -2206,6 +2577,19 @@ export default class StashpadPlugin extends Plugin {
async saveSettings(): Promise<void> {
await this.queueWrite();
setSettings(this.settings);
// 0.77.1: keep the registry's record of the local user current. The
// registry is a recovery cache — recording here means a name/role/
// department change is remembered (with rename history) even if the
// _authors stubs are later deleted.
const id = (this.settings.authorId ?? "").trim();
if (id) {
this.authorRegistry.record({
id,
name: this.settings.authorName,
role: this.settings.authorRole,
department: this.settings.authorDepartment,
});
}
console.debug("[Stashpad] saveSettings", {
shortcuts: this.settings.shortcuts,
mod: this.settings.mod,

View file

@ -1,4 +1,5 @@
import { App, Modal, Platform, moment, Notice, setIcon } from "obsidian";
import { buildTimePickerInto } from "./time-picker";
import type { NotificationCategory, NotificationKind, NotificationRecord, NotificationService } from "./notifications";
interface LogEv { ts: string; type: string; id: string; payload?: any; author?: string; }
@ -864,7 +865,11 @@ export class DueDatePickerModal extends Modal {
// 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 */ } };
timeIcon.onclick = () => { try { (timeInput 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);
@ -886,13 +891,27 @@ export class DueDatePickerModal extends Modal {
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 = () => { this.didChoose = true; this.close(); this.onPick(null); };
clear.onclick = () => {
dateInput.value = "";
timeInput.value = "";
dateInput.focus();
};
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 = () => {
if (!dateInput.value) { new Notice("Pick a date first (or use Clear)."); return; }
// Empty Set = remove the due date.
if (!dateInput.value) {
this.didChoose = true;
this.close();
this.onPick(null);
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;
@ -905,7 +924,72 @@ export class DueDatePickerModal extends Modal {
requestAnimationFrame(() => dateInput.focus());
}
onClose(): void { this.contentEl.empty(); void this.didChoose; }
onClose(): void {
this.tinyClosePopover?.();
this.contentEl.empty();
void this.didChoose;
}
/** 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?.();
// 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);
const pop = document.body.createDiv({ cls: "stashpad-when-popover stashpad-due-time-pop" });
pop.style.position = "fixed";
// Above the modal (Obsidian modals sit ~var(--layer-modal)).
pop.style.zIndex = "9999";
let onEnter: (() => void) | null = null;
const close = (): void => {
pop.remove();
document.removeEventListener("mousedown", outside, true);
document.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.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 220))}px`;
pop.style.top = `${rect.bottom + 4}px`;
setTimeout(() => {
document.addEventListener("mousedown", outside, true);
document.addEventListener("keydown", onKey, true);
}, 0);
}
private startOfTodayLocal(): Date {
const d = new Date();

View file

@ -2,6 +2,7 @@ import { App, FuzzySuggestModal, Platform, Scope, SuggestModal, TFile, moment, s
import type { TreeIndex } from "./tree-index";
import type { TreeNode } from "./types";
import { ROOT_ID } from "./types";
import { buildTimePickerInto, formatWhenTime } from "./time-picker";
/** Parsed shape of a search query string. The original free-text tokens
* go into `text` (token-order-agnostic match against title/body); each
@ -776,6 +777,20 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
this.mountFilterChipRow(inputEl, resultsEl);
return;
}
// 0.76.36: on mobile, when the picker opens while the composer holds
// the soft keyboard, focus doesn't move to the picker input on its
// own — typed queries land in the composer. Obsidian's SuggestModal
// deliberately doesn't autofocus its input on mobile (to avoid popping
// the keyboard). We DO want it here, and crucially this onOpen runs
// synchronously inside Modal.open(), which we call inside the button's
// tap handler — so this focus() executes while the user gesture is
// still live, letting iOS hop the keyboard from the composer textarea
// straight to the picker input WITHOUT dismissing it. (Deferred
// focus() via setTimeout would be outside the gesture and fail.)
if (Platform.isMobile) {
const inputEl = (this as any).inputEl as HTMLInputElement | undefined;
if (inputEl) inputEl.focus();
}
}
private mountFilterChipRow(inputEl: HTMLInputElement, resultsEl: HTMLElement): void {
@ -1373,158 +1388,15 @@ export class StashpadSuggest extends SuggestModal<PickerItem> {
seedPeriod: "am" | "pm",
): void => {
openInsertPopover(anchor, (pop, close, setOnEnter) => {
pop.addClass("stashpad-when-pop-time");
let period = seedPeriod;
// Top row: HH : MM AM/PM toggle
const display = pop.createDiv({ cls: "stashpad-when-time-display" });
const hField = display.createEl("input", {
cls: "stashpad-when-time-field",
attr: { type: "text", inputmode: "numeric", maxlength: "2" },
}) as HTMLInputElement;
hField.value = String(seedH);
display.createSpan({ cls: "stashpad-when-time-colon", text: ":" });
const mField = display.createEl("input", {
cls: "stashpad-when-time-field",
attr: { type: "text", inputmode: "numeric", maxlength: "2" },
}) as HTMLInputElement;
mField.value = String(seedM).padStart(2, "0");
const periodWrap = display.createDiv({ cls: "stashpad-when-time-period" });
const amBtn = periodWrap.createEl("button", { cls: "stashpad-when-time-ampm", text: "AM" });
amBtn.type = "button";
const pmBtn = periodWrap.createEl("button", { cls: "stashpad-when-time-ampm", text: "PM" });
pmBtn.type = "button";
const syncPeriod = (): void => {
amBtn.toggleClass("is-active", period === "am");
pmBtn.toggleClass("is-active", period === "pm");
};
syncPeriod();
amBtn.addEventListener("mousedown", (ev) => ev.preventDefault());
pmBtn.addEventListener("mousedown", (ev) => ev.preventDefault());
amBtn.addEventListener("click", (ev) => { ev.preventDefault(); period = "am"; syncPeriod(); });
pmBtn.addEventListener("click", (ev) => { ev.preventDefault(); period = "pm"; syncPeriod(); });
// Track focused field for the numpad. When a field gains focus
// we select-all so the next digit replaces (0.69.15 fix).
let focused: HTMLInputElement = hField;
hField.addEventListener("focus", () => { focused = hField; hField.select(); });
mField.addEventListener("focus", () => { focused = mField; mField.select(); });
/** 0.69.15: when HH exceeds 12, AM/PM is meaningless (24h mode)
* grey out both buttons and treat the value as 24-hour. */
const syncAmpmEnabled = (): void => {
const h = parseInt(hField.value || "0", 10) || 0;
const is24 = h > 12;
amBtn.toggleClass("is-disabled", is24);
pmBtn.toggleClass("is-disabled", is24);
amBtn.disabled = is24;
pmBtn.disabled = is24;
};
// Constrain typed input — digits only, max 2 chars, clamp to range.
// 0.69.15: hours now accept 0-24 (24h), minutes 0-59.
const clamp = (el: HTMLInputElement): void => {
let v = el.value.replace(/\D/g, "").slice(0, 2);
if (v === "") { el.value = ""; if (el === hField) syncAmpmEnabled(); return; }
let n = parseInt(v, 10);
if (el === hField) { if (n > 24) n = 24; }
else { if (n > 59) n = 59; }
el.value = String(n);
if (el === hField) syncAmpmEnabled();
};
for (const el of [hField, mField]) {
el.addEventListener("input", () => clamp(el));
}
syncAmpmEnabled();
const finalize = (): void => {
const hNum = parseInt(hField.value || "12", 10) || 12;
const mm = parseInt(mField.value || "0", 10) || 0;
// 0.69.15: when HH > 12, output 24-hour without am/pm suffix.
// Otherwise output 12-hour with the toggled am/pm.
const time = hNum > 12
? `${hNum}:${String(mm).padStart(2, "0")}`
: `${hNum}:${String(mm).padStart(2, "0")}${period}`;
insertAtCursor(targetInput, time);
close();
};
// 0.69.29: route popover-level Enter to finalize so it
// works regardless of which sub-element has focus (numpad
// buttons aren't focusable enough for the DOM Enter listener
// to be reliable).
setOnEnter(finalize);
// 0.69.15: list every tabbable element in the popover and trap
// Tab on each to cycle within (Tab forward, Shift+Tab back).
// Numpad buttons are constructed below — we collect them after.
const tabRing: HTMLElement[] = [hField, mField, amBtn, pmBtn];
const cycleFocus = (cur: HTMLElement, dir: 1 | -1): void => {
const idx = tabRing.indexOf(cur);
if (idx === -1) return;
const len = tabRing.length;
const next = (idx + dir + len) % len;
tabRing[next].focus();
};
const trapKey = (el: HTMLElement): void => {
el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); finalize(); }
else if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); close(); }
else if (ev.key === "Tab") {
ev.preventDefault();
ev.stopPropagation();
cycleFocus(el, ev.shiftKey ? -1 : 1);
}
});
};
// Numpad — 3x4 grid: 1 2 3 / 4 5 6 / 7 8 9 / ⌫ 0 Insert.
const pad = pop.createDiv({ cls: "stashpad-when-time-pad" });
const keys = ["1","2","3","4","5","6","7","8","9","backspace","0","insert"];
let okBtn: HTMLButtonElement | null = null;
for (const key of keys) {
const b = pad.createEl("button", { cls: "stashpad-when-time-padbtn" });
b.type = "button";
if (key === "backspace") setIcon(b, "delete");
else if (key === "insert") { b.setText("OK"); okBtn = b; }
else b.setText(key);
if (key === "insert") b.addClass("is-go");
b.addEventListener("mousedown", (ev) => ev.preventDefault());
b.addEventListener("click", (ev) => {
ev.preventDefault();
if (key === "insert") { finalize(); return; }
if (key === "backspace") {
focused.value = focused.value.slice(0, -1);
clamp(focused);
focused.focus();
return;
}
// 0.69.15: if the field's current value is fully selected
// (e.g. just got focus and select() ran), REPLACE with the
// digit. Otherwise append until the 2-char cap, then replace.
const allSelected =
focused.selectionStart === 0 &&
focused.selectionEnd === focused.value.length &&
focused.value.length > 0;
const cap = 2;
const next = allSelected || focused.value.length >= cap
? key
: focused.value + key;
focused.value = next;
clamp(focused);
focused.focus();
// Move caret to end so subsequent presses append.
focused.setSelectionRange(focused.value.length, focused.value.length);
// Auto-advance from hours to minutes once full.
if (focused === hField && focused.value.length >= cap) {
mField.focus();
mField.select();
}
});
}
// 0.69.15: complete the tab ring (HH → MM → AM → PM → OK → HH).
if (okBtn) tabRing.push(okBtn);
for (const el of tabRing) trapKey(el);
hField.focus();
hField.select();
// 0.76.23: the picker UI now lives in src/time-picker.ts so
// the due-date modal can reuse the exact same control. This
// host keeps the SuggestModal scope-aware popover behaviour;
// the shared builder fills it + reports the result, which we
// format the same way as before and insert at the caret.
buildTimePickerInto(pop, {
seedH, seedM, seedPeriod, close, setOnEnter,
onFinalize: (r) => insertAtCursor(targetInput, formatWhenTime(r)),
});
});
};

View file

@ -536,16 +536,24 @@ export class StashpadSettingTab extends PluginSettingTab {
/** Hide every .setting-item whose name + desc doesn't match the
* current query, then collapse any group whose body has no
* visible matches. Called after rendering in search mode. */
* visible matches. Called after rendering in search mode.
*
* 0.76.24: all-tokens, order-agnostic matching (same approach as
* the composer's link/tag autocomplete) instead of a single
* substring. The query is split on whitespace; EVERY token must
* appear somewhere in the item's name+desc, in any order so
* "folder import" matches "Stash import subfolder" and "date tz"
* matches "Display timezone" via the date-format neighbours, etc. */
private applySearchFilter(body: HTMLElement): void {
const q = this.searchQuery.trim().toLowerCase();
if (!q) return;
const tokens = q.split(/\s+/).filter(Boolean);
const matchesAll = (haystack: string): boolean => tokens.every((t) => haystack.includes(t));
const items = body.querySelectorAll<HTMLElement>(".setting-item");
items.forEach((el) => {
const name = el.querySelector(".setting-item-name")?.textContent?.toLowerCase() ?? "";
const desc = el.querySelector(".setting-item-description")?.textContent?.toLowerCase() ?? "";
const match = name.includes(q) || desc.includes(q);
el.style.display = match ? "" : "none";
el.style.display = matchesAll(`${name} ${desc}`) ? "" : "none";
});
body.querySelectorAll<HTMLElement>(".stashpad-settings-search-group").forEach((g) => {
const any = Array.from(g.querySelectorAll<HTMLElement>(".setting-item"))
@ -706,12 +714,13 @@ export class StashpadSettingTab extends PluginSettingTab {
b.setButtonText("Rebootstrap now").onClick(async () => {
b.setDisabled(true).setButtonText("Working…");
try {
const { touched, fmChecked, fmWritten, slugsRenamed } = await this.plugin.rebootstrapAllFolders();
const { touched, fmChecked, fmWritten, slugsRenamed, authors } = await this.plugin.rebootstrapAllFolders();
const parts: string[] = [];
parts.push(`rebootstrapped ${touched.length} folder${touched.length === 1 ? "" : "s"}`);
if (fmWritten > 0) parts.push(`updated frontmatter on ${fmWritten} of ${fmChecked} notes`);
else if (fmChecked > 0) parts.push(`frontmatter already in sync (${fmChecked} notes checked)`);
if (slugsRenamed > 0) parts.push(`renamed ${slugsRenamed} note${slugsRenamed === 1 ? "" : "s"} to match body`);
if (authors > 0) parts.push(`rebuilt author registry (${authors} author${authors === 1 ? "" : "s"})`);
new Notice(`Stashpad: ${parts.join("; ")}.`);
} catch (e) {
new Notice(`Stashpad: rebootstrap failed (${(e as Error).message})`);
@ -1341,6 +1350,64 @@ export class StashpadSettingTab extends PluginSettingTab {
row.createSpan({ cls: "stashpad-authored-folder-counts", text: ` · ${counts.join(", ")}` });
}
}
this.renderKnownAuthorsSection(parent);
}
/** 0.77.5: surface the author registry a rebuildable cache + rename
* history of every author the plugin has seen. Lists known authors
* with role/department + rename history, plus rebuild/restore actions.
* The registry is NOT authoritative (the id baked into note frontmatter
* is); this is recovery + an audit trail. */
private renderKnownAuthorsSection(parent: HTMLElement): void {
parent.createEl("h4", { text: "Known authors (registry)" });
parent.createEl("div", {
cls: "setting-item-description",
text: "A rebuildable cache of every author Stashpad has seen, with rename history. Not a source of truth — the author id stored in each note is authoritative. Use it to recover deleted author pages or audit name changes.",
});
new Setting(parent)
.setName("Registry maintenance")
.setDesc("Rebuild scans the whole vault to reconstruct the list. Restore regenerates any deleted author pages across every Stashpad folder.")
.addButton((b) => b.setButtonText("Rebuild").onClick(async () => {
b.setDisabled(true).setButtonText("Rebuilding…");
try {
const r = await this.plugin.rebuildAuthorRegistry();
new Notice(`Author registry rebuilt: ${r.total} author(s).`);
} catch (e) { new Notice(`Rebuild failed: ${(e as Error).message}`); }
b.setDisabled(false).setButtonText("Rebuild");
this.display();
}))
.addButton((b) => b.setButtonText("Restore missing pages").onClick(async () => {
b.setDisabled(true).setButtonText("Restoring…");
try {
const r = await this.plugin.restoreMissingAuthorStubs();
new Notice(r.created > 0 ? `Restored ${r.created} author page(s).` : "No missing author pages.");
} catch (e) { new Notice(`Restore failed: ${(e as Error).message}`); }
b.setDisabled(false).setButtonText("Restore missing pages");
}));
const authors = this.plugin.authorRegistry.all();
if (authors.length === 0) {
parent.createEl("div", { cls: "setting-item-description", text: "No authors recorded yet. Rebuild to scan the vault." });
return;
}
const list = parent.createDiv({ cls: "stashpad-known-authors-list" });
for (const a of authors) {
const row = list.createDiv({ cls: "stashpad-known-author-row" });
const main = row.createDiv({ cls: "stashpad-known-author-main" });
main.createSpan({ cls: "stashpad-known-author-name", text: a.name || "(unnamed)" });
const meta: string[] = [];
if (a.role) meta.push(a.role);
if (a.department) meta.push(a.department);
meta.push(`id ${a.id}`);
main.createSpan({ cls: "stashpad-known-author-meta", text: ` · ${meta.join(" · ")}` });
if (a.renames && a.renames.length > 0) {
const hist = row.createDiv({ cls: "stashpad-known-author-history" });
const trail = a.renames.map((r) => `${r.from}${r.to}`).join(", ");
hist.setText(`Renamed: ${trail}`);
}
}
}
private renderNoteTemplatesSection(parent: HTMLElement): void {
@ -1391,9 +1458,14 @@ export class StashpadSettingTab extends PluginSettingTab {
const renderSuggestions = (): void => {
sugg.empty();
const q = input.value.trim().toLowerCase();
// 0.76.26: Sift — all-tokens, any-order match (see docs/sift.md).
const tokens = input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const sift = (p: string): boolean => {
const h = p.toLowerCase();
return tokens.every((t) => h.includes(t));
};
const matches = allMd()
.filter((p) => !q || p.toLowerCase().includes(q))
.filter((p) => sift(p))
.slice(0, 12);
if (matches.length === 0) { sugg.style.display = "none"; return; }
sugg.style.display = "";

174
src/time-picker.ts Normal file
View file

@ -0,0 +1,174 @@
import { setIcon } from "obsidian";
/** 0.76.23: the numpad time-picker UI, extracted from the search
* When-builder so the due-date modal can reuse the exact same
* control. PURE UI it builds its widgets into a host element you
* provide and reports the result via `onFinalize`; the caller owns
* the popover lifecycle (positioning, click-outside, close). That
* keeps the search picker's scope-aware host and the modal's plain
* host independent while sharing one control. */
export interface TimePickResult {
/** 24-hour hours (023) — convenient for native <input type=time>. */
hours24: number;
minutes: number;
/** Raw entry so a caller can reproduce an exact display string:
* `hh` is what was typed (124), `period` the am/pm toggle, and
* `is24` true when hh > 12 (am/pm meaningless). */
raw: { hh: number; mm: number; period: "am" | "pm"; is24: boolean };
}
export interface BuildTimePickerOpts {
seedH: number; // 124 (or 112) to prefill the HH field
seedM: number; // 059
seedPeriod: "am" | "pm";
onFinalize: (r: TimePickResult) => void;
/** Close the host popover (called after finalize / on OK). */
close: () => void;
/** Register the host's Enter handler so Enter anywhere finalizes. */
setOnEnter?: (cb: () => void) => void;
}
/** Convert a typed (hh 124, period) into 24-hour hours. */
function to24(hh: number, period: "am" | "pm"): number {
if (hh > 12) return hh >= 24 ? 0 : hh; // already 24-hour
if (period === "am") return hh === 12 ? 0 : hh;
return hh === 12 ? 12 : hh + 12;
}
/** Build the numpad time picker into `pop`. Mirrors the search
* When-builder's control exactly (HH : MM + AM/PM toggle on top,
* 3×4 numpad below). */
export function buildTimePickerInto(pop: HTMLElement, opts: BuildTimePickerOpts): void {
pop.addClass("stashpad-when-pop-time");
let period = opts.seedPeriod;
const display = pop.createDiv({ cls: "stashpad-when-time-display" });
const hField = display.createEl("input", {
cls: "stashpad-when-time-field",
attr: { type: "text", inputmode: "numeric", maxlength: "2" },
}) as HTMLInputElement;
hField.value = String(opts.seedH);
display.createSpan({ cls: "stashpad-when-time-colon", text: ":" });
const mField = display.createEl("input", {
cls: "stashpad-when-time-field",
attr: { type: "text", inputmode: "numeric", maxlength: "2" },
}) as HTMLInputElement;
mField.value = String(opts.seedM).padStart(2, "0");
const periodWrap = display.createDiv({ cls: "stashpad-when-time-period" });
const amBtn = periodWrap.createEl("button", { cls: "stashpad-when-time-ampm", text: "AM" });
amBtn.type = "button";
const pmBtn = periodWrap.createEl("button", { cls: "stashpad-when-time-ampm", text: "PM" });
pmBtn.type = "button";
const syncPeriod = (): void => {
amBtn.toggleClass("is-active", period === "am");
pmBtn.toggleClass("is-active", period === "pm");
};
syncPeriod();
amBtn.addEventListener("mousedown", (ev) => ev.preventDefault());
pmBtn.addEventListener("mousedown", (ev) => ev.preventDefault());
amBtn.addEventListener("click", (ev) => { ev.preventDefault(); period = "am"; syncPeriod(); });
pmBtn.addEventListener("click", (ev) => { ev.preventDefault(); period = "pm"; syncPeriod(); });
let focused: HTMLInputElement = hField;
hField.addEventListener("focus", () => { focused = hField; hField.select(); });
mField.addEventListener("focus", () => { focused = mField; mField.select(); });
const syncAmpmEnabled = (): void => {
const h = parseInt(hField.value || "0", 10) || 0;
const is24 = h > 12;
amBtn.toggleClass("is-disabled", is24);
pmBtn.toggleClass("is-disabled", is24);
amBtn.disabled = is24;
pmBtn.disabled = is24;
};
const clamp = (el: HTMLInputElement): void => {
const v = el.value.replace(/\D/g, "").slice(0, 2);
if (v === "") { el.value = ""; if (el === hField) syncAmpmEnabled(); return; }
let n = parseInt(v, 10);
if (el === hField) { if (n > 24) n = 24; }
else { if (n > 59) n = 59; }
el.value = String(n);
if (el === hField) syncAmpmEnabled();
};
for (const el of [hField, mField]) el.addEventListener("input", () => clamp(el));
syncAmpmEnabled();
const finalize = (): void => {
const hh = parseInt(hField.value || "12", 10) || 12;
const mm = parseInt(mField.value || "0", 10) || 0;
const is24 = hh > 12;
opts.onFinalize({
hours24: to24(hh, period),
minutes: mm,
raw: { hh, mm, period, is24 },
});
opts.close();
};
opts.setOnEnter?.(finalize);
const tabRing: HTMLElement[] = [hField, mField, amBtn, pmBtn];
const cycleFocus = (cur: HTMLElement, dir: 1 | -1): void => {
const idx = tabRing.indexOf(cur);
if (idx === -1) return;
const next = (idx + dir + tabRing.length) % tabRing.length;
tabRing[next].focus();
};
const trapKey = (el: HTMLElement): void => {
el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); finalize(); }
else if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); opts.close(); }
else if (ev.key === "Tab") { ev.preventDefault(); ev.stopPropagation(); cycleFocus(el, ev.shiftKey ? -1 : 1); }
});
};
const pad = pop.createDiv({ cls: "stashpad-when-time-pad" });
const keys = ["1","2","3","4","5","6","7","8","9","backspace","0","insert"];
let okBtn: HTMLButtonElement | null = null;
for (const key of keys) {
const b = pad.createEl("button", { cls: "stashpad-when-time-padbtn" });
b.type = "button";
if (key === "backspace") setIcon(b, "delete");
else if (key === "insert") { b.setText("OK"); okBtn = b; }
else b.setText(key);
if (key === "insert") b.addClass("is-go");
b.addEventListener("mousedown", (ev) => ev.preventDefault());
b.addEventListener("click", (ev) => {
ev.preventDefault();
if (key === "insert") { finalize(); return; }
if (key === "backspace") {
focused.value = focused.value.slice(0, -1);
clamp(focused);
focused.focus();
return;
}
const allSelected =
focused.selectionStart === 0 &&
focused.selectionEnd === focused.value.length &&
focused.value.length > 0;
const cap = 2;
const next = allSelected || focused.value.length >= cap ? key : focused.value + key;
focused.value = next;
clamp(focused);
focused.focus();
focused.setSelectionRange(focused.value.length, focused.value.length);
if (focused === hField && focused.value.length >= cap) { mField.focus(); mField.select(); }
});
}
if (okBtn) tabRing.push(okBtn);
for (const el of tabRing) trapKey(el);
hField.focus();
hField.select();
}
/** Format a finalized pick the way the search When-builder inserts it:
* `h:MMam` / `h:MMpm` for 12-hour, or `HH:MM` (no suffix) for 24-hour
* entries. */
export function formatWhenTime(r: TimePickResult): string {
const { hh, mm, period, is24 } = r.raw;
return is24
? `${hh}:${String(mm).padStart(2, "0")}`
: `${hh}:${String(mm).padStart(2, "0")}${period}`;
}

View file

@ -188,6 +188,15 @@ export class TreeIndex {
.filter((n): n is TreeNode => !!n);
}
/** 0.76.30: number of nodes backed by an actual file. Compared
* against the on-disk Stashpad-note count to detect a tree that
* drifted out of sync (mobile cold start / post-sync burst). */
fileBackedCount(): number {
let n = 0;
for (const node of this.nodes.values()) if (node.file) n++;
return n;
}
pathTo(id: StashpadId): TreeNode[] {
const out: TreeNode[] = [];
let cur = this.nodes.get(id);

View file

@ -1,6 +1,7 @@
import {
FuzzySuggestModal, ItemView, MarkdownRenderer, Menu, Notice, Platform,
Scope, SuggestModal, TFile, TFolder, WorkspaceLeaf, debounce, moment, setIcon,
Scope, SuggestModal, TFile, TFolder, WorkspaceLeaf, debounce,
moment, setIcon,
} from "obsidian";
import {
ROOT_ID, STASHPAD_VIEW_TYPE, RESERVED_FRONTMATTER, fmHasTag, fmAddTag, fmRemoveTag,
@ -26,6 +27,30 @@ import type StashpadPlugin from "./main";
const IMG_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]);
/** 0.76.33: setIcon that never leaves a blank button. If `name` isn't
* in this Obsidian build's bundled Lucide set (older iPad/iOS app
* versions lag desktop, so some names that resolve on desktop don't
* resolve there), setIcon injects no <svg> and the button renders
* empty. We detect that (setIcon is synchronous) and drop in a
* Unicode glyph so there's always a visible affordance. */
function setIconSafe(el: HTMLElement, name: string, fallbackGlyph: string): void {
el.empty();
try { setIcon(el, name); } catch { /* ignore */ }
// The ONLY reliable signal that the icon actually rendered is the
// presence of a drawable shape element inside the injected <svg>.
// Older/stripped mobile Lucide bundles can inject an empty (or
// whitespace-only) <svg class="svg-icon"></svg> for names they don't
// know — an svg node that exists but draws nothing. Checking for a
// path/line/circle/etc. distinguishes "real icon" from "empty shell".
const svg = el.querySelector("svg");
const drawn = !!svg && !!svg.querySelector(
"path, line, circle, rect, polyline, polygon, ellipse"
);
if (drawn) return;
el.empty();
el.createSpan({ cls: "stashpad-icon-fallback", text: fallbackGlyph });
}
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
nested: "Nested",
flat: "Flat",
@ -124,6 +149,10 @@ export class StashpadView extends ItemView {
* survives reloads. */
private tinyMode = false;
private tinyAlwaysOnTop = false;
/** 0.77.0-feat: tiny-mode popout window opacity (0.31.0). Electron
* `BrowserWindow.setOpacity` desktop popouts only; a no-op on
* mobile / non-popout. Persisted via view state. 1 = fully opaque. */
private tinyOpacity = 1;
/** 0.61.2: compact mode like tiny mode but stays in the current
* tab/leaf (no popout, no resize). Hides the time filter row and
* focused-header; keeps breadcrumb + list + composer. Persisted. */
@ -184,6 +213,13 @@ export class StashpadView extends ItemView {
/** When true, the listResizeObserver re-pins scroll to the bottom each time
* the list grows. Set after scrollListToBottom; cleared on user scroll. */
private stickToListBottom = false;
/** 0.76.27: timestamp until which the listResizeObserver ignores
* scroll adjustments. Set on mobile composer focus/blur the
* keyboard show/hide resizes the list, which otherwise fired the
* observer and yanked the scroll position each time (the list
* "moving" on every composer interaction). During this window we
* let the browser's own reflow settle without fighting it. */
private keyboardTransitionUntil = 0;
/** Per-row ResizeObserver attached during scrollListToBottom re-pins
* the list to the bottom whenever a row's height changes. Survives
* past the initial paint so cold-cache markdown / late font loads
@ -203,6 +239,12 @@ export class StashpadView extends ItemView {
private lastCursorByFocus = new Map<StashpadId, StashpadId>();
private expandedNotes = new Set<StashpadId>();
private focusComposerOnNextRender = false;
/** 0.76.21: timestamp until which the activation auto-focus
* (focusComposer) is suppressed. Set after actions that close a
* modal and re-activate the leaf (e.g. Split) the leaf
* re-activation otherwise yanks focus into the composer regardless
* of the autofocus-after-send setting. */
private suppressComposerFocusUntil = 0;
/** Debounced wrapper around saveDraft for the input event. Lazily
* initialized on first composer render. */
private debouncedSaveDraft?: (v: string) => void;
@ -367,6 +409,17 @@ export class StashpadView extends ItemView {
this.register(() => popViewScope());
this.detachTreeHook = this.tree.hookMetadataCache(() => this.debouncedRender());
// 0.76.30: self-heal stale trees after a sync burst / cold start.
// The per-file create/changed hooks above can miss files that
// sync in before the view's listeners attach (mobile cold start)
// or land in a burst — leaving the folder showing fewer notes
// (or a stale layout) until a manual reload. metadataCache
// "resolved" fires when Obsidian finishes (re)indexing, which is
// exactly when synced-in files become known; reconcile then. The
// reconcile only rebuilds + renders when this folder's markdown
// file count actually differs from the tree, so it's a no-op
// during normal editing.
this.registerEvent(this.app.metadataCache.on("resolved", () => this.scheduleTreeReconcile()));
// 0.76.11: keep the authoritative completed-state map in sync with
// the metadataCache. A "changed" event means the cache is fresh
// for that file, so re-sync our cached value from it. This is what
@ -510,6 +563,37 @@ export class StashpadView extends ItemView {
}));
}
/** 0.76.30: debounced reconcile against the metadata cache. Counts
* the markdown files actually under this folder and, if that count
* differs from what the tree knows, rebuilds + re-renders so a
* folder that mounted with a stale/partial tree (mobile cold start,
* post-sync burst) self-heals without a manual reload. No-op when
* the counts already match. */
private treeReconcileTimer: number | null = null;
private scheduleTreeReconcile(): void {
if (this.treeReconcileTimer != null) return;
this.treeReconcileTimer = window.setTimeout(() => {
this.treeReconcileTimer = null;
if (!this.viewRoot?.isConnected) return;
const folder = this.noteFolder;
const prefix = folder + "/";
// Count actual Stashpad NOTES on disk (markdown files under this
// folder whose frontmatter carries an id) — matching what the
// tree tracks. Counting all markdown would over-count _authors
// stubs / templates and trigger perpetual no-op rebuilds.
let onDisk = 0;
for (const f of this.app.vault.getMarkdownFiles()) {
const dir = f.parent?.path?.replace(/\/+$/, "") ?? "";
if (!(dir === folder || (folder !== "" && dir.startsWith(prefix)))) continue;
const id = this.app.metadataCache.getFileCache(f)?.frontmatter?.id;
if (typeof id === "string" && id) onDisk++;
}
if (onDisk === this.tree.fileBackedCount()) return; // in sync — no-op
this.tree.rebuild(folder);
this.debouncedRender();
}, 400);
}
private focusView(): void {
// Defer to next frame so Obsidian's own focus handling has settled first.
requestAnimationFrame(() => {
@ -525,8 +609,20 @@ export class StashpadView extends ItemView {
/** Focus the composer input. Used when activating the view so users can type immediately.
* Runs multiple times to outlast Obsidian's own focus management on leaf activation. */
private focusComposer(): void {
// 0.76.24: honour the autofocus setting. This activation auto-focus
// previously ignored it, so the composer kept grabbing focus on
// view open / leaf re-activation even with the setting OFF. When
// off, the user clicks the composer to type. (Focus PRESERVATION
// across renders — focusComposerOnNextRender — is separate and
// still works: it only re-focuses when the composer already had
// focus.)
if (!getSettings().autofocusComposerAfterSend) return;
const tryFocus = () => {
if (!this.viewRoot?.isConnected) return;
// 0.76.21: skip the activation auto-focus during the suppression
// window (set right after a Split etc. so the modal-close leaf
// re-activation doesn't steal focus into the composer).
if (Date.now() < this.suppressComposerFocusUntil) return;
const ae = document.activeElement as HTMLElement | null;
// Don't steal from another input/modal that the user is intentionally in.
if (ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA") && ae !== this.composerInputEl) return;
@ -557,6 +653,7 @@ export class StashpadView extends ItemView {
this.composerNarrowObserver = null;
this.focusedMiniObserver?.disconnect();
this.focusedMiniObserver = null;
if (this.treeReconcileTimer != null) { window.clearTimeout(this.treeReconcileTimer); this.treeReconcileTimer = null; }
this.composerAutocomplete?.detach();
this.composerAutocomplete = null;
for (const d of this.slugDebouncers.values()) d.cancel();
@ -609,6 +706,7 @@ export class StashpadView extends ItemView {
timeFilterCalendar: this.timeFilterCalendar,
tinyMode: this.tinyMode,
tinyAlwaysOnTop: this.tinyAlwaysOnTop,
tinyOpacity: this.tinyOpacity,
compactMode: this.compactMode,
// 0.67.2: persist nav stacks so reloads keep the back/forward
// history. Without this every reload starts the user with empty
@ -625,6 +723,7 @@ export class StashpadView extends ItemView {
timeFilterCalendar?: boolean;
tinyMode?: boolean;
tinyAlwaysOnTop?: boolean;
tinyOpacity?: number;
compactMode?: boolean;
navBackStack?: NavSnapshot[];
navForwardSnapshots?: NavSnapshot[];
@ -638,6 +737,9 @@ export class StashpadView extends ItemView {
if ("timeFilterCalendar" in s) this.timeFilterCalendar = !!s.timeFilterCalendar;
if ("tinyMode" in s) this.tinyMode = !!s.tinyMode;
if ("tinyAlwaysOnTop" in s) this.tinyAlwaysOnTop = !!s.tinyAlwaysOnTop;
if (typeof s.tinyOpacity === "number" && Number.isFinite(s.tinyOpacity)) {
this.tinyOpacity = Math.min(1, Math.max(0.3, s.tinyOpacity));
}
if ("compactMode" in s) this.compactMode = !!s.compactMode;
// 0.67.2: restore nav stacks from view state. Validate the
// shape so a malformed entry doesn't crash navigation later.
@ -1921,6 +2023,10 @@ export class StashpadView extends ItemView {
const targetList = this.listEl;
let settleTop = targetList.scrollTop;
const ro = new ResizeObserver(() => {
// 0.76.27: during a mobile keyboard show/hide the list resizes;
// don't touch scrollTop then, or the list visibly jumps on
// every composer tap. Let the browser's reflow settle.
if (Date.now() < this.keyboardTransitionUntil) return;
// Sticky-to-bottom mode: every growth of the list jumps to the new bottom.
if (this.stickToListBottom) {
targetList.scrollTop = targetList.scrollHeight;
@ -1993,7 +2099,7 @@ export class StashpadView extends ItemView {
// 0.68.4: icon-only Search button between the folder switcher and
// the tags dropdown. Mirrors the Mod+F binding for mouse users.
const searchBtn = bar.createEl("button", { cls: "stashpad-search-btn" });
setIcon(searchBtn, "search");
setIconSafe(searchBtn, "search", "🔍");
searchBtn.title = "Search notes (Mod+F)";
searchBtn.onclick = (e) => { e.preventDefault(); this.openSearchModal(); };
@ -2126,13 +2232,13 @@ export class StashpadView extends ItemView {
// sit at the start of the actions cluster so they're always
// visible alongside the breadcrumb.
const backBtn = actions.createEl("button", { cls: "stashpad-mobile-action-btn" });
setIcon(backBtn, "arrow-left");
setIconSafe(backBtn, "arrow-left", "");
const canGoBack = this.navBackStack.length > 0 || this.focusId !== ROOT_ID;
backBtn.title = this.navBackStack.length > 0 ? "Back" : (this.focusId !== ROOT_ID ? "Back (up to parent)" : "No back history");
if (!canGoBack) backBtn.addClass("is-disabled");
backBtn.onclick = (e) => { e.preventDefault(); this.navigateBack(); };
const fwdBtn = actions.createEl("button", { cls: "stashpad-mobile-action-btn" });
setIcon(fwdBtn, "arrow-right");
setIconSafe(fwdBtn, "arrow-right", "");
const canGoFwd = this.navForwardSnapshots.length > 0;
fwdBtn.title = canGoFwd ? "Forward" : "No forward history";
if (!canGoFwd) fwdBtn.addClass("is-disabled");
@ -2140,7 +2246,7 @@ export class StashpadView extends ItemView {
const selectBtn = actions.createEl("button", { cls: "stashpad-mobile-action-btn" });
const inSelect = this.mobileSelectMode;
setIcon(selectBtn, inSelect ? "check-square" : "square");
setIconSafe(selectBtn, inSelect ? "check-square" : "square", inSelect ? "☑" : "☐");
selectBtn.title = inSelect
? `${this.selection.size} selected — tap to exit (keeps the first selection)`
: "Enter select mode (tap notes to add)";
@ -2177,7 +2283,7 @@ export class StashpadView extends ItemView {
};
const moreBtn = actions.createEl("button", { cls: "stashpad-mobile-action-btn" });
setIcon(moreBtn, "zap");
setIconSafe(moreBtn, "zap", "⚡");
moreBtn.title = "Actions (move, delete, undo, …)";
moreBtn.onclick = (e) => {
e.preventDefault();
@ -2936,7 +3042,7 @@ export class StashpadView extends ItemView {
// both the view-header and the breadcrumb, so without these the
// user is stuck unless they ⤢ out of tiny mode first.
const backBtn = bar.createEl("button", { cls: "stashpad-tiny-nav-btn" });
setIcon(backBtn, "arrow-left");
setIconSafe(backBtn, "arrow-left", "");
backBtn.title = "Back (up to parent)";
const tinyCanBack = this.navBackStack.length > 0 || this.focusId !== ROOT_ID;
if (!tinyCanBack) backBtn.addClass("is-disabled");
@ -2945,7 +3051,7 @@ export class StashpadView extends ItemView {
: (this.focusId !== ROOT_ID ? "Back (up to parent)" : "No back history");
backBtn.onclick = () => this.navigateBack();
const fwdBtn = bar.createEl("button", { cls: "stashpad-tiny-nav-btn" });
setIcon(fwdBtn, "arrow-right");
setIconSafe(fwdBtn, "arrow-right", "");
fwdBtn.title = this.navForwardSnapshots.length > 0 ? "Forward" : "No forward history";
if (this.navForwardSnapshots.length === 0) fwdBtn.addClass("is-disabled");
fwdBtn.onclick = () => this.navigateForward();
@ -2979,6 +3085,17 @@ export class StashpadView extends ItemView {
this.applyTinyAlwaysOnTop();
};
// 0.77.0-feat: window-transparency button. Desktop popouts only
// (Electron setOpacity) — hidden on mobile, where there's no
// window to make transparent. Click toggles a small slider popover.
if (!Platform.isMobile) {
const opacityBtn = bar.createEl("button", { cls: "stashpad-tiny-nav-btn stashpad-tiny-opacity-btn" });
setIcon(opacityBtn, "contrast");
opacityBtn.title = "Window transparency";
if (this.tinyOpacity < 1) opacityBtn.addClass("is-active");
opacityBtn.onclick = (e) => { e.stopPropagation(); this.toggleTinyOpacityPopover(opacityBtn); };
}
// 0.61.8: ALWAYS render the compact-toggle button in the tiny
// header. Carrying compactMode through to tiny was meant to surface
// the exit, but if a user enters tiny WITHOUT being in compact
@ -3005,6 +3122,59 @@ export class StashpadView extends ItemView {
expandBtn.onclick = () => { void this.exitTinyMode(); };
}
/** 0.77.0-feat: handle to the open opacity popover so a second click
* (or click-outside) closes it. */
private tinyOpacityPopover: HTMLElement | null = null;
private toggleTinyOpacityPopover(anchor: HTMLElement): void {
if (this.tinyOpacityPopover) {
this.tinyOpacityPopover.remove();
this.tinyOpacityPopover = null;
return;
}
const pop = document.createElement("div");
pop.className = "stashpad-tiny-opacity-popover";
pop.createSpan({ cls: "stashpad-tiny-opacity-label", text: "Transparency" });
const slider = pop.createEl("input", { type: "range" }) as HTMLInputElement;
slider.min = "30"; slider.max = "100"; slider.step = "1";
slider.value = String(Math.round(this.tinyOpacity * 100));
const pct = pop.createSpan({ cls: "stashpad-tiny-opacity-pct", text: `${slider.value}%` });
// Live-apply as the user drags — opacity is cheap to set.
slider.addEventListener("input", () => {
const v = Math.min(100, Math.max(30, parseInt(slider.value, 10) || 100));
this.tinyOpacity = v / 100;
pct.setText(`${v}%`);
this.applyTinyOpacity();
anchor.toggleClass("is-active", this.tinyOpacity < 1);
});
// Persist on release so the value survives reloads (view state).
slider.addEventListener("change", () => { this.app.workspace.requestSaveLayout(); });
// Position under the anchor button.
this.viewRoot.appendChild(pop);
const r = anchor.getBoundingClientRect();
const rootR = this.viewRoot.getBoundingClientRect();
pop.style.top = `${r.bottom - rootR.top + 4}px`;
pop.style.left = `${Math.max(4, Math.min(r.left - rootR.left, rootR.width - 180))}px`;
// Close on click-outside / Escape. Added next tick so the opening
// click doesn't immediately dismiss it.
const onDoc = (ev: Event) => {
if (pop.contains(ev.target as Node) || ev.target === anchor || anchor.contains(ev.target as Node)) return;
close();
};
const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") close(); };
const close = () => {
pop.remove();
this.tinyOpacityPopover = null;
document.removeEventListener("mousedown", onDoc, true);
document.removeEventListener("keydown", onKey, true);
};
setTimeout(() => {
document.addEventListener("mousedown", onDoc, true);
document.addEventListener("keydown", onKey, true);
}, 0);
this.tinyOpacityPopover = pop;
slider.focus();
}
/** Resolve the Electron BrowserWindow that hosts THIS view's leaf
* not the main app window. Each Obsidian popout runs its own renderer,
* so require() must be invoked through the leaf's owner-document's
@ -3067,6 +3237,18 @@ export class StashpadView extends ItemView {
}
}
/** 0.77.0-feat: push this.tinyOpacity onto the host BrowserWindow.
* Electron-only; silent no-op on mobile / sandboxed builds. Clamped
* to [0.3, 1] so the window can't vanish entirely. */
private applyTinyOpacity(): void {
const win = this.getOwnElectronWindow();
if (!win) return;
const o = Math.min(1, Math.max(0.3, this.tinyOpacity));
try { win.setOpacity?.(o); } catch (e) {
console.debug("[Stashpad] setOpacity failed", e);
}
}
/** Apply tiny-mode side-effects to the BrowserWindow that hosts this
* leaf: resize down + optionally pin always-on-top. Best-effort
* bails silently if Electron's window APIs aren't reachable
@ -3095,6 +3277,8 @@ export class StashpadView extends ItemView {
try { win.setBounds?.({ x, y, width: targetW, height: targetH }); } catch {}
try { win.setSize?.(targetW, targetH); } catch {}
win.setAlwaysOnTop?.(!!this.tinyAlwaysOnTop);
// 0.77.0-feat: restore the saved opacity when entering tiny.
try { win.setOpacity?.(Math.min(1, Math.max(0.3, this.tinyOpacity))); } catch {}
} else {
win.setAlwaysOnTop?.(false);
}
@ -3109,6 +3293,10 @@ export class StashpadView extends ItemView {
private async exitTinyMode(): Promise<void> {
this.tinyMode = false;
this.tinyAlwaysOnTop = false;
// 0.77.0-feat: restore full opacity on the way out so the
// expanded window isn't left see-through.
this.tinyOpacity = 1;
try { this.getOwnElectronWindow()?.setOpacity?.(1); } catch {}
// 0.61.10: also clear compact when leaving tiny. The user expected
// expand-out to restore the full chrome, not retain the compact
// row-stripping. (They can still toggle compact back on via the
@ -4019,8 +4207,9 @@ export class StashpadView extends ItemView {
// events don't fire reliably inside Obsidian's webview, so this is a
// more dependable proxy for "keyboard is showing right now."
if (Platform.isMobile) {
ta.addEventListener("focus", () => document.body.classList.add("stashpad-keyboard-open"));
ta.addEventListener("blur", () => document.body.classList.remove("stashpad-keyboard-open"));
const keyboardTransition = () => { this.keyboardTransitionUntil = Date.now() + 600; };
ta.addEventListener("focus", () => { document.body.classList.add("stashpad-keyboard-open"); keyboardTransition(); });
ta.addEventListener("blur", () => { document.body.classList.remove("stashpad-keyboard-open"); keyboardTransition(); });
}
this.composerInputEl = ta;
// Tear down any previous autocomplete (the textarea was just rebuilt
@ -4872,6 +5061,13 @@ export class StashpadView extends ItemView {
}
openDestinationPicker(): void {
// 0.76.36: do NOT blur the composer here. On iOS, blur() dismisses the
// soft keyboard, and once dismissed a programmatic focus() on the
// picker input can't bring it back (iOS only re-summons the keyboard
// inside a live user gesture). Instead the picker focuses its own
// input synchronously inside the tap gesture (see note-picker onOpen),
// which lets iOS hop the keyboard straight from the composer textarea
// to the picker input without ever dismissing it.
// 0.57.2: destination picker now spans all Stashpad folders + offers
// each external Stashpad's root (Home) as its own pick. Picking a
// cross-folder destination switches the view to that folder first
@ -6666,22 +6862,29 @@ export class StashpadView extends ItemView {
});
}
/** 0.76.11: authoritative completed-state per path. Decouples
* isCompleted from the live metadataCache, which can transiently
* return stale frontmatter for sibling rows during the synthetic
* create-render. Seeded lazily from the cache, kept fresh by the
* metadataCache "changed" listener + our own toggles. */
/** 0.76.11 / 0.76.32: completed-state OVERRIDE per path. Holds only
* values written authoritatively our own toggles + the
* metadataCache "changed" listener (which fires as files parse).
* isCompleted prefers an override when present (keeps a row stable
* during the synthetic create-render, when getFileCache can
* transiently return stale frontmatter for siblings); otherwise it
* reads the LIVE cache.
*
* 0.76.32 fix: we no longer lazily cache a live read into the map.
* That poisoned hide-completed on mobile cold start the first
* render ran before frontmatter parsed, cached `false` for a
* completed note, and the cached value stuck forever (so the note
* was treated as incomplete). Reading live when there's no override
* self-corrects on the parse-triggered re-render, and the "changed"
* listener still fills the map for create-render stability. */
private completedState = new Map<string, boolean>();
private isCompleted(node: TreeNode): boolean {
if (!node.file) return false;
const path = node.file.path;
const cached = this.completedState.get(path);
if (cached !== undefined) return cached;
const override = this.completedState.get(node.file.path);
if (override !== undefined) return override;
const fm = this.app.metadataCache.getFileCache(node.file)?.frontmatter;
const v = !!fm?.completed;
this.completedState.set(path, v);
return v;
return !!fm?.completed;
}
/** 0.76.1: open the due-date picker for the action targets and write
@ -7744,6 +7947,13 @@ export class StashpadView extends ItemView {
});
this.tree.rebuild(this.noteFolder);
this.render();
// 0.76.21: keep focus in the list, not the composer. Splitting
// closes a modal which re-activates the leaf and (via
// focusComposer) used to pull focus into the composer even
// with autofocus-after-send OFF. Suppress that activation
// focus briefly and land on the list instead.
this.suppressComposerFocusUntil = Date.now() + 500;
this.viewRoot?.focus({ preventScroll: true } as any);
this.plugin.notifications.show({
message: `Split "${this.titleForNode(target)}" into two`,
kind: "success",
@ -8235,6 +8445,151 @@ export class StashpadView extends ItemView {
return { link, path, name, id };
}
// --- 0.77.8: Claim authorship (retroactive stamping) ---
/** Public entry points (called from main.ts command palette). */
claimSelectedAsAuthor(): void { void this.claimAuthorship({ scope: "selection", contributorMode: false }); }
claimFolderAsAuthor(): void { void this.claimAuthorship({ scope: "folder", contributorMode: false }); }
claimSelectedWithContributor(): void { void this.claimAuthorship({ scope: "selection", contributorMode: true }); }
claimFolderWithContributor(): void { void this.claimAuthorship({ scope: "folder", contributorMode: true }); }
/** All file-backed Stashpad notes (frontmatter `id`) under this view's
* folder, excluding the _authors stubs. */
private fileBackedNotesInFolder(): TFile[] {
const folder = this.noteFolder.replace(/\/+$/, "");
return this.app.vault.getMarkdownFiles().filter((f) => {
const dir = f.parent?.path?.replace(/\/+$/, "") ?? "";
if (dir !== folder && !dir.startsWith(folder + "/")) return false;
if (f.path.includes("/_authors/")) return false;
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
return typeof fm?.id === "string" && !!fm.id;
});
}
/** Apply a frontmatter mutation across many files, paced in batches so
* a bulk claim/unclaim doesn't choke the filesystem. Marks each as a
* self-write so the multiplayer external-modify detector doesn't park
* the change. */
private async pacedFrontmatter(paths: string[], mutate: (m: any, path: string) => void): Promise<void> {
const BATCH = 25, DELAY = 30;
for (let i = 0; i < paths.length; i++) {
const f = this.app.vault.getAbstractFileByPath(paths[i]);
if (f instanceof TFile) {
this.recentSelfWrites.set(f.path, Date.now());
try { await this.app.fileManager.processFrontMatter(f, (m) => mutate(m, paths[i])); }
catch (e) { console.warn("[Stashpad] claim: frontmatter write failed", paths[i], e); }
}
if ((i + 1) % BATCH === 0) await new Promise((r) => setTimeout(r, DELAY));
}
}
/** Retroactively stamp the local user onto notes that predate authorship
* setup. SAFETY MODEL:
* - Never overwrites an existing `author` (only fills blank ones).
* - contributorMode=false: only blank-author notes are claimed.
* - contributorMode=true: blank authored; already-authored-by-
* someone-else we're added to `contributors` (original author
* untouched). Notes already authored/contributed by us are skipped.
* - Folder scope shows a counted confirmation first.
* - Undo stores ONLY the changed paths (not content snapshots), so
* undoing a big claim is cheap; undo guards against clobbering a
* real author that landed after the claim. */
private async claimAuthorship(opts: { scope: "selection" | "folder"; contributorMode: boolean }): Promise<void> {
const author = this.currentAuthorLink();
if (!author) { new Notice("Set your author name in Stashpad settings first."); return; }
const idTag = `-${author.id}`;
const files = opts.scope === "selection"
? this.getActionTargets().map((n) => n.file).filter((f): f is TFile => !!f)
: this.fileBackedNotesInFolder();
if (files.length === 0) { new Notice(opts.scope === "selection" ? "No notes selected." : "No notes in this folder."); return; }
const toAuthor: string[] = [];
const toContributor: string[] = [];
for (const f of files) {
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
const a = typeof fm?.author === "string" ? fm.author : "";
if (!a.trim()) { toAuthor.push(f.path); continue; }
if (a.includes(idTag)) continue; // already ours
if (!opts.contributorMode) continue; // skip authored
const contributors: string[] = Array.isArray(fm?.contributors)
? fm.contributors.filter((c: any) => typeof c === "string") : [];
if (!contributors.some((c) => c.includes(idTag))) toContributor.push(f.path);
}
const total = toAuthor.length + toContributor.length;
if (total === 0) { new Notice("Nothing to claim — those notes are already authored by you."); return; }
if (opts.scope === "folder") {
const parts = [`Stamp yourself as author on ${toAuthor.length} unauthored note(s)`];
if (toContributor.length) parts.push(`and as a contributor on ${toContributor.length} already-authored note(s)`);
const ok = await new Promise<boolean>((resolve) => {
new ConfirmModal(this.app, "Claim authorship",
`${parts.join(" ")}?\nExisting authors are never overwritten. This can be undone.`,
"Claim", resolve).open();
});
if (!ok) return;
}
const folder = this.noteFolder;
const authorLink = author.link;
// 0.77.9: when we author-claim a note where we were ALSO a contributor,
// drop the (now-redundant) contributor entry — author supersedes. Track
// those paths so undo can faithfully restore the contributor entry.
const demotedFromContributor = new Set<string>();
// Forward apply, reused by redo.
const applyClaim = async () => {
await this.pacedFrontmatter(toAuthor, (m, path) => {
if (!(typeof m.author === "string" && m.author.trim())) m.author = authorLink;
if (Array.isArray(m.contributors) && m.contributors.some((c: any) => typeof c === "string" && c.includes(idTag))) {
m.contributors = m.contributors.filter((c: any) => !(typeof c === "string" && c.includes(idTag)));
demotedFromContributor.add(path);
}
});
await this.pacedFrontmatter(toContributor, (m) => {
const contributors: string[] = Array.isArray(m.contributors)
? m.contributors.filter((c: any) => typeof c === "string") : [];
if (!contributors.some((c) => c.includes(idTag))) contributors.push(authorLink);
m.contributors = contributors;
});
};
void this.ensureAuthorFile(author);
await applyClaim();
this.plugin.getUndoStack(folder).push({
label: `Claim authorship (${total} note${total === 1 ? "" : "s"})`,
undo: async () => {
// Only strip what we added, and only if it's still ours (a real
// author/contributor that landed afterwards is left intact).
await this.pacedFrontmatter(toAuthor, (m, path) => {
if (typeof m.author === "string" && m.author.includes(idTag)) delete m.author;
// Restore a contributor entry we demoted during the claim.
if (demotedFromContributor.has(path)) {
const contributors: string[] = Array.isArray(m.contributors)
? m.contributors.filter((c: any) => typeof c === "string") : [];
if (!contributors.some((c) => c.includes(idTag))) contributors.push(authorLink);
m.contributors = contributors;
}
});
await this.pacedFrontmatter(toContributor, (m) => {
if (Array.isArray(m.contributors)) {
m.contributors = m.contributors.filter((c: any) => !(typeof c === "string" && c.includes(idTag)));
}
});
this.debouncedRender();
},
redo: async () => { demotedFromContributor.clear(); await applyClaim(); this.debouncedRender(); },
});
const bits: string[] = [];
if (toAuthor.length) bits.push(`authored ${toAuthor.length}`);
if (toContributor.length) bits.push(`contributing to ${toContributor.length}`);
new Notice(`Claimed authorship: ${bits.join(", ")}. Undo available.`);
this.debouncedRender();
}
/** Lazily create the author stub file under <stashpad>/_authors/.
* Idempotent: skips if the file already exists. The stub carries a
* small frontmatter with id + display name + created stamp, plus a
@ -8243,18 +8598,22 @@ export class StashpadView extends ItemView {
* block note creation) but logged for diagnosis. */
private async ensureAuthorFile(info: { path: string; name: string; id: string }): Promise<void> {
try {
// 0.77.1: register the author in the rebuildable registry (recovery
// cache + rename history). Cheap upsert; no-op if unchanged.
if (info.id) this.plugin.authorRegistry.record({ id: info.id, name: info.name });
const folder = `${this.noteFolder}/_authors`;
await this.ensureFolder(folder);
if (await this.app.vault.adapter.exists(info.path)) return;
const created = new Date().toISOString();
const content = [
"---",
`authorId: ${info.id}`,
`name: "${info.name.replace(/"/g, '\\"')}"`,
`created: ${created}`,
"---",
`# ${info.name}`,
].join("\n");
// 0.77.4: stub uses the Obsidian-native `aliases` for the display
// name (so [[Name]] resolves + quick-switcher finds it). Role/dept
// come from the local user's settings when this stub is theirs.
const isSelf = info.id === (this.plugin.settings.authorId ?? "").trim();
const content = this.plugin.buildAuthorStub({
id: info.id,
name: info.name,
role: isSelf ? this.plugin.settings.authorRole : undefined,
department: isSelf ? this.plugin.settings.authorDepartment : undefined,
}, new Date().toISOString());
await this.app.vault.create(info.path, content);
} catch (e) {
console.warn("[Stashpad] ensureAuthorFile failed", e);
@ -8617,6 +8976,28 @@ export class StashpadView extends ItemView {
this.stickToListBottom = true;
list.scrollTop = list.scrollHeight;
// 0.76.37: on mobile, skip the continuous re-pin entirely. The soft
// keyboard animating in/out, visualViewport resizes, and late
// markdown/attachment layout all change scrollHeight repeatedly after
// a composer submit — and the desktop watchdog below would yank the
// list to the bottom on every one of those, producing a visible
// up/down bounce. Instead do a few discrete, transition-aware
// settle scrolls and then leave the list alone.
if (Platform.isMobile) {
let tries = 0;
const settle = (): void => {
if (!this.stickToListBottom || tries >= 8) return;
tries++;
// Don't fight the keyboard while it's animating — just wait it out.
if (Date.now() >= this.keyboardTransitionUntil) {
list.scrollTop = list.scrollHeight;
}
window.setTimeout(settle, 120);
};
window.setTimeout(settle, 60);
return;
}
// Per-row ResizeObserver: re-pin to bottom whenever any row's height
// changes. Catches direct size changes (block re-layout, expand
// toggles, etc.).

View file

@ -1748,6 +1748,36 @@
margin-right: 6px;
align-items: center;
}
/* 0.77.5: known-authors registry list in settings. */
.stashpad-known-authors-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 8px 0 4px;
}
.stashpad-known-author-row {
padding: 6px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
}
.stashpad-known-author-name { font-weight: 600; }
.stashpad-known-author-meta { color: var(--text-muted); font-size: 0.85em; }
.stashpad-known-author-history {
margin-top: 3px;
color: var(--text-faint);
font-size: 0.8em;
}
/* 0.76.33: glyph shown when an icon name isn't in the device's
bundled Lucide set (older iPad/iOS app) so the button isn't blank.
Sized to read like the icon it replaces. */
.stashpad-icon-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
}
.stashpad-mobile-action-btn {
width: 32px;
height: 28px;
@ -2139,19 +2169,31 @@
margin-top: 12px;
}
/* 0.76.18: compact confirm dialogs (delete, generic confirm, due
picker) size to their content instead of Obsidian's default tall
box the leftover empty space looked oversized, especially on
mobile where modals lean large. */
/* 0.76.28: compact confirm dialogs (delete, generic confirm, due
picker) size to their content. 0.76.18's `width: auto` shrank the
box to content width (cut the text off on mobile); now we use a
definite, readable width (capped on desktop, near-full on mobile)
and force the height to hug content with !important so Obsidian's
mobile modal min-height can't re-inflate it. */
.modal.stashpad-compact-modal {
height: auto;
min-height: 0;
width: auto;
max-width: min(440px, 92vw);
width: min(440px, 94vw) !important;
max-width: 94vw !important;
height: auto !important;
min-height: unset !important;
max-height: 85vh;
/* 0.76.29: don't clip the content the bottom button row was
getting shaved by the modal's rounded-corner overflow when the
box hugged content exactly. */
overflow: visible !important;
}
.modal.stashpad-compact-modal .modal-content {
max-height: 70vh;
height: auto;
min-height: 0;
max-height: 80vh;
overflow-y: auto;
/* Clearance so the Cancel/Delete/Set row clears the modal's
bottom edge. */
padding-bottom: 16px;
}
/* 0.76.1 / 0.76.5: due-date picker modal. Native date/time inputs
@ -2166,14 +2208,23 @@
gap: 8px;
max-width: 260px;
}
/* 0.76.22: give the picker icons a button-like background so they
read as clickable (they open the OS date/time picker). */
.stashpad-due-field-icon {
display: inline-flex;
align-items: center;
color: var(--text-muted);
justify-content: center;
width: 30px;
height: 30px;
flex: 0 0 auto;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
}
.stashpad-due-field-icon:hover { color: var(--text-normal); }
.stashpad-due-field-icon:hover { color: var(--text-normal); background: var(--interactive-hover); }
.stashpad-due-field-icon:active { background: var(--background-modifier-active-hover); }
.stashpad-due-field-icon svg { width: 16px; height: 16px; }
/* 0.76.8: hide the native right-side picker indicator our left
icon is the picker button now (calls showPicker). */
@ -2193,13 +2244,17 @@
/* 0.76.17: uppercase any letters (month abbreviation / AM·PM). */
text-transform: uppercase;
}
/* 0.76.17: kill the leading gap webkit reserves inside the value
editor (looked like stray space before the date now that the
picker indicator is hidden). */
/* 0.76.17 / 0.76.22: kill the leading gap webkit reserves inside the
value editor so the date sits flush-left in its box (no stray
space where the picker indicator used to be). */
.stashpad-due-date::-webkit-datetime-edit,
.stashpad-due-time::-webkit-datetime-edit { padding: 0; }
.stashpad-due-time::-webkit-datetime-edit { padding: 0; margin: 0; }
.stashpad-due-date::-webkit-datetime-edit-fields-wrapper,
.stashpad-due-time::-webkit-datetime-edit-fields-wrapper { padding: 0; }
.stashpad-due-time::-webkit-datetime-edit-fields-wrapper { padding: 0; margin: 0; }
.stashpad-due-date::-webkit-datetime-edit-text:first-child,
.stashpad-due-date::-webkit-datetime-edit-month-field:first-child,
.stashpad-due-date::-webkit-datetime-edit-year-field:first-child,
.stashpad-due-date::-webkit-datetime-edit-day-field:first-child { padding-left: 0; margin-left: 0; }
.stashpad-due-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@ -2384,6 +2439,33 @@
border-bottom: 1px solid var(--background-modifier-border);
font-size: 0.85em;
}
/* 0.77.0-feat: tiny-window transparency button + slider popover. */
.stashpad-tiny-opacity-btn.is-active { color: var(--text-accent); }
.stashpad-tiny-opacity-popover {
position: absolute;
z-index: 50;
display: flex;
flex-direction: column;
gap: 6px;
width: 170px;
padding: 10px 12px;
border-radius: 8px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.stashpad-tiny-opacity-label {
font-size: var(--font-ui-smaller);
font-weight: 600;
color: var(--text-muted);
}
.stashpad-tiny-opacity-popover input[type="range"] { width: 100%; }
.stashpad-tiny-opacity-pct {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.stashpad-tiny-title {
flex: 1 1 auto;
overflow: hidden;