/** * Universal declarative creation enumerator. * * Rather than write a bespoke enumerator per rApp, each module declares a * `CreationSpec` that names its Automerge doc patterns, the collection keys * holding records, and a few per-collection accessors. One generic engine * walks the docs and emits creations for all modules uniformly. * * Adding a new module to rPast = ~10 lines in `creation-specs.ts`. */ import { loadDoc, listDocIds } from './doc-loader'; import type { Creation, CreationEnumerator } from './creations'; export interface CreationCollection { /** Top-level key in the doc (e.g. "events", "tasks", "notes"). */ path: string; /** Short record-type tag, e.g. "event", "task", "note". */ recordType: string; /** Field on the record holding the creation timestamp. Defaults to "createdAt". */ timestampField?: string; /** Pick the display title. Default: record.title || record.name || record.path || id. */ title?: (record: Record, id: string) => string; /** Build a deep-link URL for a record. */ href?: (args: { space: string; docId: string; id: string; record: Record }) => string | undefined; /** Emit extra per-record tags. */ tags?: (record: Record) => string[] | undefined; /** Return false to skip a record (e.g. abstain votes, tombstones). */ filter?: (record: Record) => boolean; /** Pull a short description body. */ description?: (record: Record) => string | undefined; } export interface CreationSpec { module: string; label: string; icon: string; color?: string; /** * Doc-ID patterns this module owns. * `{space}` is replaced with the space slug. * Trailing `*` is a prefix wildcard (expanded via listDocIds). * Otherwise the pattern is loaded as a single doc. * Examples: * `{space}:cal:events` (single doc) * `{space}:rnotes:vaults:*` (fan-out over all vaults) */ docPatterns: string[]; collections: CreationCollection[]; } function pickTitle(record: Record, id: string, col: CreationCollection): string { if (col.title) return col.title(record, id); const candidates = ['title', 'name', 'path', 'subject', 'label'] as const; for (const k of candidates) { const v = record[k]; if (typeof v === 'string' && v.trim()) return v; } return id; } async function expandDocIds(pattern: string, space: string): Promise { const resolved = pattern.replace(/\{space\}/g, space); if (resolved.endsWith('*')) { return listDocIds(resolved.slice(0, -1)); } return [resolved]; } export function createCreationEnumerator(spec: CreationSpec): CreationEnumerator { return { module: spec.module, label: spec.label, icon: spec.icon, color: spec.color, async enumerate({ space, from, to }) { const out: Creation[] = []; for (const pattern of spec.docPatterns) { const docIds = await expandDocIds(pattern, space); for (const docId of docIds) { const doc = await loadDoc>(docId); if (!doc) continue; for (const col of spec.collections) { const records = doc[col.path]; if (!records || typeof records !== 'object') continue; const tsField = col.timestampField ?? 'createdAt'; for (const [id, raw] of Object.entries(records as Record)) { if (!raw || typeof raw !== 'object') continue; const rec = raw as Record; if (col.filter && !col.filter(rec)) continue; const ts = rec[tsField]; if (typeof ts !== 'number' || !Number.isFinite(ts)) continue; if (from !== undefined && ts < from) continue; if (to !== undefined && ts > to) continue; const updatedAt = typeof rec.updatedAt === 'number' ? rec.updatedAt as number : undefined; out.push({ createdAt: ts, updatedAt, title: pickTitle(rec, id, col), recordType: col.recordType, recordId: id, href: col.href?.({ space, docId, id, record: rec }), tags: col.tags?.(rec), description: col.description?.(rec), }); } } } } return out; }, }; }