新报告用围栏画出大运和事业、财富、婚恋,标题下写名字与日期。旧报告不变。 自测:pytest test_report_full_data_fix.py 10 通过;相关四份报告测试退出码 0;前端相关测试 147 通过、0 失败、0 取消;tsc 退出码 0;lint 0 error。 Co-Authored-By: Grok (agent loop)
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import { z } from "zod";
|
||
|
||
import type { ReportLanguage } from "./report-language";
|
||
|
||
const PLANET_ZH: Record<string, string> = {
|
||
Sun: "太阳",
|
||
Moon: "月亮",
|
||
Mars: "火星",
|
||
Mercury: "水星",
|
||
Jupiter: "木星",
|
||
Venus: "金星",
|
||
Saturn: "土星",
|
||
Rahu: "北交点",
|
||
Ketu: "南交点",
|
||
};
|
||
|
||
const LEVEL_LABEL = {
|
||
md: { zh: "主运", en: "Major" },
|
||
ad: { zh: "子运", en: "Sub" },
|
||
pd: { zh: "次子运", en: "Sub-sub" },
|
||
} as const;
|
||
|
||
const DOMAIN_LABEL = {
|
||
career: { zh: "事业", en: "Career" },
|
||
wealth: { zh: "财富", en: "Wealth" },
|
||
relationship: { zh: "婚恋", en: "Relationship" },
|
||
} as const;
|
||
|
||
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable();
|
||
const lord = z.string().min(1).max(40).nullable();
|
||
|
||
export const reportFocusBlockSchema = z.strictObject({
|
||
version: z.literal(1),
|
||
periods: z.tuple([
|
||
z.strictObject({ level: z.literal("md"), lord, start: day, end: day }),
|
||
z.strictObject({ level: z.literal("ad"), lord, start: day, end: day }),
|
||
z.strictObject({ level: z.literal("pd"), lord, start: day, end: day }),
|
||
]),
|
||
domains: z.tuple([
|
||
z.strictObject({
|
||
key: z.literal("career"),
|
||
house: z.literal(10),
|
||
lord,
|
||
placed: z.number().int().min(1).max(12).nullable(),
|
||
dignity: z.string().min(1).max(80),
|
||
role: z.string().min(1).max(80),
|
||
}),
|
||
z.strictObject({
|
||
key: z.literal("wealth"),
|
||
house: z.literal(2),
|
||
lord,
|
||
placed: z.number().int().min(1).max(12).nullable(),
|
||
dignity: z.string().min(1).max(80),
|
||
role: z.string().min(1).max(80),
|
||
}),
|
||
z.strictObject({
|
||
key: z.literal("relationship"),
|
||
house: z.literal(7),
|
||
lord,
|
||
placed: z.number().int().min(1).max(12).nullable(),
|
||
dignity: z.string().min(1).max(80),
|
||
role: z.string().min(1).max(80),
|
||
}),
|
||
]),
|
||
caveat: z.string().min(1).max(240),
|
||
});
|
||
|
||
export type ReportFocusBlock = z.infer<typeof reportFocusBlockSchema>;
|
||
|
||
export function parseReportFocusBlock(source: string): ReportFocusBlock | null {
|
||
try {
|
||
return reportFocusBlockSchema.parse(JSON.parse(source));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function planetLabel(name: string | null, language: ReportLanguage): string {
|
||
if (!name) return language === "en" ? "not listed" : "未列出";
|
||
if (language === "zh") return PLANET_ZH[name] ?? name;
|
||
return name;
|
||
}
|
||
|
||
function monthLabel(iso: string | null): string | null {
|
||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
|
||
}
|
||
|
||
export function periodRange(start: string | null, end: string | null): string | null {
|
||
const from = monthLabel(start);
|
||
const to = monthLabel(end);
|
||
if (from && to) return `${from} – ${to}`;
|
||
return from ?? to;
|
||
}
|
||
|
||
export function focusBlockText(block: ReportFocusBlock, language: ReportLanguage = "zh"): string {
|
||
const kicker = language === "en" ? "Current period" : "现在走的大运";
|
||
const periods = block.periods.map((period) => {
|
||
const label = LEVEL_LABEL[period.level][language];
|
||
const range = periodRange(period.start, period.end);
|
||
const name = planetLabel(period.lord, language);
|
||
return range ? `${label} ${name} ${range}` : `${label} ${name}`;
|
||
});
|
||
const domains = block.domains.map((domain) => {
|
||
const label = DOMAIN_LABEL[domain.key][language];
|
||
const name = planetLabel(domain.lord, language);
|
||
const placed = domain.placed === null
|
||
? (language === "en" ? "house not listed" : "落宫未列出")
|
||
: (language === "en" ? `house ${domain.placed}` : `第${domain.placed}宫`);
|
||
const where = language === "en"
|
||
? `${label}: House ${domain.house} lord · ${name} · ${placed}`
|
||
: domain.placed === null
|
||
? `${label}:第${domain.house}宫主 · ${name} · ${placed}`
|
||
: `${label}:第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
|
||
return `${where}(${domain.dignity};${domain.role})`;
|
||
});
|
||
return [kicker, ...periods, ...domains, block.caveat].join("\n");
|
||
}
|
||
|
||
const FOCUS_FENCE_RE = /```jyotish-focus\r?\n([\s\S]*?)```/g;
|
||
|
||
/** Turn the fence into readable lines. A bad fence becomes the same invalid sentence as the page. */
|
||
export function renderReportFocusFences(markdown: string, language: ReportLanguage = "zh"): string {
|
||
return markdown.replace(FOCUS_FENCE_RE, (_full, source: string) => {
|
||
const block = parseReportFocusBlock(String(source).trim());
|
||
if (!block) return language === "en" ? "Focus data is invalid\n" : "重点数据无效\n";
|
||
return `${focusBlockText(block, language)}\n`;
|
||
});
|
||
}
|
||
|
||
export function reportTitleByline(input: {
|
||
title: string;
|
||
subjectName?: string | null;
|
||
createdAt?: string | null;
|
||
language?: ReportLanguage;
|
||
}): string | null {
|
||
const language = input.language ?? "zh";
|
||
const name = (input.subjectName ?? "").trim();
|
||
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec((input.createdAt ?? "").trim());
|
||
const date = dateMatch && validDay(dateMatch[1]) ? dateMatch[1] : "";
|
||
const showName = name.length > 0 && !input.title.includes(name);
|
||
if (!showName && !date) return null;
|
||
if (language === "en") {
|
||
if (showName && date) return `${name} · Generated ${date}`;
|
||
if (showName) return name;
|
||
return `Generated ${date}`;
|
||
}
|
||
if (showName && date) return `${name} · 生成于 ${date}`;
|
||
if (showName) return name;
|
||
return `生成于 ${date}`;
|
||
}
|
||
|
||
function validDay(iso: string): boolean {
|
||
const [year, month, day] = iso.split("-").map(Number);
|
||
if (!year || !month || !day) return false;
|
||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||
return true;
|
||
}
|