|
| 1 | +/** |
| 2 | + * Type-aware value formatting for the streaming data export route |
| 3 | + * (`GET /data/:object/export`). |
| 4 | + * |
| 5 | + * The raw rows returned by `findData` carry *storage* values: lookup / user |
| 6 | + * fields hold ids (or, when `$expand`-ed, nested records), select fields hold |
| 7 | + * option codes, booleans hold true/false, dates hold ISO strings. None of those |
| 8 | + * read well in a spreadsheet. These helpers turn each value into a human |
| 9 | + * readable cell using the object's field metadata. |
| 10 | + * |
| 11 | + * Contract: when no field metadata is available (schema lookup failed or carried |
| 12 | + * no fields) every helper is a pass-through, so the export stays byte-for-byte |
| 13 | + * identical to the un-formatted behaviour. |
| 14 | + */ |
| 15 | + |
| 16 | +export interface ExportFieldMeta { |
| 17 | + name: string; |
| 18 | + type?: string; |
| 19 | + label?: string; |
| 20 | + options?: Array<{ label?: string; value?: unknown }>; |
| 21 | + /** Target object for lookup / master_detail / user fields. */ |
| 22 | + reference?: string; |
| 23 | + /** Field on the referenced record to show as its label. */ |
| 24 | + displayField?: string; |
| 25 | +} |
| 26 | + |
| 27 | +/** Field types whose stored value points at another record. */ |
| 28 | +const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']); |
| 29 | + |
| 30 | +/** Field types whose stored value maps to a static option label. */ |
| 31 | +const OPTION_TYPES = new Set(['select', 'radio']); |
| 32 | +const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); |
| 33 | + |
| 34 | +/** |
| 35 | + * Keys tried, in order, to derive a referenced record's display value when the |
| 36 | + * field carries no explicit `displayField`. |
| 37 | + */ |
| 38 | +const NAME_KEY_FALLBACKS = [ |
| 39 | + 'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email', |
| 40 | +]; |
| 41 | + |
| 42 | +/** |
| 43 | + * Build a field-name → metadata map from an object schema (best-effort). |
| 44 | + * |
| 45 | + * Accepts both shapes `fields` appears in across the stack: the runtime |
| 46 | + * `ObjectSchema.fields` is a `Record<fieldName, FieldDefinition>` object map |
| 47 | + * (the form served by the engine registry / `getMetaItem`), while some callers |
| 48 | + * and fixtures hand back a plain `FieldDefinition[]` array. A field's name is |
| 49 | + * taken from its own `name`, falling back to the map key. |
| 50 | + */ |
| 51 | +export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta> { |
| 52 | + const map = new Map<string, ExportFieldMeta>(); |
| 53 | + const fields = (schema as { fields?: unknown })?.fields; |
| 54 | + |
| 55 | + // Normalize either shape to a list of [name, definition] entries. |
| 56 | + let entries: Array<[string, any]>; |
| 57 | + if (Array.isArray(fields)) { |
| 58 | + entries = fields |
| 59 | + .filter((f) => f && typeof f === 'object') |
| 60 | + .map((f) => [typeof f.name === 'string' ? f.name : '', f] as [string, any]); |
| 61 | + } else if (fields && typeof fields === 'object') { |
| 62 | + entries = Object.entries(fields as Record<string, any>).map( |
| 63 | + ([key, def]) => [ |
| 64 | + def && typeof def === 'object' && typeof def.name === 'string' ? def.name : key, |
| 65 | + def, |
| 66 | + ] as [string, any], |
| 67 | + ); |
| 68 | + } else { |
| 69 | + return map; |
| 70 | + } |
| 71 | + |
| 72 | + for (const [name, f] of entries) { |
| 73 | + if (!name || !f || typeof f !== 'object') continue; |
| 74 | + map.set(name, { |
| 75 | + name, |
| 76 | + type: typeof f.type === 'string' ? f.type : undefined, |
| 77 | + label: typeof f.label === 'string' ? f.label : undefined, |
| 78 | + options: Array.isArray(f.options) ? f.options : undefined, |
| 79 | + reference: typeof f.reference === 'string' ? f.reference : undefined, |
| 80 | + displayField: typeof f.displayField === 'string' ? f.displayField : undefined, |
| 81 | + }); |
| 82 | + } |
| 83 | + return map; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Reference-typed field names that should be `$expand`-ed so their stored ids |
| 88 | + * resolve to the referenced record (and thus to a readable name). |
| 89 | + */ |
| 90 | +export function referenceFieldNames(metaMap: Map<string, ExportFieldMeta>): string[] { |
| 91 | + const out: string[] = []; |
| 92 | + for (const meta of metaMap.values()) { |
| 93 | + if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) out.push(meta.name); |
| 94 | + } |
| 95 | + return out; |
| 96 | +} |
| 97 | + |
| 98 | +/** Header label for a column: schema label when present, else the field name. */ |
| 99 | +export function headerLabel(field: string, metaMap: Map<string, ExportFieldMeta>): string { |
| 100 | + return metaMap.get(field)?.label || field; |
| 101 | +} |
| 102 | + |
| 103 | +function pad2(n: number): string { |
| 104 | + return n < 10 ? `0${n}` : String(n); |
| 105 | +} |
| 106 | + |
| 107 | +function toDate(value: unknown): Date | null { |
| 108 | + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; |
| 109 | + if (typeof value === 'number' || typeof value === 'string') { |
| 110 | + const d = new Date(value); |
| 111 | + return Number.isNaN(d.getTime()) ? null : d; |
| 112 | + } |
| 113 | + return null; |
| 114 | +} |
| 115 | + |
| 116 | +/** `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime), in UTC. */ |
| 117 | +function formatDate(value: unknown, withTime: boolean): unknown { |
| 118 | + const d = toDate(value); |
| 119 | + if (!d) return value; |
| 120 | + const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; |
| 121 | + if (!withTime) return ymd; |
| 122 | + return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; |
| 123 | +} |
| 124 | + |
| 125 | +function optionLabel(value: unknown, options?: Array<{ label?: string; value?: unknown }>): unknown { |
| 126 | + if (!options) return value; |
| 127 | + const hit = options.find((o) => o && o.value === value); |
| 128 | + return hit?.label ?? value; |
| 129 | +} |
| 130 | + |
| 131 | +function displayFromRecord(rec: Record<string, unknown>, displayField?: string): string { |
| 132 | + if (displayField && rec[displayField] != null) return String(rec[displayField]); |
| 133 | + for (const k of NAME_KEY_FALLBACKS) { |
| 134 | + const v = rec[k]; |
| 135 | + if (v != null && typeof v !== 'object') return String(v); |
| 136 | + } |
| 137 | + if (rec.id != null) return String(rec.id); |
| 138 | + try { return JSON.stringify(rec); } catch { return String(rec); } |
| 139 | +} |
| 140 | + |
| 141 | +function formatReference(value: unknown, displayField?: string): unknown { |
| 142 | + const one = (v: unknown): unknown => |
| 143 | + v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField) : v; |
| 144 | + if (Array.isArray(value)) return value.map(one).join(', '); |
| 145 | + return one(value); |
| 146 | +} |
| 147 | + |
| 148 | +/** Format one storage value into a display value using its field metadata. */ |
| 149 | +export function formatCellValue(value: unknown, meta?: ExportFieldMeta): unknown { |
| 150 | + if (value === null || value === undefined) return value; |
| 151 | + if (!meta || !meta.type) return value; |
| 152 | + const t = meta.type; |
| 153 | + if (t === 'boolean' || t === 'toggle') { |
| 154 | + if (value === true || value === 'true' || value === 1) return '是'; |
| 155 | + if (value === false || value === 'false' || value === 0) return '否'; |
| 156 | + return value; |
| 157 | + } |
| 158 | + if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options); |
| 159 | + if (MULTI_OPTION_TYPES.has(t)) { |
| 160 | + const arr = Array.isArray(value) ? value : [value]; |
| 161 | + return arr.map((v) => optionLabel(v, meta.options)).join(', '); |
| 162 | + } |
| 163 | + if (t === 'date') return formatDate(value, false); |
| 164 | + if (t === 'datetime') return formatDate(value, true); |
| 165 | + if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField); |
| 166 | + return value; |
| 167 | +} |
| 168 | + |
| 169 | +/** Ordered display cells for one row — the CSV / XLSX column path. */ |
| 170 | +export function formatRowCells( |
| 171 | + row: Record<string, unknown>, |
| 172 | + fields: string[], |
| 173 | + metaMap: Map<string, ExportFieldMeta>, |
| 174 | +): unknown[] { |
| 175 | + return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f))); |
| 176 | +} |
| 177 | + |
| 178 | +/** |
| 179 | + * Format a row for JSON output: readable values for known fields, every other |
| 180 | + * key left untouched. Returns the original object reference when nothing needs |
| 181 | + * formatting so the stream stays byte-identical to the un-formatted path. |
| 182 | + */ |
| 183 | +export function formatRowForJson( |
| 184 | + row: Record<string, unknown>, |
| 185 | + metaMap: Map<string, ExportFieldMeta>, |
| 186 | +): Record<string, unknown> { |
| 187 | + if (metaMap.size === 0 || !row || typeof row !== 'object') return row; |
| 188 | + let copy: Record<string, unknown> | null = null; |
| 189 | + for (const key of Object.keys(row)) { |
| 190 | + const meta = metaMap.get(key); |
| 191 | + if (!meta) continue; |
| 192 | + const formatted = formatCellValue(row[key], meta); |
| 193 | + if (formatted !== row[key]) { |
| 194 | + if (!copy) copy = { ...row }; |
| 195 | + copy[key] = formatted; |
| 196 | + } |
| 197 | + } |
| 198 | + return copy ?? row; |
| 199 | +} |
0 commit comments