import type { BirthDatum, Gender } from "./birth.ts"; import type { PropertyDatum } from "./property.ts"; import { describe, validatePropertyDatum } from "./property.ts"; /** * Interchange card for one client profile — the JSON that travels between * apps via clipboard (复制资料/粘贴资料) or URL. Birth fields stay flat at the * top level so pre-v1 consumers that read a bare `BirthDatum` keep working; * v1 adds `v`, `name`, and `properties` around them. */ export interface ProfileCard extends BirthDatum { readonly v: 1; readonly name: string | null; readonly properties: readonly PropertyDatum[]; } /** Producer-side input: a `BirthDatum` plus the optional v1 extras. */ export type ProfileCardInput = BirthDatum & { readonly name?: string | null; readonly properties?: readonly PropertyDatum[]; }; function cardInt(obj: Record, key: string, min: number, max: number): number { const value = obj[key]; if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) { throw new Error( `profile card ${key} must be an integer between ${min} and ${max}, got ${describe(value)}`, ); } return value; } function cardIntOrNull( obj: Record, key: string, min: number, max: number, ): number | null { if ((obj[key] ?? null) === null) return null; return cardInt(obj, key, min, max); } function cardGender(obj: Record): Gender | null { const gender = obj["gender"] ?? null; if (gender === null || gender === "male" || gender === "female") return gender; throw new Error(`profile card gender must be "male", "female", or null, got ${describe(gender)}`); } function cardName(obj: Record): string | null { const name = obj["name"] ?? null; if (name === null) return null; if (typeof name !== "string") { throw new Error(`profile card name must be a string or null, got ${describe(name)}`); } const trimmed = name.trim(); return trimmed === "" ? null : trimmed; } function cardProperties(obj: Record): readonly PropertyDatum[] { const raw = obj["properties"] ?? []; if (!Array.isArray(raw)) { throw new Error(`profile card properties must be an array, got ${describe(raw)}`); } return raw.map((entry, index) => validatePropertyDatum(entry, `properties[${index}]`)); } /** * Validate an untrusted object (already-parsed JSON, request body) into a * normalized `ProfileCard`. Legacy cards without `v`/`name`/`properties` are * accepted; unknown fields are dropped. Throws on the first invalid field. */ export function normalizeProfileCard(value: unknown): ProfileCard { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`profile card must be a JSON object, got ${describe(value)}`); } const obj = value as Record; const version = obj["v"] ?? 1; if (version !== 1) { throw new Error( `unsupported profile card version ${describe(version)} — this app understands v1 only`, ); } const tz = obj["tzOffsetMinutes"]; if (typeof tz !== "number" || !Number.isInteger(tz) || tz < -720 || tz > 840) { throw new Error( `profile card tzOffsetMinutes must be a UTC offset in minutes between -720 and 840 ` + `(GMT+8 = 480) — explicit, never ambient; got ${describe(tz)}`, ); } return { v: 1, name: cardName(obj), year: cardInt(obj, "year", 1, 9999), month: cardInt(obj, "month", 1, 12), day: cardInt(obj, "day", 1, 31), hour: cardIntOrNull(obj, "hour", 0, 23), minute: cardIntOrNull(obj, "minute", 0, 59), gender: cardGender(obj), tzOffsetMinutes: tz, properties: cardProperties(obj), }; } /** * Parse a profile card from JSON text (clipboard paste, URL param). Accepts * v1 cards and the legacy bare `{name?, ...BirthDatum}` JSON that * bazi-plotter's 复制资料 emits. Throws with an actionable message. */ export function parseProfileCard(text: string): ProfileCard { let value: unknown; try { value = JSON.parse(text); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw new Error(`profile card is not valid JSON: ${reason}`, { cause: err }); } return normalizeProfileCard(value); } /** * Serialize a profile card to interchange JSON. Validates the input first so * a producer holding bad data fails at the source, not in the receiving app. * Output keeps birth fields flat at the top level (legacy-consumer contract). */ export function serializeProfileCard(card: ProfileCardInput): string { return JSON.stringify(normalizeProfileCard(card), null, 2); }