grub-basket_SP/src/types.ts

307 lines
14 KiB
TypeScript

import type { TFile } from "obsidian";
export const STASHPAD_VIEW_TYPE = "stashpad-view";
/** 0.68.0: sidebar panels view (Pinned Notes + future panels). */
export const STASHPAD_PANELS_VIEW_TYPE = "stashpad-panels";
/** 0.86.0: left-sidebar folder picker (pinned notes + folders, split). */
export const STASHPAD_FOLDER_PANEL_VIEW_TYPE = "stashpad-folder-panel";
/** 0.74.1: right-sidebar detail panel. Shows the currently-cursored
* note's body + metadata + children. Lives separately from the
* left-sidebar panels view (Pinned/Shared/Tasks). */
export const STASHPAD_DETAIL_VIEW_TYPE = "stashpad-detail";
/** 0.98.35: dedicated encrypted-trash tab (recoverable deleted notes, grouped by
* the folder they came from). */
export const STASHPAD_TRASH_VIEW_TYPE = "stashpad-trash";
/** Per-folder overhaul (Phase A): on-the-fly "database" tab that lists notes by
* state across the whole vault. One view type, two modes via view state:
* "encrypted" (all locked subtrees) and "archived" (all archive folders). The
* "deleted" aggregate is the existing trash view. Read-only: list + navigate. */
export const STASHPAD_AGGREGATE_VIEW_TYPE = "stashpad-aggregate";
export const ROOT_ID = "__root__";
/** A user's pinned-note record. Cross-folder by design — the panel
* shows pins from every Stashpad folder in one flat list with a
* folder badge for context. 0.68.0. */
export interface PinnedNoteRef {
folder: string;
id: StashpadId;
}
export type StashpadId = string;
/** 0.76.3: frontmatter `tags` helpers. Obsidian allows `tags` as
* either a YAML list or a space/comma-separated string; these
* normalize to an array, mutate, and write back as an array (or
* delete the key when empty). `fm` is a live processFrontMatter
* object or a cached frontmatter snapshot. Tags are compared
* without a leading '#'. */
function fmTagList(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter((t): t is string => typeof t === "string");
if (typeof raw === "string") return raw.split(/[,\s]+/).filter(Boolean);
return [];
}
export function fmHasTag(fm: any, tag: string): boolean {
const want = tag.replace(/^#/, "");
return fmTagList(fm?.tags).some((t) => t.replace(/^#/, "") === want);
}
export function fmAddTag(fm: any, tag: string): void {
const want = tag.replace(/^#/, "");
const list = fmTagList(fm.tags);
if (!list.some((t) => t.replace(/^#/, "") === want)) list.push(want);
fm.tags = list;
}
export function fmRemoveTag(fm: any, tag: string): void {
const want = tag.replace(/^#/, "");
const list = fmTagList(fm.tags).filter((t) => t.replace(/^#/, "") !== want);
if (list.length) fm.tags = list;
else delete fm.tags;
}
export interface NoteFrontmatter {
id: StashpadId;
parent: StashpadId | null;
created: string;
attachments?: string[];
tags?: string[];
/** Optional hex color (e.g. "#E07A78") that tints the row's swatch, border,
* and child-count arrow. Stored verbatim in frontmatter. */
color?: string | null;
}
export interface TreeNode {
id: StashpadId;
parent: StashpadId | null;
children: StashpadId[];
file: TFile | null;
created: string;
}
export type TimeFilter = "all" | "year" | "month" | "week" | "day";
/** Per-folder view mode (settings.viewModes keyed by folder path).
* - "nested" (default): tree, immediate children of focus.
* - "flat": all descendants of focus, flat, sorted by the current sort mode.
* - "everything": all descendants of focus PLUS every non-Stashpad file in
* the Stashpad folder, interleaved by created/ctime. Non-Stashpad files
* are always folder-wide (they don't belong to any note).
*
* Drag-reorder and tree-mutation commands only operate in "nested" mode;
* in flat/everything the list is a synthesized view and direct
* position/parent changes would have no meaningful target. */
export type ViewMode = "nested" | "flat" | "everything";
/** Frontmatter keys Stashpad auto-manages. Templates / clones / settings
* UI must filter these out of any user-supplied frontmatter so the
* plugin always wins on these. Centralised here so every guard reads
* from the same list.
*
* - `id`, `parent`, `created`, `attachments`, `position` — canonical
* machine-readable structure (mutating these here would corrupt the
* tree).
* - `modified`, `author`, `contributors` — auto-stamped on edit.
* - `parentLink`, `children` — redundant recovery fields written by
* FrontmatterSyncQueue; human-clickable navigation back-up.
*/
export const RESERVED_FRONTMATTER: readonly string[] = [
"id", "parent", "created", "modified", "attachments", "position",
"author", "contributors",
"parentLink", "children",
// 0.78.1: task scheduling/assignment — Stashpad-managed, so clones /
// templates must not carry someone else's due date or assignees.
"due", "assignedTo", "assignedBy",
// 0.86.3: sidebar pin state lives on the note (so it SYNCS with the note
// across devices). Stashpad-managed; clones/templates must not inherit it.
"pinned", "pinnedAt",
// 0.105.0: list pin — floats a note to the TOP of its sibling list (distinct
// from the sidebar pin above). Stashpad-managed; not inherited by clones.
"listPinned", "listPinnedAt",
// 0.88.0: marks a note that came in via import (used by the "imported only"
// view filter). Stashpad-managed; a clone of an imported note isn't imported.
"imported",
// 0.101.x: OKF relative-markdown cross-links, derived from the tree and
// Stashpad-managed (rebuilt by the OKF pass). The user-editable OKF fields
// (okfType/okfTitle/okfTimestamp) are intentionally NOT reserved.
"okfParent", "okfChildren",
] as const;
/** Reserved Stashpad subfolder names (machine-managed; not user notes).
* Centralised so search/link/folder surfaces filter them consistently. */
export const RESERVED_SUBFOLDER_NAMES: ReadonlySet<string> = new Set([
"_attachments", "_authors", "_exports", "_imports", "_processed",
"_archive", ".archive", // .archive is legacy (pre-0.79.10)
]);
/** 0.136.0 (per-folder archive/trash overhaul): every Stashpad folder owns an
* `archive/` (and, Phase 2, a `trash/`) subfolder. These names are reserved
* ONLY as subfolders — a TOP-LEVEL folder literally named "archive"/"trash"
* stays a normal folder. NOTE: a pre-existing user SUBfolder named
* `archive`/`trash` becomes that folder's archive/trash — its notes stay
* reachable via the aggregated views. */
export const SUBFOLDER_ONLY_RESERVED_NAMES: ReadonlySet<string> = new Set(["archive", "trash"]);
/** A reserved name in SUBFOLDER position (never applies to a path's first
* segment for archive/trash; always applies for the underscore names). */
export function isReservedSubfolderName(name: string): boolean {
return RESERVED_SUBFOLDER_NAMES.has(name) || SUBFOLDER_ONLY_RESERVED_NAMES.has(name);
}
/** 0.136.0: the archive subfolder for a Stashpad folder. */
export function archiveSubfolderOf(folder: string): string {
return `${(folder || "").replace(/\/+$/, "")}/archive`;
}
/** 0.136.0: is `path` (a folder or file path) inside some folder's `archive/`
* subfolder? Matches any `archive` segment except a leading one (a top-level
* folder literally named "archive" is a normal folder, not a subfolder). */
export function isArchiveSubfolderPath(path: string): boolean {
return (path || "").replace(/\/+$/, "").split("/").slice(1).includes("archive");
}
/** True if any path segment is a reserved Stashpad subfolder. archive/trash
* count only in SUBfolder position (see SUBFOLDER_ONLY_RESERVED_NAMES). */
export function isInReservedSubfolder(path: string): boolean {
return path.split("/").some((seg, i) =>
RESERVED_SUBFOLDER_NAMES.has(seg) || (i > 0 && SUBFOLDER_ONLY_RESERVED_NAMES.has(seg)));
}
/** True if the path lives under an archive subfolder (`_archive`/`.archive`)
* — the import-originals graveyard, excluded from search + link surfaces. */
export function isArchivedPath(path: string): boolean {
return path.split("/").some((seg) => seg === "_archive" || seg === ".archive");
}
/** 0.79.18: an `attachments` frontmatter entry as a wikilink. Idempotent —
* returns an existing `[[...]]` unchanged (never double-brackets), so it's
* safe to run repeatedly (e.g. in rebootstrap) without looping. */
export function toAttachmentLink(entry: string): string {
const s = (entry ?? "").trim();
if (!s) return s;
if (/^\[\[.*\]\]$/.test(s)) return s;
return `[[${s}]]`;
}
/** The resolvable vault path/linktext inside an attachment entry — strips
* `[[ ]]`, a trailing `|alias`, and `#heading`/`^block` refs. Accepts both
* the new wikilink form and the legacy plain-path form. */
export function attachmentLinkPath(entry: string): string {
let s = (entry ?? "").trim();
const m = s.match(/^\[\[(.*)\]\]$/);
if (m) s = m[1];
return s.split("|")[0].split("#")[0].split("^")[0].trim();
}
/** File extensions Stashpad never surfaces in link/search (plugin-internal
* formats users don't link to). `.edtz` = Encrypted Templater. */
export const IGNORED_FILE_EXTENSIONS: ReadonlySet<string> = new Set(["edtz"]);
export function isIgnoredFileExtension(path: string): boolean {
const m = path.match(/\.([^./]+)$/);
return !!m && IGNORED_FILE_EXTENSIONS.has(m[1].toLowerCase());
}
/** Test a path against Obsidian's "Excluded files" entries
* (`userIgnoreFilters`): an entry wrapped in `/.../` is a regex; otherwise
* it's a path prefix. Lets our surfaces inherit the user's exclusion list
* so they manage it in one place. */
export function matchesObsidianIgnore(path: string, filters: string[] | undefined): boolean {
if (!Array.isArray(filters)) return false;
for (const raw of filters) {
const f = (raw ?? "").trim();
if (!f) continue;
if (f.length > 2 && f.startsWith("/") && f.endsWith("/")) {
try { if (new RegExp(f.slice(1, -1)).test(path)) return true; } catch { /* bad regex */ }
} else if (path === f || path.startsWith(f.endsWith("/") ? f : f + "/")) {
return true;
}
}
return false;
}
/** Sift: the canonical Stashpad search match — all whitespace-split tokens
* must each appear (case-insensitive substring) somewhere in the haystack,
* in any order. Empty query matches everything. See docs/sift.md. Exported
* so simple inputs (e.g. the assignee picker) reuse it instead of
* re-implementing `includes`. */
export function siftMatch(query: string, haystack: string): boolean {
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return true;
const hay = haystack.toLowerCase();
return tokens.every((t) => hay.includes(t));
}
/** 0.78.1: parse an author wikilink as Stashpad writes it into
* author / contributors / assignedTo / assignedBy frontmatter —
* `[[demo/_authors/Jane-743jcy.md|Jane Doe]]` → { id: "743jcy",
* name: "Jane Doe" }. The alias (after `|`) is the display name; if
* absent we de-slug the filename stem. Returns null when no id segment
* is present. Shared so main/view/panels parse identically. */
export function parseAuthorRef(raw: unknown): { 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 };
}
/** Read an assignee list (`assignedTo`) from frontmatter into
* {id,name}[]. Accepts an array of wikilinks or a single wikilink. */
export function parseAssignees(fm: any): Array<{ id: string; name: string }> {
const raw = fm?.assignedTo;
const arr = Array.isArray(raw) ? raw : (raw != null ? [raw] : []);
const out: Array<{ id: string; name: string }> = [];
for (const r of arr) { const p = parseAuthorRef(r); if (p) out.push(p); }
return out;
}
/** Explicit instruction for what the post-`render()` block should do with
* the list scrollTop. Replaces the legacy quorum of flags
* (scrollToBottomOnNextRender, stickToListBottom, pendingScrollRestore,
* prevAtBottom/prevScroll inference). Every render() caller picks one;
* `preserve` is the default and the safe choice for any "data changed,
* user wasn't intending a viewport move" mutation.
*
* See `docs/branches/scroll-rewrite-2.md` for the full rationale + the
* per-call-site assignment table.
*
* Wired in incrementally:
* - 0.56.0: type introduced; render() accepts but ignores it.
* - 0.56.1: every caller passes an explicit policy.
* - 0.56.2: render() honours the policy alongside legacy flags.
* - 0.56.4+: legacy flags retired. */
export type ScrollPolicy =
| { kind: "preserve" }
| { kind: "pin-bottom"; until: "settle" | "next-user-input" }
| { kind: "restore"; scrollTop: number }
| { kind: "follow-cursor" }
// `align` is passed straight to scrollIntoView({ block }), so it must be
// one of its valid values — "start" means top. ("top" was a long-standing
// typo that silently no-op'd; esbuild doesn't typecheck so it slipped by.)
| { kind: "scroll-to-id"; id: StashpadId; align: "start" | "center" | "end" | "nearest" };
export interface ViewConfigState {
focusId: StashpadId;
timeFilter: TimeFilter;
}
export type LogEventType =
| "create" | "delete" | "missing" | "parent_change" | "rename" | "reorder"
| "complete" | "uncomplete"
| "stash_export" | "stash_import"
| "attachment_add" | "attachment_remove"
| "palette_color_add" | "palette_color_remove"
// 0.136.0: one-time move of legacy dedicated-archive contents into the
// folder's own archive/ subfolder (per-folder archive overhaul).
| "archive_migration"
// 0.146.0: encryption / archive / trash lifecycle — previously surfaced only
// as notifications; now also recorded in the per-folder action log.
| "lock" | "unlock" | "archive" | "restore";
export interface LogEvent {
ts: string;
type: LogEventType;
id: StashpadId;
payload?: Record<string, unknown>;
/** Display name of whoever performed the action. Stamped automatically
* by StashpadLog.append() from the plugin's authorName setting; older
* log lines may lack this field, in which case readers should treat
* it as unknown. */
author?: string;
}