rspace-online/shared/markwhen/universal-enumerator.ts

114 lines
4.1 KiB
TypeScript

/**
* 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<string, unknown>, id: string) => string;
/** Build a deep-link URL for a record. */
href?: (args: { space: string; docId: string; id: string; record: Record<string, unknown> }) => string | undefined;
/** Emit extra per-record tags. */
tags?: (record: Record<string, unknown>) => string[] | undefined;
/** Return false to skip a record (e.g. abstain votes, tombstones). */
filter?: (record: Record<string, unknown>) => boolean;
/** Pull a short description body. */
description?: (record: Record<string, unknown>) => 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<string, unknown>, 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<string[]> {
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<Record<string, unknown>>(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<string, unknown>)) {
if (!raw || typeof raw !== 'object') continue;
const rec = raw as Record<string, unknown>;
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;
},
};
}