/** * Schema-driven mappers between an entity record and DocumentEditor sections. * * These make the editor generic (not OGMC-specific): an app hands the editor an * entity type's attributes + its `data` blob and gets back typed * {@link DocSection}s; on save it turns the edited sections back into a `data` * patch to PATCH. Pure + framework-free (no React) so they're cheap to unit-test * and reuse in a Node route. * * Blob convention mirrors `collection/data.ts`: a tenant API client camelCases * response keys, so a snake_case attr `meta_description` may land as * `metaDescription` — reads try the camel form first, then the raw key. */ import type { DocSection, DocSectionKind } from './types'; /** * Minimal attribute shape the mapper needs — a structural subset of the entity * schema `AttributeDef` (name + dataType, optional label/config), so callers can * pass their existing attribute objects directly. */ export interface DocAttribute { name: string; /** Entity data type — `text` | `longtext` | `json` | `enum` | `number` | … */ dataType: string; label?: string; config?: Record | null; } export interface SectionsFromRecordOptions { /** Attribute names to skip outright (in addition to the built-in system/status skips). */ exclude?: string[]; /** Mark these section keys read-only. */ readOnlyKeys?: string[]; /** Restrict output to these kinds (e.g. only `markdown` + `text`). */ includeKinds?: DocSectionKind[]; } /** snake_case / kebab → camelCase (matches collection/data.ts). */ export function toCamelKey(name: string): string { return name.replace(/[_-]+([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); } /** camelCase-aware read of a value out of a `data` blob. */ export function readData(data: Record | undefined | null, name: string): unknown { if (!data) return undefined; return data[toCamelKey(name)] ?? data[name]; } /** Attribute names that are never editable document sections. */ const SYSTEM_NAMES = new Set(['id', 'status', 'created_at', 'updated_at', 'createdat', 'updatedat', 'external_id']); function humanize(name: string): string { const spaced = name.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim(); return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : name; } /** A name like `sources`, `source_urls`, `references` → a list section. */ function looksLikeList(name: string): boolean { return /\bsources?\b/i.test(name) || /^sources?/i.test(name) || /_sources?$/i.test(name); } /** Decide the section kind for an attribute, or `null` to skip it. */ function kindFor(attr: DocAttribute): DocSectionKind | null { if (looksLikeList(attr.name)) return 'list'; switch (attr.dataType) { case 'longtext': return 'markdown'; case 'json': return 'structured'; case 'text': return 'text'; default: // number/boolean/date/enum/etc. are not document-editable sections. return null; } } function toStr(v: unknown): string { return v == null ? '' : String(v); } /** Coerce an arbitrary stored value into the shape a `kind` expects. */ function coerceValue(kind: DocSectionKind, raw: unknown): unknown { switch (kind) { case 'markdown': case 'text': return toStr(raw); case 'list': if (Array.isArray(raw)) { return raw .map((el) => { if (typeof el === 'string') return el.trim(); if (el && typeof el === 'object') { const o = el as Record; return toStr(o.url ?? o.href ?? o.value ?? o.name).trim(); } return toStr(el).trim(); }) .filter((s) => s.length > 0); } if (typeof raw === 'string') { return raw .split(/\r?\n/) .map((s) => s.trim()) .filter((s) => s.length > 0); } return []; case 'structured': { if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const out: Record = {}; for (const [k, v] of Object.entries(raw as Record)) out[k] = toStr(v); return out; } return {}; } } } /** * Map an entity type's attributes + its `data` blob → editable DocSections. * Skips system/status/enum attributes; heuristically routes `longtext`→markdown, * `json`→structured, `text`→text, and any `source(s)`-ish name→list. */ export function sectionsFromRecord( attributes: DocAttribute[], data: Record | undefined | null, opts: SectionsFromRecordOptions = {}, ): DocSection[] { const exclude = new Set((opts.exclude ?? []).map((n) => n.toLowerCase())); const readOnly = new Set(opts.readOnlyKeys ?? []); const sections: DocSection[] = []; for (const attr of attributes) { const lname = attr.name.toLowerCase(); if (exclude.has(lname) || SYSTEM_NAMES.has(lname)) continue; // Enums are status/vocabulary fields, not document prose. if (attr.dataType === 'enum') continue; const kind = kindFor(attr); if (!kind) continue; if (opts.includeKinds && !opts.includeKinds.includes(kind)) continue; const placeholder = attr.config && typeof attr.config.placeholder === 'string' ? (attr.config.placeholder as string) : undefined; sections.push({ key: attr.name, label: attr.label || humanize(attr.name), kind, value: coerceValue(kind, readData(data, attr.name)), placeholder, readOnly: readOnly.has(attr.name) || undefined, }); } return sections; } /** * Turn edited sections back into a `data` patch object (keyed by section.key) to * merge and PATCH. Values are normalized to their kind's canonical shape. */ export function recordPatchFromSections(sections: DocSection[]): Record { const patch: Record = {}; for (const s of sections) { patch[s.key] = coerceValue(s.kind, s.value); } return patch; }