Skip to main content

x_iztro/
dto.rs

1//! JS iztro 兼容的序列化 DTO。
2//!
3//! 核心模型(`Astrolabe`/`HoroscopeData`)以枚举承载数据,供 Rust 调用方做
4//! 类型安全查询;跨语言绑定(FFI/PyO3)输出的 JSON 则来自本模块的 DTO:
5//! 键为 camelCase、值为按排盘语言翻译的字符串,结构与 JS iztro 的
6//! `JSON.stringify` 输出一致(不含其 `plugins`/`copyright` 及运限对象内嵌的
7//! `astrolabe` 等实现细节字段)。
8//!
9//! 在 JS 字段集之外附加两类扩展:
10//! - 排盘上下文(`genderKey`/`timeIndex`/`fixLeap`/`language`/`config`),
11//!   使消费方能以纯参数(无状态)方式发起运限计算;
12//! - 语言无关标识(星/宫/干支/四化/亮度的 `*key`/`*Key(s)` 字段,取值为
13//!   iztro i18n key),供强类型绑定做跨语言的身份判断与枚举映射;宫位另带
14//!   `mutagenStarKeys`(本宫天干化出的禄权科忌四星),绑定层据此做飞星判断。
15//!
16//! 出错时三条绑定出口(FFI/wasm/PyO3)返回同一形状的错误 JSON
17//! `{"error": "<描述>", "code": "<分类>"}`,分类取值见 [`crate::error::BridgeError`]。
18
19use serde::{Deserialize, Serialize};
20
21use crate::data::stars::{MUTAGEN, StarKey};
22use crate::data::types::*;
23use crate::error::BridgeError;
24use crate::i18n::{
25    translate_brightness, translate_earthly_branch, translate_five_elements_class,
26    translate_gender, translate_heavenly_stem, translate_mutagen, translate_palace, translate_star,
27};
28use crate::models::astrolabe::Astrolabe;
29use crate::models::horoscope::{HoroscopeData, HoroscopeItem};
30use crate::models::palace::PalaceData;
31use crate::models::star::Star;
32
33/// 星耀 DTO。
34/// 主星/辅星的 `brightness` 恒存在(无亮度为空串);无四化时省略 `mutagen` 键;
35/// 杂耀与运限流耀省略 `brightness` 与 `mutagen`。
36/// `key`/`brightnessKey`/`mutagenKey` 为语言无关标识(x-iztro 扩展),
37/// 供强类型绑定做跨语言的身份判断。
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct StarDto {
41    /// 语言无关星耀标识(iztro i18n key,如 "ziweiMaj")
42    pub key: String,
43    /// 名称(按排盘语言翻译)
44    pub name: String,
45    /// 星耀类型(major/soft/tough/adjective/flower/helper/lucun/tianma)
46    #[serde(rename = "type")]
47    pub star_type: String,
48    /// 作用范围(origin/decadal/yearly/monthly/daily/hourly)
49    pub scope: String,
50    /// 亮度显示文本;主星辅星恒有该键(无亮度为空串),杂耀与流耀省略
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub brightness: Option<String>,
53    /// 语言无关亮度标识("miao" 等),无亮度时省略
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub brightness_key: Option<String>,
56    /// 四化显示文本;四化候选星恒有该键(无四化为空串),其余省略
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub mutagen: Option<String>,
59    /// 语言无关四化标识("sihuaLu" 等),无四化时省略
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub mutagen_key: Option<String>,
62}
63
64/// 大限区间与干支 DTO。
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct DecadalDto {
68    /// 大限起止虚岁(起始、截止,含两端)
69    pub range: [u32; 2],
70    /// 天干(按排盘语言翻译)
71    pub heavenly_stem: String,
72    /// 语言无关天干标识("jiaHeavenly" 等)
73    pub heavenly_stem_key: String,
74    /// 地支(按排盘语言翻译)
75    pub earthly_branch: String,
76    /// 语言无关地支标识("ziEarthly" 等)
77    pub earthly_branch_key: String,
78}
79
80/// 宫位 DTO。
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct PalaceDto {
84    /// 宫位索引(0-11,寅宫为 0)
85    pub index: usize,
86    /// 名称(按排盘语言翻译)
87    pub name: String,
88    /// 语言无关宫位标识("soulPalace" 等)
89    pub name_key: String,
90    /// 是否身宫
91    pub is_body_palace: bool,
92    /// 是否来因宫
93    pub is_original_palace: bool,
94    /// 天干(按排盘语言翻译)
95    pub heavenly_stem: String,
96    /// 语言无关天干标识
97    pub heavenly_stem_key: String,
98    /// 地支(按排盘语言翻译)
99    pub earthly_branch: String,
100    /// 语言无关地支标识
101    pub earthly_branch_key: String,
102    /// 主星列表(按安放顺序)
103    pub major_stars: Vec<StarDto>,
104    /// 辅星列表(按安放顺序)
105    pub minor_stars: Vec<StarDto>,
106    /// 杂耀列表(按安放顺序)
107    pub adjective_stars: Vec<StarDto>,
108    /// 长生十二神(按排盘语言翻译)
109    pub changsheng12: String,
110    /// 长生十二神的语言无关标识
111    pub changsheng12_key: String,
112    /// 博士十二神(按排盘语言翻译)
113    pub boshi12: String,
114    /// 博士十二神的语言无关标识
115    pub boshi12_key: String,
116    /// 将前十二神(按排盘语言翻译)
117    pub jiangqian12: String,
118    /// 将前十二神的语言无关标识
119    pub jiangqian12_key: String,
120    /// 岁前十二神(按排盘语言翻译)
121    pub suiqian12: String,
122    /// 岁前十二神的语言无关标识
123    pub suiqian12_key: String,
124    /// 大限信息
125    pub decadal: DecadalDto,
126    /// 小限经过的虚岁列表
127    pub ages: Vec<u32>,
128    /// x-iztro 扩展:本宫天干化出的四颗星的语言无关标识,顺序为禄、权、科、忌。
129    /// 取自排盘时生效的四化表(含 `Config` 的自定义覆盖),
130    /// 绑定层据此做飞星判断,无须各自再抄一份四化表。
131    pub mutagen_star_keys: [String; 4],
132}
133
134/// 数字化农历生日 DTO。
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct RawLunarDateDto {
138    /// 农历年
139    pub lunar_year: i64,
140    /// 农历月(1-12,闰月与否见 is_leap)
141    pub lunar_month: u32,
142    /// 农历日(1-30)
143    pub lunar_day: u32,
144    /// 是否闰月
145    pub is_leap: bool,
146}
147
148/// 四柱干支 DTO(每柱为 [天干, 地支] 两元素数组)。
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct RawChineseDateDto {
152    /// 年柱(天干、地支,干支原文)
153    pub yearly: [String; 2],
154    /// 月柱(天干、地支,干支原文)
155    pub monthly: [String; 2],
156    /// 日柱(天干、地支,干支原文)
157    pub daily: [String; 2],
158    /// 时柱(天干、地支,干支原文)
159    pub hourly: [String; 2],
160    /// 年柱的语言无关标识(天干、地支)
161    pub yearly_keys: [String; 2],
162    /// 月柱的语言无关标识
163    pub monthly_keys: [String; 2],
164    /// 日柱的语言无关标识
165    pub daily_keys: [String; 2],
166    /// 时柱的语言无关标识
167    pub hourly_keys: [String; 2],
168}
169
170/// 结构化出生日期 DTO。
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct RawDatesDto {
174    /// 数字化农历生日
175    pub lunar_date: RawLunarDateDto,
176    /// 四柱干支
177    pub chinese_date: RawChineseDateDto,
178}
179
180/// 排盘配置 DTO(字符串取值,与 JS iztro 的 config() 取值一致)。
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct ConfigDto {
184    /// "normal" | "exact"
185    pub year_divide: String,
186    /// "normal" | "exact"
187    pub horoscope_divide: String,
188    /// "normal" | "birthday"
189    pub age_divide: String,
190    /// "forward" | "current"
191    pub day_divide: String,
192    /// "default" | "zhongzhou"
193    pub algorithm: String,
194    /// "heaven" | "earth" | "human"
195    pub astro_type: String,
196}
197
198/// 星盘 DTO。
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct AstrolabeDto {
202    /// 性别(按排盘语言翻译)
203    pub gender: String,
204    /// 阳历日期("YYYY-M-D")
205    pub solar_date: String,
206    /// 农历日期中文表示
207    pub lunar_date: String,
208    /// 干支纪日四柱展示串
209    pub chinese_date: String,
210    /// 结构化的出生日期信息
211    pub raw_dates: RawDatesDto,
212    /// 时辰名称(按排盘语言)
213    pub time: String,
214    /// 时辰对应的时间段
215    pub time_range: String,
216    /// 星座(按排盘语言)
217    pub sign: String,
218    /// 生肖(按排盘语言)
219    pub zodiac: String,
220    /// 命宫地支(按排盘语言翻译)
221    pub earthly_branch_of_soul_palace: String,
222    /// 命宫地支的语言无关标识
223    pub earthly_branch_of_soul_palace_key: String,
224    /// 身宫地支(按排盘语言翻译)
225    pub earthly_branch_of_body_palace: String,
226    /// 身宫地支的语言无关标识
227    pub earthly_branch_of_body_palace_key: String,
228    /// 命主星(按排盘语言翻译)
229    pub soul: String,
230    /// 命主星的语言无关标识
231    pub soul_key: String,
232    /// 身主星(按排盘语言翻译)
233    pub body: String,
234    /// 身主星的语言无关标识
235    pub body_key: String,
236    /// 五行局(按排盘语言翻译)
237    pub five_elements_class: String,
238    /// 五行局的语言无关标识("water2nd" 等)
239    pub five_elements_class_key: String,
240    /// 十二宫数据
241    pub palaces: Vec<PalaceDto>,
242    /// x-iztro 扩展:机器可读性别("male"/"female",无状态运限所需)
243    pub gender_key: String,
244    /// x-iztro 扩展:出生时辰索引(无状态运限所需)
245    pub time_index: u8,
246    /// x-iztro 扩展:是否修正闰月
247    pub fix_leap: bool,
248    /// x-iztro 扩展:排盘语言("zh-CN" 等)
249    pub language: String,
250    /// x-iztro 扩展:排盘配置
251    pub config: ConfigDto,
252}
253
254/// 运限单层级 DTO(大限/流年/流月/流日/流时)。
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct HoroscopeScopeDto {
258    /// 宫位索引(0-11,寅宫为 0)
259    pub index: usize,
260    /// 名称(按排盘语言翻译)
261    pub name: String,
262    /// 天干(按排盘语言翻译)
263    pub heavenly_stem: String,
264    /// 语言无关天干标识
265    pub heavenly_stem_key: String,
266    /// 地支(按排盘语言翻译)
267    pub earthly_branch: String,
268    /// 语言无关地支标识
269    pub earthly_branch_key: String,
270    /// 该运限的十二宫名(按宫位索引排列,翻译文本)
271    pub palace_names: Vec<String>,
272    /// 十二宫名的语言无关标识
273    pub palace_name_keys: Vec<String>,
274    /// 四化星名(禄、权、科、忌,翻译文本)
275    pub mutagen: Vec<String>,
276    /// 四化星(禄、权、科、忌)的语言无关标识
277    pub mutagen_keys: Vec<String>,
278    /// 流耀在十二宫的分布;无流耀的层级省略该键
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub stars: Option<Vec<Vec<StarDto>>>,
281}
282
283/// 小限 DTO。
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct AgeDto {
287    /// 通用运限字段
288    #[serde(flatten)]
289    pub base: HoroscopeScopeDto,
290    /// 虚岁
291    pub nominal_age: u32,
292}
293
294/// 流年十二神 DTO。
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct YearlyDecStarDto {
297    /// 岁前十二神(按宫位索引排列,翻译文本)
298    pub suiqian12: Vec<String>,
299    /// 岁前十二神的语言无关标识
300    #[serde(rename = "suiqian12Keys")]
301    pub suiqian12_keys: Vec<String>,
302    /// 将前十二神(按宫位索引排列,翻译文本)
303    pub jiangqian12: Vec<String>,
304    /// 将前十二神的语言无关标识
305    #[serde(rename = "jiangqian12Keys")]
306    pub jiangqian12_keys: Vec<String>,
307}
308
309/// 流年 DTO。
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct YearlyDto {
313    /// 通用运限字段
314    #[serde(flatten)]
315    pub base: HoroscopeScopeDto,
316    /// 流年十二神
317    pub yearly_dec_star: YearlyDecStarDto,
318}
319
320/// 运限 DTO。
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct HoroscopeDto {
324    /// 农历日期中文表示
325    pub lunar_date: String,
326    /// 阳历日期("YYYY-M-D")
327    pub solar_date: String,
328    /// 大限(未起运时为童限)
329    pub decadal: HoroscopeScopeDto,
330    /// 小限
331    pub age: AgeDto,
332    /// 流年
333    pub yearly: YearlyDto,
334    /// 流月
335    pub monthly: HoroscopeScopeDto,
336    /// 流日
337    pub daily: HoroscopeScopeDto,
338    /// 流时
339    pub hourly: HoroscopeScopeDto,
340}
341
342// ============================================================
343// 枚举 → 字符串
344// ============================================================
345
346/// 绑定层出口的错误 JSON:`{"error": "<message>", "code": "<code>"}`。
347///
348/// 用结构体而非 map 序列化,键序固定为 error 先、code 后;消息经 serde
349/// 转义,引号、反斜杠与控制字符一律安全。
350pub(crate) fn error_json(err: &BridgeError) -> String {
351    #[derive(Serialize)]
352    struct ErrorJson<'a> {
353        error: &'a str,
354        code: &'a str,
355    }
356    serde_json::to_string(&ErrorJson {
357        error: &err.message,
358        code: err.code,
359    })
360    .expect("错误 JSON 只含两个字符串字段,序列化不会失败")
361}
362
363/// 从 panic 载荷提取人类可读消息(downcast String/&str,兜底固定文案)。
364/// 供各绑定层把核心计算的 panic 转为对外错误。
365pub(crate) fn panic_message(panic: &(dyn std::any::Any + Send)) -> &str {
366    panic
367        .downcast_ref::<String>()
368        .map(String::as_str)
369        .or_else(|| panic.downcast_ref::<&str>().copied())
370        .unwrap_or("computation panicked")
371}
372
373/// config JSON 的部分键补丁:缺省键取默认值。
374#[derive(Debug, Default, Deserialize)]
375#[serde(rename_all = "camelCase", default)]
376struct ConfigPatch {
377    year_divide: Option<String>,
378    horoscope_divide: Option<String>,
379    age_divide: Option<String>,
380    day_divide: Option<String>,
381    algorithm: Option<String>,
382    astro_type: Option<String>,
383    /// 天干标识 → 四化星标识数组(禄权科忌四项)
384    mutagens: Option<std::collections::HashMap<String, Vec<String>>>,
385    /// 星耀标识 → 十二宫亮度标识数组(十二项,空串表示该宫无亮度)
386    brightness: Option<std::collections::HashMap<String, Vec<String>>>,
387}
388
389/// 解析绑定层的 config JSON(如 `{"algorithm":"zhongzhou"}`)。
390/// `None` 或空串返回默认配置;未出现的键取默认值;非法取值报错。
391///
392/// # Errors
393/// JSON 语法错误或任一键取值不在允许集合内时返回 `invalid_argument`。
394pub fn parse_config_json(json: Option<&str>) -> Result<Config, BridgeError> {
395    let json = match json {
396        None => return Ok(Config::default()),
397        Some(s) if s.trim().is_empty() => return Ok(Config::default()),
398        Some(s) => s,
399    };
400    let patch: ConfigPatch = serde_json::from_str(json)
401        .map_err(|e| BridgeError::invalid_argument(format!("invalid config JSON: {e}")))?;
402    config_from_patch(patch)
403}
404
405/// 由已反序列化的补丁构造配置,供 `parse_config_json` 与 `serde_json::Value`
406/// 入参共用——后者不必先序列化回字符串再解析一遍。
407///
408/// # Errors
409/// 任一键取值不在允许集合内时返回 `invalid_argument`。
410pub fn parse_config_value(value: &serde_json::Value) -> Result<Config, BridgeError> {
411    let patch: ConfigPatch = serde_json::from_value(value.clone())
412        .map_err(|e| BridgeError::invalid_argument(format!("invalid config: {e}")))?;
413    config_from_patch(patch)
414}
415
416/// 开关键取值非法的统一报错:`invalid <field> '<value>': expected <expected>`
417fn bad_switch(field: &str, value: &str, expected: &str) -> BridgeError {
418    BridgeError::invalid_argument(format!("invalid {field} '{value}': expected {expected}"))
419}
420
421fn config_from_patch(patch: ConfigPatch) -> Result<Config, BridgeError> {
422    let mut config = Config::default();
423    if let Some(v) = patch.year_divide {
424        config.year_divide = YearDivide::from_key(&v)
425            .ok_or_else(|| bad_switch("yearDivide", &v, "'normal' or 'exact'"))?;
426    }
427    if let Some(v) = patch.horoscope_divide {
428        config.horoscope_divide = HoroscopeDivide::from_key(&v)
429            .ok_or_else(|| bad_switch("horoscopeDivide", &v, "'normal' or 'exact'"))?;
430    }
431    if let Some(v) = patch.age_divide {
432        config.age_divide = AgeDivide::from_key(&v)
433            .ok_or_else(|| bad_switch("ageDivide", &v, "'normal' or 'birthday'"))?;
434    }
435    if let Some(v) = patch.day_divide {
436        config.day_divide = DayDivide::from_key(&v)
437            .ok_or_else(|| bad_switch("dayDivide", &v, "'forward' or 'current'"))?;
438    }
439    if let Some(v) = patch.algorithm {
440        config.algorithm = Algorithm::from_key(&v)
441            .ok_or_else(|| bad_switch("algorithm", &v, "'default' or 'zhongzhou'"))?;
442    }
443    if let Some(v) = patch.astro_type {
444        config.astro_type = AstroType::from_key(&v)
445            .ok_or_else(|| bad_switch("astroType", &v, "'heaven', 'earth' or 'human'"))?;
446    }
447
448    // 自定义四化与亮度表:键与值都是语言无关标识
449    if let Some(map) = patch.mutagens {
450        for (stem_key, star_keys) in map {
451            let stem = HeavenlyStem::from_key(&stem_key).ok_or_else(|| {
452                BridgeError::invalid_argument(format!(
453                    "invalid mutagens key '{stem_key}': unknown heavenly stem"
454                ))
455            })?;
456            if star_keys.len() != 4 {
457                return Err(BridgeError::invalid_argument(format!(
458                    "invalid mutagens for '{stem_key}': expected 4 stars (lu, quan, ke, ji), got {}",
459                    star_keys.len()
460                )));
461            }
462            let mut stars = [StarKey::ZiweiMaj; 4];
463            for (i, key) in star_keys.iter().enumerate() {
464                stars[i] = StarKey::from_key(key).ok_or_else(|| {
465                    BridgeError::invalid_argument(format!(
466                        "invalid mutagens for '{stem_key}': unknown star '{key}'"
467                    ))
468                })?;
469            }
470            config = config.with_mutagens(stem, stars);
471        }
472    }
473    if let Some(map) = patch.brightness {
474        for (star_key, brightness_keys) in map {
475            let star = StarKey::from_key(&star_key).ok_or_else(|| {
476                BridgeError::invalid_argument(format!(
477                    "invalid brightness key '{star_key}': unknown star"
478                ))
479            })?;
480            if brightness_keys.len() != 12 {
481                return Err(BridgeError::invalid_argument(format!(
482                    "invalid brightness for '{star_key}': expected 12 entries, got {}",
483                    brightness_keys.len()
484                )));
485            }
486            let mut table = [None; 12];
487            for (i, key) in brightness_keys.iter().enumerate() {
488                // 空串表示该宫位无亮度
489                if key.is_empty() {
490                    continue;
491                }
492                table[i] = Some(Brightness::from_key(key).ok_or_else(|| {
493                    BridgeError::invalid_argument(format!(
494                        "invalid brightness for '{star_key}': unknown brightness '{key}'"
495                    ))
496                })?);
497            }
498            config = config.with_brightness(star, table);
499        }
500    }
501
502    Ok(config)
503}
504
505impl From<Config> for ConfigDto {
506    fn from(c: Config) -> Self {
507        ConfigDto {
508            year_divide: c.year_divide.as_key().to_string(),
509            horoscope_divide: c.horoscope_divide.as_key().to_string(),
510            age_divide: c.age_divide.as_key().to_string(),
511            day_divide: c.day_divide.as_key().to_string(),
512            algorithm: c.algorithm.as_key().to_string(),
513            astro_type: c.astro_type.as_key().to_string(),
514        }
515    }
516}
517
518// ============================================================
519// 模型 → DTO
520// ============================================================
521
522/// 是否属于四化候选星(十四主星与文昌/文曲/左辅/右弼)。
523/// 这些星的 DTO 恒带 mutagen 键(无四化为空串),其余星省略该键。
524fn is_mutagen_candidate(key: crate::data::stars::StarKey) -> bool {
525    use crate::data::stars::StarKey::*;
526    matches!(
527        key,
528        ZiweiMaj
529            | TianjiMaj
530            | TaiyangMaj
531            | WuquMaj
532            | TiantongMaj
533            | LianzhenMaj
534            | TianfuMaj
535            | TaiyinMaj
536            | TanlangMaj
537            | JumenMaj
538            | TianxiangMaj
539            | TianliangMaj
540            | QishaMaj
541            | PojunMaj
542            | WenchangMin
543            | WenquMin
544            | ZuofuMin
545            | YoubiMin
546    )
547}
548
549/// 主星/辅星条目:brightness 恒存在(无亮度为空串);
550/// 四化候选星恒带 mutagen 键(无四化为空串),其余省略。
551fn primary_star_dto(s: &Star, lang: Language) -> StarDto {
552    StarDto {
553        key: s.key.as_key().to_string(),
554        name: s.name.clone(),
555        star_type: StarType::as_key(s.star_type).to_string(),
556        scope: Scope::as_key(s.scope).to_string(),
557        brightness: Some(
558            s.brightness
559                .map(|b| translate_brightness(b, lang).to_string())
560                .unwrap_or_default(),
561        ),
562        brightness_key: s.brightness.map(|b| b.as_key().to_string()),
563        mutagen: if is_mutagen_candidate(s.key) {
564            Some(
565                s.mutagen
566                    .map(|m| translate_mutagen(m, lang).to_string())
567                    .unwrap_or_default(),
568            )
569        } else {
570            None
571        },
572        mutagen_key: s.mutagen.map(|m| m.as_key().to_string()),
573    }
574}
575
576/// 杂耀/流耀条目:仅 name/type/scope 三键。
577fn bare_star_dto(s: &Star) -> StarDto {
578    StarDto {
579        key: s.key.as_key().to_string(),
580        name: s.name.clone(),
581        star_type: StarType::as_key(s.star_type).to_string(),
582        scope: Scope::as_key(s.scope).to_string(),
583        brightness: None,
584        brightness_key: None,
585        mutagen: None,
586        mutagen_key: None,
587    }
588}
589
590fn pillar(p: (HeavenlyStem, EarthlyBranch), lang: Language) -> [String; 2] {
591    [
592        translate_heavenly_stem(p.0, lang).to_string(),
593        translate_earthly_branch(p.1, lang).to_string(),
594    ]
595}
596
597/// 四柱的语言无关标识,与 `pillar` 一一对应。
598fn pillar_keys(p: (HeavenlyStem, EarthlyBranch)) -> [String; 2] {
599    [p.0.as_key().to_string(), p.1.as_key().to_string()]
600}
601
602fn palace_dto(p: &PalaceData, lang: Language) -> PalaceDto {
603    PalaceDto {
604        index: p.index,
605        name: translate_palace(p.name, lang).to_string(),
606        name_key: p.name.as_key().to_string(),
607        is_body_palace: p.is_body_palace,
608        is_original_palace: p.is_original_palace,
609        heavenly_stem: translate_heavenly_stem(p.heavenly_stem, lang).to_string(),
610        heavenly_stem_key: p.heavenly_stem.as_key().to_string(),
611        earthly_branch: translate_earthly_branch(p.earthly_branch, lang).to_string(),
612        earthly_branch_key: p.earthly_branch.as_key().to_string(),
613        major_stars: p
614            .major_stars
615            .iter()
616            .map(|s| primary_star_dto(s, lang))
617            .collect(),
618        minor_stars: p
619            .minor_stars
620            .iter()
621            .map(|s| primary_star_dto(s, lang))
622            .collect(),
623        adjective_stars: p.adjective_stars.iter().map(bare_star_dto).collect(),
624        changsheng12: translate_star(p.changsheng12, lang).to_string(),
625        changsheng12_key: p.changsheng12.as_key().to_string(),
626        boshi12: translate_star(p.boshi12, lang).to_string(),
627        boshi12_key: p.boshi12.as_key().to_string(),
628        jiangqian12: translate_star(p.jiangqian12, lang).to_string(),
629        jiangqian12_key: p.jiangqian12.as_key().to_string(),
630        suiqian12: translate_star(p.suiqian12, lang).to_string(),
631        suiqian12_key: p.suiqian12.as_key().to_string(),
632        decadal: DecadalDto {
633            range: [p.decadal.range.0, p.decadal.range.1],
634            heavenly_stem: translate_heavenly_stem(p.decadal.heavenly_stem, lang).to_string(),
635            heavenly_stem_key: p.decadal.heavenly_stem.as_key().to_string(),
636            earthly_branch: translate_earthly_branch(p.decadal.earthly_branch, lang).to_string(),
637            earthly_branch_key: p.decadal.earthly_branch.as_key().to_string(),
638        },
639        ages: p.ages.clone(),
640        mutagen_star_keys: {
641            let stars = p.mutagen_stars(&MUTAGEN);
642            std::array::from_fn(|i| stars[i].as_key().to_string())
643        },
644    }
645}
646
647impl Astrolabe {
648    /// 转为 JS iztro 兼容的序列化 DTO(值按排盘语言翻译)。
649    pub fn to_dto(&self) -> AstrolabeDto {
650        let lang = self.language;
651        AstrolabeDto {
652            gender: translate_gender(self.gender, lang).to_string(),
653            solar_date: self.solar_date.clone(),
654            lunar_date: self.lunar_date.clone(),
655            chinese_date: self.chinese_date.clone(),
656            raw_dates: RawDatesDto {
657                lunar_date: RawLunarDateDto {
658                    lunar_year: self.raw_dates.lunar_date.lunar_year,
659                    lunar_month: self.raw_dates.lunar_date.lunar_month,
660                    lunar_day: self.raw_dates.lunar_date.lunar_day,
661                    is_leap: self.raw_dates.lunar_date.is_leap,
662                },
663                // rawDates 的四柱为未本地化的干支原文(任何输出语言下均为中文)
664                chinese_date: RawChineseDateDto {
665                    yearly: pillar(self.raw_dates.chinese_date.yearly, Language::ZhCN),
666                    monthly: pillar(self.raw_dates.chinese_date.monthly, Language::ZhCN),
667                    daily: pillar(self.raw_dates.chinese_date.daily, Language::ZhCN),
668                    hourly: pillar(self.raw_dates.chinese_date.hourly, Language::ZhCN),
669                    yearly_keys: pillar_keys(self.raw_dates.chinese_date.yearly),
670                    monthly_keys: pillar_keys(self.raw_dates.chinese_date.monthly),
671                    daily_keys: pillar_keys(self.raw_dates.chinese_date.daily),
672                    hourly_keys: pillar_keys(self.raw_dates.chinese_date.hourly),
673                },
674            },
675            time: self.time.clone(),
676            time_range: self.time_range.clone(),
677            sign: self.sign.clone(),
678            zodiac: self.zodiac.clone(),
679            earthly_branch_of_soul_palace: translate_earthly_branch(
680                self.earthly_branch_of_soul_palace,
681                lang,
682            )
683            .to_string(),
684            earthly_branch_of_soul_palace_key: self
685                .earthly_branch_of_soul_palace
686                .as_key()
687                .to_string(),
688            earthly_branch_of_body_palace: translate_earthly_branch(
689                self.earthly_branch_of_body_palace,
690                lang,
691            )
692            .to_string(),
693            earthly_branch_of_body_palace_key: self
694                .earthly_branch_of_body_palace
695                .as_key()
696                .to_string(),
697            soul: translate_star(self.soul, lang).to_string(),
698            soul_key: self.soul.as_key().to_string(),
699            body: translate_star(self.body, lang).to_string(),
700            body_key: self.body.as_key().to_string(),
701            five_elements_class: translate_five_elements_class(self.five_elements_class, lang)
702                .to_string(),
703            five_elements_class_key: self.five_elements_class.as_key().to_string(),
704            palaces: self.palaces.iter().map(|p| palace_dto(p, lang)).collect(),
705            gender_key: match self.gender {
706                Gender::Male => "male",
707                Gender::Female => "female",
708            }
709            .to_string(),
710            time_index: self.time_index,
711            fix_leap: self.fix_leap,
712            language: lang.as_code().to_string(),
713            config: self.config.clone().into(),
714        }
715    }
716}
717
718fn scope_dto(item: &HoroscopeItem, lang: Language) -> HoroscopeScopeDto {
719    HoroscopeScopeDto {
720        index: item.index,
721        name: item.name.clone(),
722        heavenly_stem: translate_heavenly_stem(item.heavenly_stem, lang).to_string(),
723        heavenly_stem_key: item.heavenly_stem.as_key().to_string(),
724        earthly_branch: translate_earthly_branch(item.earthly_branch, lang).to_string(),
725        earthly_branch_key: item.earthly_branch.as_key().to_string(),
726        palace_names: item
727            .palace_names
728            .iter()
729            .map(|p| translate_palace(*p, lang).to_string())
730            .collect(),
731        palace_name_keys: item
732            .palace_names
733            .iter()
734            .map(|p| p.as_key().to_string())
735            .collect(),
736        mutagen: item
737            .mutagen
738            .iter()
739            .map(|k| translate_star(*k, lang).to_string())
740            .collect(),
741        mutagen_keys: item
742            .mutagen
743            .iter()
744            .map(|k| k.as_key().to_string())
745            .collect(),
746        stars: item.stars.as_ref().map(|groups| {
747            groups
748                .iter()
749                .map(|g| g.iter().map(bare_star_dto).collect())
750                .collect()
751        }),
752    }
753}
754
755impl HoroscopeData {
756    /// 转为 JS iztro 兼容的序列化 DTO(值按给定语言翻译)。
757    pub fn to_dto(&self, lang: Language) -> HoroscopeDto {
758        HoroscopeDto {
759            lunar_date: self.lunar_date.clone(),
760            solar_date: self.solar_date.clone(),
761            decadal: scope_dto(&self.decadal, lang),
762            age: AgeDto {
763                base: scope_dto(&self.age.base, lang),
764                nominal_age: self.age.nominal_age,
765            },
766            yearly: YearlyDto {
767                base: scope_dto(&self.yearly.base, lang),
768                yearly_dec_star: YearlyDecStarDto {
769                    suiqian12: self
770                        .yearly
771                        .yearly_dec_star
772                        .suiqian12
773                        .iter()
774                        .map(|k| translate_star(*k, lang).to_string())
775                        .collect(),
776                    suiqian12_keys: self
777                        .yearly
778                        .yearly_dec_star
779                        .suiqian12
780                        .iter()
781                        .map(|k| k.as_key().to_string())
782                        .collect(),
783                    jiangqian12: self
784                        .yearly
785                        .yearly_dec_star
786                        .jiangqian12
787                        .iter()
788                        .map(|k| translate_star(*k, lang).to_string())
789                        .collect(),
790                    jiangqian12_keys: self
791                        .yearly
792                        .yearly_dec_star
793                        .jiangqian12
794                        .iter()
795                        .map(|k| k.as_key().to_string())
796                        .collect(),
797                },
798            },
799            monthly: scope_dto(&self.monthly, lang),
800            daily: scope_dto(&self.daily, lang),
801            hourly: scope_dto(&self.hourly, lang),
802        }
803    }
804}