mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.77.9: author registry + claim authorship
This commit is contained in:
parent
801ba4d8a0
commit
ba6a1069c8
14 changed files with 1594 additions and 272 deletions
109
main.js
109
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
69
release-notes/0.77.9.md
Normal 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.1–0.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.8–0.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
189
src/author-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
396
src/main.ts
396
src/main.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
174
src/time-picker.ts
Normal 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 (0–23) — 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 (1–24), `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; // 1–24 (or 1–12) to prefill the HH field
|
||||
seedM: number; // 0–59
|
||||
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 1–24, 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}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
441
src/view.ts
441
src/view.ts
|
|
@ -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.3–1.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.).
|
||||
|
|
|
|||
114
styles.css
114
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue