import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts"; import { parseProfileGender } from "./profile-gender.ts"; import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts"; const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const DATE = /^(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; const SOURCES = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const; const PERIODS = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; const STATUSES = ["reported", "assessing", "rectifying", "candidate"] as const; export type ChartSubjectWrite = { readonly columns: Record; readonly profile: Record; }; function recordOf(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function text(value: unknown, max: number): string { return typeof value === "string" ? value.trim().slice(0, max) : ""; } function clock(value: unknown): string | null { const raw = text(value, 8); return CLOCK.test(raw) ? raw : null; } function date(value: unknown): string | null { const raw = text(value, 10); if (!DATE.test(raw)) return null; const parsed = new Date(`${raw}T00:00:00Z`); if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== raw) return null; return raw; } function finite(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function oneOf(value: unknown, allowed: readonly T[]): T | null { const raw = text(value, 40); return allowed.find((item) => item === raw) ?? null; } function minutes(value: unknown): number | null { const parsed = finite(value); if (parsed == null || !Number.isInteger(parsed) || parsed < 0 || parsed > 720) return null; return parsed; } export function parseChartSubjectWrite(value: unknown): { ok: true; write: ChartSubjectWrite } | { ok: false; error: string } { const profile = recordOf(value); if (!profile) return { ok: false, error: "星盘资料格式不正确" }; if ("active_birth_time" in profile || "activeBirthTime" in profile || "active_birth_date" in profile || "activeDate" in profile || "active_birth_timezone_offset" in profile || "activeTimezoneOffset" in profile) { return { ok: false, error: "不能直接写入排盘采用时间" }; } const statusRaw = text(profile.birthTimeStatus ?? profile.birth_time_status, 40); if (statusRaw === "accepted" || statusRaw === "confirmed") { return { ok: false, error: "不能把出生时间标成已采用" }; } const name = text(profile.name, 80); if (!name) return { ok: false, error: "请填写称呼" }; const birthDate = date(profile.date ?? profile.birth_date); const reported = clock(profile.reportedTime ?? profile.reported_birth_time); const source = oneOf(profile.birthTimeSource ?? profile.birth_time_source, SOURCES); const period = oneOf(profile.birthTimePeriod ?? profile.birth_time_period, PERIODS); const windowStart = clock(profile.declaredWindowStart ?? profile.declared_window_start); const windowEnd = clock(profile.declaredWindowEnd ?? profile.declared_window_end); const before = minutes(profile.uncertaintyBeforeMinutes ?? profile.uncertainty_before_minutes); const after = minutes(profile.uncertaintyAfterMinutes ?? profile.uncertainty_after_minutes); const latitude = finite(profile.latitude); const longitude = finite(profile.longitude); if (latitude != null && (latitude < -90 || latitude > 90)) return { ok: false, error: "出生地坐标不正确" }; if (longitude != null && (longitude < -180 || longitude > 180)) return { ok: false, error: "出生地坐标不正确" }; const timezoneOffset = finite(profile.timezoneOffset ?? profile.timezone_offset); const timezoneId = text(profile.timezoneId ?? profile.timezone_id, 80); const label = text(profile.birthPlaceLabel ?? profile.birth_place_label, 160); const status = oneOf(statusRaw, STATUSES) ?? (reported ? "reported" : null); const ayanamsa = isAyanamsaName(profile.ayanamsa) ? profile.ayanamsa : resolveAyanamsa(profile); // Optional gender: written only when the client sends the key, so a caller // that does not know the field never clears it. null / "" clear it. const genderSent = Object.prototype.hasOwnProperty.call(profile, "gender"); const gender = parseProfileGender(profile.gender); if (genderSent && profile.gender != null && profile.gender !== "" && !gender) { return { ok: false, error: "性别只能选女、男或不填" }; } const columns: Record = { name, birth_date: birthDate, reported_birth_time: reported, birth_time_source: source, birth_time_period: period, declared_window_start: windowStart, declared_window_end: windowEnd, uncertainty_before_minutes: before, uncertainty_after_minutes: after, latitude, longitude, timezone_id: timezoneId || null, timezone_offset: timezoneOffset, birth_place_label: label || null, birth_place_type: text(profile.birthPlaceType ?? profile.birth_place_type, 40) || null, birth_place_provider: text(profile.birthPlaceProvider ?? profile.birth_place_provider, 40) || null, birth_place_provider_id: text(profile.birthPlaceProviderId ?? profile.birth_place_provider_id, 120) || null, ayanamsa, birth_time_status: status, ...(genderSent ? { gender } : {}), }; const mirror = { name, date: birthDate ?? "", time: reported ?? "", reportedTime: reported ?? "", birthTimeSource: source ?? "", birthTimePeriod: period ?? "", declaredWindowStart: windowStart ?? "", declaredWindowEnd: windowEnd ?? "", uncertaintyBeforeMinutes: before, uncertaintyAfterMinutes: after, birthTimeStatus: status ?? "", countryCode: text(profile.countryCode ?? profile.country_code, 8) || "CN", provinceCode: text(profile.provinceCode ?? profile.province_code, 20), cityCode: text(profile.cityCode ?? profile.city_code, 20), districtCode: text(profile.districtCode ?? profile.district_code, 20), birthPlaceLabel: label, birthPlaceType: columns.birth_place_type ?? "", birthPlaceProvider: columns.birth_place_provider ?? "", birthPlaceProviderId: columns.birth_place_provider_id ?? "", timezoneId, timezoneSource: text(profile.timezoneSource ?? profile.timezone_source, 40), latitude, longitude, timezoneOffset, ayanamsa, }; return { ok: true, write: { columns, profile: mirror } }; } export function libraryProfileFromSubjectRow(row: Record): Record { const stored = recordOf(row.profile) ?? {}; const reported = typeof row.reported_birth_time === "string" ? row.reported_birth_time.slice(0, 5) : ""; const active = typeof row.active_birth_time === "string" ? row.active_birth_time.slice(0, 5) : ""; const status = typeof row.birth_time_status === "string" ? row.birth_time_status : ""; const chartTime = status === "accepted" || status === "confirmed" ? (active || reported) : reported; return { name: typeof row.name === "string" ? row.name : "", date: typeof row.birth_date === "string" ? row.birth_date.slice(0, 10) : "", time: chartTime, reportedTime: reported, birthTimeSource: typeof row.birth_time_source === "string" ? row.birth_time_source : "", birthTimePeriod: typeof row.birth_time_period === "string" ? row.birth_time_period : "", declaredWindowStart: typeof row.declared_window_start === "string" ? row.declared_window_start.slice(0, 5) : "", declaredWindowEnd: typeof row.declared_window_end === "string" ? row.declared_window_end.slice(0, 5) : "", uncertaintyBeforeMinutes: typeof row.uncertainty_before_minutes === "number" ? row.uncertainty_before_minutes : null, uncertaintyAfterMinutes: typeof row.uncertainty_after_minutes === "number" ? row.uncertainty_after_minutes : null, birthTimeStatus: status, countryCode: typeof stored.countryCode === "string" ? stored.countryCode : "CN", provinceCode: typeof stored.provinceCode === "string" ? stored.provinceCode : "", cityCode: typeof stored.cityCode === "string" ? stored.cityCode : "", districtCode: typeof stored.districtCode === "string" ? stored.districtCode : "", birthPlaceLabel: typeof row.birth_place_label === "string" ? row.birth_place_label : "", birthPlaceType: typeof row.birth_place_type === "string" ? row.birth_place_type : "", birthPlaceProvider: typeof row.birth_place_provider === "string" ? row.birth_place_provider : "", birthPlaceProviderId: typeof row.birth_place_provider_id === "string" ? row.birth_place_provider_id : "", timezoneId: typeof row.timezone_id === "string" ? row.timezone_id : "", timezoneSource: typeof stored.timezoneSource === "string" ? stored.timezoneSource : "", latitude: typeof row.latitude === "number" ? row.latitude : null, longitude: typeof row.longitude === "number" ? row.longitude : null, timezoneOffset: typeof row.timezone_offset === "number" ? row.timezone_offset : null, ayanamsa: resolveAyanamsa(row), ...(parseProfileGender(row.gender) ? { gender: parseProfileGender(row.gender) } : {}), ...(typeof row.active_birth_date === "string" ? { activeDate: row.active_birth_date.slice(0, 10) } : {}), ...(typeof row.active_birth_timezone_offset === "number" ? { activeTimezoneOffset: row.active_birth_timezone_offset } : {}), }; } export function subjectLimitReached(otherCount: number): boolean { return otherCount >= MAX_OTHER_SUBJECTS; }