Consolidate desktop controls with editable holidays and a running-window tray
This commit is contained in:
1 parent
9908dde9f8
commit
326465952a
55 files changed
+4207
-450
No files matched your search
@@ -15,6 +15,7 @@ regex = "1.12"
|
||||
ferese-panel = { path = "../ferese-panel" }
|
||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||
jiff.workspace = true
|
||||
rhai = { version = "1.27", features = ["sync"] }
|
||||
kdl = "6.7.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Japanese holiday function and official reference
|
||||
|
||||
Source: Cabinet Office, [国民の祝日について](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html).
|
||||
|
||||
- CSV: https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv
|
||||
- Retrieved: 2026-10-28
|
||||
- Original SHA-256: `cec37a743c96995cdb9cb52b685c9003634682a9b0e1a640a6b9b96881fe964a`
|
||||
- Coverage: 1955-01-01 through 2027-11-23
|
||||
- Conversion: CP932 to UTF-8; dates normalized to YYYY-MM-DD; header removed.
|
||||
|
||||
`japanese-holidays.rhai` is the editable, built-in runtime data source. It calculates 1955–2099 Japanese holidays, including historical rules and one-off exceptions. Unpublished years are estimates; equinox holidays become official after publication in the government gazette. See [NAOJ](https://www.nao.ac.jp/faq/a0301.html).
|
||||
|
||||
The CSV is an independent regression reference, including substitute holidays and holidays between national holidays. The full function output is checked against every date and name for 1955–2027. To refresh the reference, download the official CSV, apply the same conversion, update exceptions in the function as needed, and rerun calendar tests.
|
||||
|
||||
Source terms: [Cabinet Office website terms](https://www.cao.go.jp/notice/rule.html).
|
||||
@@ -0,0 +1,82 @@
|
||||
// 日本祝日计算函数。返回 YYYY-MM-DD → 节假日名称的映射。
|
||||
// weekday(date) 的周日为 0;date(year, month, day) 与 add_days(date, n) 生成日期。
|
||||
// 规则适用 1955–2099 年;未公布年份的春秋分日为近似计算。
|
||||
fn monday(year, month, nth) {
|
||||
let first = date(year, month, 1);
|
||||
1 + (8 - weekday(first)) % 7 + 7 * (nth - 1)
|
||||
}
|
||||
|
||||
fn japanese_holidays(year) {
|
||||
let h = #{};
|
||||
if year < 1955 || year > 2099 { return h; }
|
||||
h[date(year, 1, 1)] = "元日";
|
||||
h[date(year, 1, if year >= 2000 { monday(year, 1, 2) } else { 15 })] = "成人の日";
|
||||
if year >= 1967 { h[date(year, 2, 11)] = "建国記念の日"; }
|
||||
if year >= 2020 { h[date(year, 2, 23)] = "天皇誕生日"; }
|
||||
if year >= 1989 && year <= 2018 { h[date(year, 12, 23)] = "天皇誕生日"; }
|
||||
h[date(year, 4, 29)] = if year >= 2007 { "昭和の日" }
|
||||
else if year >= 1989 { "みどりの日" } else { "天皇誕生日" };
|
||||
|
||||
// 春分・秋分近似式。官报公布前的将来日期仅为推算。
|
||||
let spring_base = if year < 1980 { 20.8357 } else { 20.8431 };
|
||||
let autumn_base = if year < 1980 { 23.2588 } else { 23.2488 };
|
||||
let leap_base = if year < 1980 { 1983 } else { 1980 };
|
||||
let shift = 0.242194 * (year - 1980) - (year - leap_base) / 4;
|
||||
h[date(year, 3, floor(spring_base + shift))] = "春分の日";
|
||||
h[date(year, 9, floor(autumn_base + shift))] = "秋分の日";
|
||||
h[date(year, 5, 3)] = "憲法記念日";
|
||||
if year >= 2007 { h[date(year, 5, 4)] = "みどりの日"; }
|
||||
h[date(year, 5, 5)] = "こどもの日";
|
||||
h[date(year, 11, 3)] = "文化の日";
|
||||
h[date(year, 11, 23)] = "勤労感謝の日";
|
||||
|
||||
if year >= 1966 {
|
||||
h[date(year, 9, if year >= 2003 { monday(year, 9, 3) } else { 15 })] = "敬老の日";
|
||||
let sport = if year == 2020 { date(year, 7, 24) }
|
||||
else if year == 2021 { date(year, 7, 23) }
|
||||
else { date(year, 10, if year >= 2000 { monday(year, 10, 2) } else { 10 }) };
|
||||
h[sport] = if year >= 2020 { "スポーツの日" }
|
||||
else if year == 2019 { "体育の日(スポーツの日)" } else { "体育の日" };
|
||||
}
|
||||
if year >= 1996 {
|
||||
let sea = if year == 2020 { date(year, 7, 23) }
|
||||
else if year == 2021 { date(year, 7, 22) }
|
||||
else { date(year, 7, if year >= 2003 { monday(year, 7, 3) } else { 20 }) };
|
||||
h[sea] = "海の日";
|
||||
}
|
||||
if year >= 2016 {
|
||||
h[date(year, 8, if year == 2020 { 10 } else if year == 2021 { 8 } else { 11 })] = "山の日";
|
||||
}
|
||||
// 即位・成婚等の特例。
|
||||
if year == 1959 { h[date(year, 4, 10)] = "結婚の儀"; }
|
||||
if year == 1989 { h[date(year, 2, 24)] = "大喪の礼"; }
|
||||
if year == 1990 { h[date(year, 11, 12)] = "即位礼正殿の儀"; }
|
||||
if year == 1993 { h[date(year, 6, 9)] = "結婚の儀"; }
|
||||
if year == 2019 {
|
||||
h[date(year, 5, 1)] = "休日(祝日扱い)";
|
||||
h[date(year, 10, 22)] = "休日(祝日扱い)";
|
||||
}
|
||||
let national = h;
|
||||
let first = date(year, 1, 1);
|
||||
// 祝日に挟まれた日:1986 年から適用、日曜日は対象外。
|
||||
if year >= 1986 {
|
||||
for day in 0..days_in_year(year) {
|
||||
let d = add_days(first, day);
|
||||
if weekday(d) != 0 && !h.contains(d)
|
||||
&& national.contains(add_days(d, -1)) && national.contains(add_days(d, 1)) {
|
||||
h[d] = "休日";
|
||||
}
|
||||
}
|
||||
}
|
||||
// 振替休日:1973 年導入、2007 年以降は次の祝日でない日まで繰り越す。
|
||||
for d in national.keys() {
|
||||
if d >= "1973-04-12" && weekday(d) == 0 {
|
||||
let substitute = add_days(d, 1);
|
||||
if year >= 2007 {
|
||||
while h.contains(substitute) { substitute = add_days(substitute, 1); }
|
||||
}
|
||||
if !h.contains(substitute) { h[substitute] = "休日"; }
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -588,5 +588,455 @@
|
||||
"Wallpaper image": "壁纸图像",
|
||||
"Shortcut guide": "快捷键指南",
|
||||
"Custom shortcuts": "自定义快捷键",
|
||||
"Animations": "动画"
|
||||
"Animations": "动画",
|
||||
"accent": "强调色",
|
||||
"active_color": "活动颜色",
|
||||
"active_opacity": "活动窗口不透明度",
|
||||
"adaptive": "自适应",
|
||||
"always": "始终",
|
||||
"angle": "角度",
|
||||
"app-icons": "应用图标",
|
||||
"app_id": "应用 ID",
|
||||
"attached": "贴合屏幕边缘",
|
||||
"auto_advance": "自动进入下一阶段",
|
||||
"auto_back_and_forth": "再次按工作区快捷键返回",
|
||||
"auto_refresh": "自动刷新",
|
||||
"background": "背景",
|
||||
"background_color": "背景颜色",
|
||||
"bar": "面板",
|
||||
"block_out_from_screencasts": "在录屏中隐藏",
|
||||
"blur": "模糊",
|
||||
"border_width": "边框宽度",
|
||||
"bottom": "底部",
|
||||
"bounce": "回弹",
|
||||
"calendar_height": "日历与控制中心高度",
|
||||
"capsules": "胶囊",
|
||||
"center_on_focus": "焦点居中",
|
||||
"command": "命令",
|
||||
"compact": "紧凑",
|
||||
"confirm_timeout": "确认超时",
|
||||
"control_gap": "控件间距",
|
||||
"controls_height": "控制区高度",
|
||||
"corner-wrap": "包裹圆角",
|
||||
"default_column_display": "默认列显示方式",
|
||||
"default_column_width": "默认列宽",
|
||||
"dim_after_seconds": "闲置多久后调暗",
|
||||
"disabled": "禁用",
|
||||
"do_not_disturb": "勿扰模式",
|
||||
"dock-or-suspend": "外接显示器或挂起",
|
||||
"dots": "圆点",
|
||||
"duration_ms": "持续时间(毫秒)",
|
||||
"eager": "主动",
|
||||
"edge_margin": "屏幕边缘间距",
|
||||
"en": "English",
|
||||
"expanded_radius": "展开时圆角",
|
||||
"expanded_width": "展开时宽度",
|
||||
"extend": "扩展",
|
||||
"external-only": "仅外接显示器",
|
||||
"fail": "失败",
|
||||
"fill": "填充",
|
||||
"fit": "适应",
|
||||
"flash_duration_ms": "闪烁时长(毫秒)",
|
||||
"flat": "平面",
|
||||
"flipped": "水平翻转",
|
||||
"flipped_180": "翻转并旋转 180°",
|
||||
"flipped_270": "翻转并旋转 270°",
|
||||
"flipped_90": "翻转并旋转 90°",
|
||||
"floating": "悬浮",
|
||||
"focus_effect": "焦点效果",
|
||||
"focus_ring": "焦点边框",
|
||||
"focus_ring_width": "焦点边框宽度",
|
||||
"focus_strategy": "焦点策略",
|
||||
"font_size": "字号",
|
||||
"frame": "边框",
|
||||
"from": "起点",
|
||||
"fullscreen": "全屏",
|
||||
"fullscreen-playing": "全屏播放时",
|
||||
"fullscreen_playback": "全屏播放",
|
||||
"fused": "融合",
|
||||
"gap_threshold": "融合间距阈值",
|
||||
"gradient": "渐变",
|
||||
"hit_width": "点击区域宽度",
|
||||
"idle_width": "收起宽度",
|
||||
"ignore": "忽略",
|
||||
"inactive_color": "非活动颜色",
|
||||
"inactive_dim": "非活动窗口调暗",
|
||||
"inactive_opacity": "非活动窗口不透明度",
|
||||
"inner_gap": "窗口间距",
|
||||
"inner_padding": "内部留白",
|
||||
"internal-only": "仅内置显示器",
|
||||
"intervals": "阶段计时",
|
||||
"island_padding": "岛形内边距",
|
||||
"keysym": "按键符号",
|
||||
"lid_closed": "合盖状态",
|
||||
"lid_policy": "合盖行为",
|
||||
"list_width": "列表宽度",
|
||||
"long_break_minutes": "长休息时长(分钟)",
|
||||
"max_total": "总数量上限",
|
||||
"max_visible": "可见数量上限",
|
||||
"media_height": "媒体区域高度",
|
||||
"min_height": "最小高度",
|
||||
"min_width": "最小宽度",
|
||||
"minimal": "最少移动",
|
||||
"mirror": "镜像",
|
||||
"mirror_source": "镜像来源",
|
||||
"nested": "嵌套",
|
||||
"never": "从不",
|
||||
"none": "无",
|
||||
"normal": "正常",
|
||||
"notch": "刘海形状",
|
||||
"notification_bridge": "通知桥接",
|
||||
"notifications_height": "通知区域高度",
|
||||
"numbers": "数字",
|
||||
"offset_y": "垂直偏移",
|
||||
"ok": "成功",
|
||||
"on-demand": "按需",
|
||||
"outer_gap": "工作区边距",
|
||||
"outline_color": "轮廓颜色",
|
||||
"outline_width": "轮廓宽度",
|
||||
"overshoot": "越界回弹",
|
||||
"owner_ceiling": "单个来源数量上限",
|
||||
"padding": "内边距",
|
||||
"paged": "分页",
|
||||
"path": "路径",
|
||||
"peek_height": "预览高度",
|
||||
"peek_on_hover": "悬停展开预览",
|
||||
"peek_width": "预览宽度",
|
||||
"physical": "物理尺寸",
|
||||
"playing": "播放中",
|
||||
"pomodoro": "番茄钟",
|
||||
"process_watch": "进程监视",
|
||||
"reduced_motion": "减少动画",
|
||||
"required": "必需",
|
||||
"restart": "退出后重启",
|
||||
"result_flash_ms": "结果闪烁时长(毫秒)",
|
||||
"rotate_180": "旋转 180°",
|
||||
"rotate_270": "旋转 270°",
|
||||
"rotate_90": "旋转 90°",
|
||||
"row_height": "行高",
|
||||
"segment_gap": "分段间距",
|
||||
"segment_height": "分段高度",
|
||||
"shadow": "阴影",
|
||||
"shell_hook": "Shell 钩子",
|
||||
"shell_radius": "Shell 圆角",
|
||||
"short_break_minutes": "短休息时长(分钟)",
|
||||
"show_badge": "显示数量标记",
|
||||
"show_popups": "显示弹出通知",
|
||||
"side_margins": "左右边距",
|
||||
"sleep_after_seconds": "闲置多久后关闭显示器",
|
||||
"smart_gaps": "智能间距",
|
||||
"soft": "柔和",
|
||||
"solid": "实色",
|
||||
"sources": "来源",
|
||||
"speed": "速度",
|
||||
"spring": "弹簧动画",
|
||||
"stacked": "堆叠",
|
||||
"start_delay_ms": "启动延迟(毫秒)",
|
||||
"startup": "启动时",
|
||||
"surface": "表面",
|
||||
"surface_base": "基础表面",
|
||||
"surface_raised": "抬升表面",
|
||||
"swipe": "滑动手势",
|
||||
"tab_indicator": "标签指示器",
|
||||
"tabbed": "标签式",
|
||||
"tabs": "标签",
|
||||
"temperature": "色温",
|
||||
"text_color": "文字颜色",
|
||||
"text_primary": "主要文字",
|
||||
"timeout_ms": "超时时长(毫秒)",
|
||||
"tint_strength": "染色强度",
|
||||
"to": "终点",
|
||||
"toast_height": "通知提示高度",
|
||||
"toast_width": "通知提示宽度",
|
||||
"top": "顶部",
|
||||
"transient": "临时",
|
||||
"translucent": "半透明",
|
||||
"tree": "树形",
|
||||
"viewport_spring": "视口弹簧动画",
|
||||
"wide": "宽幅",
|
||||
"width_presets": "宽度预设",
|
||||
"window-hug": "贴合窗口",
|
||||
"window-stacks": "窗口堆叠",
|
||||
"window_clearance": "窗口避让间距",
|
||||
"window_radius": "窗口圆角",
|
||||
"work_minutes": "专注时长(分钟)",
|
||||
"workspace": "工作区",
|
||||
"workspaces_height": "工作区区域高度",
|
||||
"zh-CN": "简体中文",
|
||||
"Config": "配置",
|
||||
"Docker build": "Docker 构建",
|
||||
"Git push": "Git 推送",
|
||||
"Make": "Make 构建",
|
||||
"Action": "动作",
|
||||
"Action name, such as spawn or focus": "动作名称,例如 spawn 或 focus。",
|
||||
"Adapts to available space.": "根据可用空间自动调整。",
|
||||
"Add a fine line around the panel shape.": "在面板形状周围添加细边框。",
|
||||
"Add holiday": "添加节假日",
|
||||
"Allow the device to use service {uuid}?": "允许设备使用服务 {uuid} 吗?",
|
||||
"Allow this device to pair?": "允许此设备配对吗?",
|
||||
"Another device is already pairing.": "另一个设备正在配对。",
|
||||
"Argument": "参数",
|
||||
"Attached to the screen edge": "贴合屏幕边缘",
|
||||
"Auto (battery)": "自动(电池)",
|
||||
"Auto appearance: off": "自动外观:关",
|
||||
"Auto appearance: on": "自动外观:开",
|
||||
"Auto follows the GTK/GNOME appearance preference": "自动模式跟随 GTK/GNOME 外观偏好。",
|
||||
"Background opacity": "背景不透明度",
|
||||
"Battery percentage": "电量百分比",
|
||||
"Blocked by a hardware switch or airplane mode": "已被硬件开关或飞行模式禁用",
|
||||
"Bluetooth": "蓝牙",
|
||||
"Bluetooth unavailable": "蓝牙不可用",
|
||||
"Both modes": "深浅两种模式",
|
||||
"Bottom panel": "底部面板",
|
||||
"Cancel pairing": "取消配对",
|
||||
"Changes apply live": "更改即时生效",
|
||||
"Choose Find devices to discover nearby devices in pairing mode.": "点击“查找设备”,发现附近处于配对模式的设备。",
|
||||
"Choose a partial KDL theme file": "选择包含主题覆盖项的 KDL 文件",
|
||||
"Choose the screen edge for the panel.": "选择面板所在的屏幕边缘。",
|
||||
"Choose when to use the overflow menu.": "选择何时将此项收纳至更多菜单。",
|
||||
"Collapse advanced customization": "收起高级自定义",
|
||||
"Collapsed height": "收起时高度",
|
||||
"Command, direction, or action argument": "命令、方向或动作参数。",
|
||||
"Configuration schema query timed out": "配置结构查询超时",
|
||||
"Confirm": "确认",
|
||||
"Connected groups with rounded ends": "圆角端点相连的分组",
|
||||
"Connected · ": "已连接 · ",
|
||||
"Connecting · ": "正在连接 · ",
|
||||
"Connection services are unavailable. Use Refresh to try again.": "连接服务不可用,请点击刷新重试。",
|
||||
"Corner radius: {label}": "圆角半径:{label}",
|
||||
"Corners follow the style and panel height.": "圆角随面板样式和高度调整。",
|
||||
"Could not load wallpaper preview.": "无法加载壁纸预览。",
|
||||
"Custom holidays": "自定义节假日",
|
||||
"Customize corners first.": "请先启用自定义圆角。",
|
||||
"Customize items first.": "请先启用自定义面板项。",
|
||||
"Customize swipe down": "自定义向下滑动",
|
||||
"Customize swipe left": "自定义向左滑动",
|
||||
"Customize swipe right": "自定义向右滑动",
|
||||
"Customize swipe up": "自定义向上滑动",
|
||||
"Dark mode": "深色模式",
|
||||
"Dark theme": "深色主题",
|
||||
"Date": "日期",
|
||||
"Decrease {label}": "减小{label}",
|
||||
"Decrease {label} radius": "减小{label}圆角",
|
||||
"Devices": "设备",
|
||||
"Discovery canceled.": "已取消搜索设备。",
|
||||
"Display profile": "显示配置",
|
||||
"Does {key} match the number on your device?": "{key} 与设备上显示的数字一致吗?",
|
||||
"Edge left": "靠屏幕边缘的左角",
|
||||
"Edge right": "靠屏幕边缘的右角",
|
||||
"Enhanced open": "增强型开放网络",
|
||||
"Enter the PIN shown on your device.": "输入设备上显示的 PIN 码。",
|
||||
"Enter the six-digit passkey shown on your device.": "输入设备上显示的六位配对码。",
|
||||
"Enterprise": "企业网络",
|
||||
"Enterprise and legacy networks need a saved NetworkManager profile with their security settings.": "企业网络及旧式网络需要预先保存包含相应安全设置的 NetworkManager 配置。",
|
||||
"Expand advanced customization": "展开高级自定义",
|
||||
"Expand the island when you hover over it.": "鼠标悬停时展开岛形面板。",
|
||||
"Find devices": "查找设备",
|
||||
"Follow the GTK/GNOME appearance preference.": "跟随 GTK/GNOME 外观偏好。",
|
||||
"Follows the active window state": "跟随活动窗口状态",
|
||||
"Forget this connection?": "忘记此连接吗?",
|
||||
"Full": "完整",
|
||||
"Group arrangement & spacing": "分组排列与间距",
|
||||
"Groups blend where they meet": "分组在相接处融合",
|
||||
"Headphones": "耳机",
|
||||
"Hidden network": "隐藏网络",
|
||||
"Holiday name": "节假日名称",
|
||||
"Horizontal padding": "水平内边距",
|
||||
"Increase {label}": "增大{label}",
|
||||
"Increase {label} radius": "增大{label}圆角",
|
||||
"Invalid display response": "显示器返回了无效信息",
|
||||
"Invalid network password.": "网络密码无效。",
|
||||
"Invalid panel corner.": "无效的面板圆角。",
|
||||
"Invalid passkey": "配对码无效",
|
||||
"Island padding": "岛形内边距",
|
||||
"Item settings": "项目设置",
|
||||
"Item spacing": "项目间距",
|
||||
"Japanese public holidays are included. Add personal dates below; MM-DD repeats annually.": "已包含日本法定节假日。可在下方添加个人节假日,MM-DD 格式每年重复。",
|
||||
"Join a hidden network…": "加入隐藏网络…",
|
||||
"Join the island to the screen edge with curved shoulders.": "以弧形连接处将岛形面板贴合屏幕边缘。",
|
||||
"Keep visible": "保持可见",
|
||||
"Keys or swipe, such as Super+Return": "按键或滑动手势,例如 Super+Return。",
|
||||
"Laptop": "笔记本电脑",
|
||||
"Legacy security": "旧式安全协议",
|
||||
"Light mode": "浅色模式",
|
||||
"Light theme": "浅色主题",
|
||||
"Loading connection services…": "正在加载连接服务…",
|
||||
"Loading preview…": "正在加载预览…",
|
||||
"Managed by the Ferese session.": "由 Ferese 会话管理。",
|
||||
"Maximum activities in the collapsed and peek island.": "收起和预览状态下显示的活动数量上限。",
|
||||
"Missing build directory": "缺少构建目录",
|
||||
"Missing executable directory": "缺少可执行文件目录",
|
||||
"Move or remove this group's items before removing the group.": "删除分组前,请先移动或移除其中的项目。",
|
||||
"Nearby devices": "附近设备",
|
||||
"Network name (SSID)": "网络名称(SSID)",
|
||||
"Network password": "网络密码",
|
||||
"Networks": "网络",
|
||||
"No Bluetooth adapter is available.": "没有可用的蓝牙适配器。",
|
||||
"No Wi-Fi adapter": "没有 Wi-Fi 适配器",
|
||||
"No Wi-Fi adapter is available.": "没有可用的 Wi-Fi 适配器。",
|
||||
"No matching pairing request": "没有匹配的配对请求",
|
||||
"No nearby devices found.": "未发现附近设备。",
|
||||
"No networks found. Scan to look for nearby networks.": "未发现网络,请扫描附近网络。",
|
||||
"No override file": "未设置覆盖文件",
|
||||
"No paired devices yet.": "尚无已配对设备。",
|
||||
"No panel configured.": "尚未配置面板。",
|
||||
"No wallpaper image selected.": "尚未选择壁纸。",
|
||||
"Not paired": "未配对",
|
||||
"Open at login": "登录时启动",
|
||||
"Outer left": "外侧左角",
|
||||
"Outer right": "外侧右角",
|
||||
"Overflow is supplied by the panel.": "更多菜单由面板自动提供。",
|
||||
"PIN must have 1–16 characters": "PIN 码需要 1–16 个字符",
|
||||
"PIN or passkey": "PIN 或配对码",
|
||||
"Pair with {device}": "与 {device} 配对",
|
||||
"Paired": "已配对",
|
||||
"Paired devices": "已配对设备",
|
||||
"Pairing canceled or timed out": "配对已取消或超时",
|
||||
"Pairing canceled.": "已取消配对。",
|
||||
"Panel only. Text and icons stay opaque.": "仅影响面板,文字和图标保持不透明。",
|
||||
"Panel position: {label}": "面板位置:{label}",
|
||||
"Panel preview unavailable: {error}": "面板预览不可用:{error}",
|
||||
"Passkey must have up to six digits": "配对码不能超过六位数字",
|
||||
"Percentage": "百分比",
|
||||
"Pins stay in place when you change styles. Top and bottom panels mirror them.": "切换样式时保留固定项目;顶部和底部面板会镜像显示。",
|
||||
"Preferred size": "首选尺寸",
|
||||
"Program": "程序",
|
||||
"Put your device in pairing mode. Discovery stops automatically after 30 seconds.": "请将设备设为配对模式,搜索将在 30 秒后自动停止。",
|
||||
"Reduce transparency is on. Turn it off in Accessibility to change opacity.": "已启用“减少透明效果”,请在辅助功能中关闭后调整不透明度。",
|
||||
"Remove holiday": "移除节假日",
|
||||
"Resolution / refresh": "分辨率/刷新率",
|
||||
"Restart if it exits": "退出后重启",
|
||||
"Saved credentials or pairing keys will be removed. You will need to connect again.": "将移除保存的凭据或配对密钥,下次使用时需重新连接。",
|
||||
"Saved profile · applies when active": "已保存的配置 · 启用时生效",
|
||||
"Saved · ": "已保存 · ",
|
||||
"Scan": "扫描",
|
||||
"Schema query failed": "配置结构查询失败",
|
||||
"Searching for devices…": "正在搜索设备…",
|
||||
"Select display": "选择显示器",
|
||||
"Separate floating groups": "独立悬浮分组",
|
||||
"Shared": "共享",
|
||||
"Shortcut": "快捷键",
|
||||
"Show +n for additional activities.": "以 +n 表示更多活动。",
|
||||
"Show item": "显示项目",
|
||||
"Show up to {count} activities": "最多显示 {count} 个活动",
|
||||
"Song title": "歌曲名称",
|
||||
"Song title · Artist ▷": "歌曲名称 · 艺术家 ▷",
|
||||
"Stop scan": "停止扫描",
|
||||
"Style": "样式",
|
||||
"Submit": "提交",
|
||||
"Switch between light and dark at set times.": "在指定时间切换深浅外观。",
|
||||
"The drop target changed. Try dragging again.": "放置目标已变化,请重新拖动。",
|
||||
"The network connection failed. Check the password and try again.": "网络连接失败,请检查密码后重试。",
|
||||
"The network did not finish connecting. Check its status before trying again.": "网络连接尚未完成,请检查状态后再重试。",
|
||||
"Theme file: {label}": "主题文件:{label}",
|
||||
"This group no longer exists. Reload settings.": "此分组已不存在,请重新加载设置。",
|
||||
"This item no longer exists. Reload settings.": "此项目已不存在,请重新加载设置。",
|
||||
"Top panel": "顶部面板",
|
||||
"Trust": "信任",
|
||||
"Trusted": "已信任",
|
||||
"Unknown panel geometry setting.": "未知的面板几何设置。",
|
||||
"Unknown panel group setting.": "未知的面板分组设置。",
|
||||
"Unknown panel item setting.": "未知的面板项目设置。",
|
||||
"Unknown sender": "未知来源",
|
||||
"Unsupported configuration schema": "不支持的配置结构",
|
||||
"Untrust": "取消信任",
|
||||
"Update": "更新",
|
||||
"Use different themes for light and dark": "为深浅模式分别设置主题",
|
||||
"Using shell opacity. Text and icons stay opaque.": "使用 Shell 不透明度,文字和图标保持不透明。",
|
||||
"Vertical padding": "垂直内边距",
|
||||
"Visible activities": "可见活动",
|
||||
"When needed": "按需",
|
||||
"Wi-Fi power": "Wi-Fi 开关",
|
||||
"Wi-Fi unavailable": "Wi-Fi 不可用",
|
||||
"Workspace style belongs to Workspaces items.": "工作区样式只能用于工作区项目。",
|
||||
"YYYY-MM-DD / MM-DD": "YYYY-MM-DD / MM-DD",
|
||||
"YYYY-MM-DD for one date; MM-DD repeats every year.": "YYYY-MM-DD 为单次日期,MM-DD 为每年重复的日期。",
|
||||
"Timezone": "时区",
|
||||
"Translucent": "半透明",
|
||||
"Minimal": "最少移动",
|
||||
"Centered": "居中",
|
||||
"Paged": "分页",
|
||||
"Move content in the direction of your fingers. Updates immediately.": "内容随手指方向滚动,即时生效。",
|
||||
"Mouse natural scrolling": "鼠标自然滚动",
|
||||
"Mouse speed": "鼠标速度",
|
||||
"Mouse scroll speed": "鼠标滚轮速度",
|
||||
"Show dates from adjacent months": "显示非本月日期",
|
||||
"Display the previous and next month's dates in the calendar.": "在日历中显示上个月和下个月的相邻日期。",
|
||||
"Holiday date must be YYYY-MM-DD or MM-DD.": "节假日日期必须使用 YYYY-MM-DD 或 MM-DD 格式。",
|
||||
"Holiday name must contain 1–80 characters.": "节假日名称需要 1–80 个字符。",
|
||||
"calendar": "日历",
|
||||
"show_adjacent_days": "显示非本月日期",
|
||||
"custom_holidays": "自定义节假日",
|
||||
"date": "日期",
|
||||
"☀ Light": "☀ 浅色",
|
||||
"☾ Dark": "☾ 深色",
|
||||
"◐ Auto": "◐ 自动",
|
||||
"Auto · currently light": "自动 · 当前为浅色",
|
||||
"Auto · currently dark": "自动 · 当前为深色",
|
||||
"Open calendar and controls": "打开日历与控制中心",
|
||||
"Light from {light}, dark from {dark} ({zone})": "{light} 起使用浅色,{dark} 起使用深色({zone})",
|
||||
" · Trusted": " · 已信任",
|
||||
" · {percent}% battery": " · 电量 {percent}%",
|
||||
"{percent}% signal": "信号 {percent}%",
|
||||
"Join {network}": "加入 {network}",
|
||||
"Opacity for the current {name} appearance. Text and icons stay opaque.": "当前{name}外观的不透明度,文字和图标保持不透明。",
|
||||
"24-hour": "24 小时制",
|
||||
"12-hour": "12 小时制",
|
||||
"Japanese holidays": "日本祝日",
|
||||
"Edit holiday calculation function": "编辑祝日计算函数",
|
||||
"Apply function": "应用函数",
|
||||
"Checking function…": "正在校验函数…",
|
||||
"Restore built-in function": "恢复内置函数",
|
||||
"Holiday function error: {error}": "祝日函数错误:{error}",
|
||||
"Rhai function japanese_holidays(year) returns a YYYY-MM-DD to name map. Built-in rules cover 1955–2099; unpublished years are estimates.": "Rhai 函数 japanese_holidays(year) 返回日期与名称的映射。内置规则适用 1955–2099 年,未公布年份按规则推算。",
|
||||
"japanese_holiday_function": "日本祝日计算函数",
|
||||
"Holiday function exceeds 16 KiB.": "祝日函数不能超过 16 KiB。",
|
||||
"Edit Notifications": "编辑通知标记",
|
||||
"Wed, 7 Oct · 14:35": "10 月 7 日 周三 · 14:35",
|
||||
"Build": "构建",
|
||||
"Build · 62%": "构建 · 62%",
|
||||
"Build · 62% +1": "构建 · 62% +1",
|
||||
"Calendar display": "日期显示",
|
||||
"Calendar, Japanese holiday rules and custom holidays.": "日历、日本祝日规则与自定义节假日。",
|
||||
"Reset {label}": "重置{label}",
|
||||
"Workspace style: {label}": "工作区样式:{label}",
|
||||
"Edit group {name}": "编辑分组 {name}",
|
||||
"Edit {label} · {id}": "编辑{label} · {id}",
|
||||
"Holiday function exceeded its operation limit.": "函数超过运算次数限制,请检查循环是否能够结束。",
|
||||
"Holiday function exceeded its call depth limit.": "函数超过调用层数限制,请检查递归是否能够结束。",
|
||||
"Holiday function must return YYYY-MM-DD keys.": "函数返回的日期必须使用 YYYY-MM-DD 格式。",
|
||||
"Holiday function returned a date outside the requested year.": "函数返回了所请求年份以外的日期。",
|
||||
"Holiday function must return text names.": "函数返回的节假日名称必须是文字。",
|
||||
"now": "刚刚",
|
||||
"{count}m ago": "{count} 分钟前",
|
||||
"{count}h ago": "{count} 小时前",
|
||||
"{count}d ago": "{count} 天前",
|
||||
"A quiet moment": "暂时没有通知",
|
||||
"Popups paused": "已暂停弹出通知",
|
||||
"Popups enabled": "已启用弹出通知",
|
||||
"{count} notification · {apps} app": "{count} 条通知 · {apps} 个应用",
|
||||
"{count} notification · {apps} apps": "{count} 条通知 · {apps} 个应用",
|
||||
"{count} notifications · {apps} app": "{count} 条通知 · {apps} 个应用",
|
||||
"{count} notifications · {apps} apps": "{count} 条通知 · {apps} 个应用",
|
||||
"Window tray": "活动窗口",
|
||||
"window-tray": "活动窗口",
|
||||
"No audio output available": "暂无可用的音频输出",
|
||||
"Power mode": "电源模式",
|
||||
"Power saver": "节能",
|
||||
"Balanced": "均衡",
|
||||
"Performance": "性能",
|
||||
"Connected to power": "已连接电源",
|
||||
"Running on battery power": "正在使用电池",
|
||||
"Battery fully charged": "电池已充满",
|
||||
"Battery status reported by the system": "电池状态由系统提供",
|
||||
"Power off this computer?": "关闭这台电脑?",
|
||||
"Restart this computer?": "重启这台电脑?",
|
||||
"Suspend this computer?": "挂起这台电脑?",
|
||||
"Log out of Ferese?": "退出 Ferese 会话?",
|
||||
"Your apps will stay open. The session will not be locked.": "应用将保持打开,会话不会锁定。",
|
||||
"Save your work. Your apps will close when you log out.": "请保存工作。退出会话时应用将关闭。",
|
||||
"Save your work before continuing.": "请先保存工作。",
|
||||
"{action} anyway": "仍然{action}",
|
||||
"Power off": "关机",
|
||||
"Default output": "默认输出"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Editable Japanese holiday calculations and user-authored calendar dates.
|
||||
pub use crate::calendar_rules::{DEFAULT_JAPANESE_HOLIDAY_FUNCTION, japanese_holidays};
|
||||
use jiff::civil::Date;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use std::{collections::BTreeMap, sync::OnceLock};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub show_adjacent_days: bool,
|
||||
pub custom_holidays: Vec<Holiday>,
|
||||
pub japanese_holiday_function: String,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_adjacent_days: true,
|
||||
custom_holidays: Vec::new(),
|
||||
japanese_holiday_function: DEFAULT_JAPANESE_HOLIDAY_FUNCTION.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Holiday {
|
||||
/// YYYY-MM-DD for one date, or MM-DD for an annual holiday.
|
||||
pub date: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Holiday {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let valid = match self.date.len() {
|
||||
10 => self.date.parse::<Date>().is_ok(),
|
||||
5 => format!("2000-{}", self.date).parse::<Date>().is_ok(),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err("Holiday date must be YYYY-MM-DD or MM-DD.".into());
|
||||
}
|
||||
if self.name.trim().is_empty() || self.name.chars().count() > 80 {
|
||||
return Err("Holiday name must contain 1–80 characters.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn matches(&self, date: Date) -> bool {
|
||||
self.date == date.to_string()
|
||||
|| self.date == format!("{:02}-{:02}", date.month(), date.day())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for holiday in &self.custom_holidays {
|
||||
holiday.validate()?;
|
||||
}
|
||||
validate_japanese_holiday_function(&self.japanese_holiday_function)
|
||||
}
|
||||
/// Custom names are combined with official names, preserving both on collisions.
|
||||
#[must_use]
|
||||
pub fn holiday_name(&self, date: Date) -> Option<String> {
|
||||
let mut names: Vec<String> =
|
||||
japanese_holidays(&self.japanese_holiday_function, date.year())
|
||||
.ok()
|
||||
.and_then(|days| days.get(&date).cloned())
|
||||
.into_iter()
|
||||
.collect();
|
||||
for holiday in &self.custom_holidays {
|
||||
if holiday.matches(date) && !names.contains(&holiday.name) {
|
||||
names.push(holiday.name.clone());
|
||||
}
|
||||
}
|
||||
(!names.is_empty()).then(|| names.join(" · "))
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
fn official_japanese_holiday(date: Date) -> Option<&'static str> {
|
||||
static HOLIDAYS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
|
||||
HOLIDAYS
|
||||
.get_or_init(|| {
|
||||
include_str!("../data/jp-holidays.csv")
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once(','))
|
||||
.collect()
|
||||
})
|
||||
.get(date.to_string().as_str())
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn validate_japanese_holiday_function(source: &str) -> Result<(), String> {
|
||||
crate::calendar_rules::validate(source)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn custom_dates_round_trip_through_editable_kdl_records() {
|
||||
let mut doc =
|
||||
crate::Document::parse("status { calendar { show-adjacent-days #false; }; }\n")
|
||||
.unwrap();
|
||||
doc.add(
|
||||
"status.calendar.custom_holidays",
|
||||
vec![
|
||||
("date".into(), "12-24".into()),
|
||||
("name".into(), "个人假日".into()),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let source = doc.to_string();
|
||||
let mut doc = crate::Document::parse(&source).unwrap();
|
||||
let status: crate::status::Settings =
|
||||
serde_json::from_value(doc.get("status").unwrap().clone()).unwrap();
|
||||
status.validate().unwrap();
|
||||
assert!(!status.calendar.show_adjacent_days);
|
||||
assert_eq!(
|
||||
status
|
||||
.calendar
|
||||
.holiday_name("2027-12-24".parse().unwrap())
|
||||
.as_deref(),
|
||||
Some("个人假日")
|
||||
);
|
||||
doc.set(
|
||||
"status.calendar.custom_holidays.0.date",
|
||||
"2026-02-30".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let status: crate::status::Settings =
|
||||
serde_json::from_value(doc.get("status").unwrap().clone()).unwrap();
|
||||
assert!(status.validate().is_err());
|
||||
doc.remove("status.calendar.custom_holidays", 0).unwrap();
|
||||
assert!(
|
||||
doc.get("status.calendar.custom_holidays")
|
||||
.is_none_or(|v| v.as_array().is_some_and(|v| v.is_empty()))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn official_dates_include_substitutes_bridges_and_olympic_exceptions() {
|
||||
for (date, expected) in [
|
||||
("2026-05-06", Some("休日")),
|
||||
("2026-09-22", Some("休日")),
|
||||
("2027-03-22", Some("休日")),
|
||||
("2021-07-22", Some("海の日")),
|
||||
("2021-07-19", None),
|
||||
("2026-10-28", None),
|
||||
("2028-01-01", None),
|
||||
] {
|
||||
assert_eq!(official_japanese_holiday(date.parse().unwrap()), expected);
|
||||
}
|
||||
let dates: Vec<_> = include_str!("../data/jp-holidays.csv")
|
||||
.lines()
|
||||
.map(|s| s.split_once(',').unwrap().0)
|
||||
.collect();
|
||||
assert!(dates.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert!(dates.iter().all(|date| date.parse::<Date>().is_ok()));
|
||||
assert_eq!(dates.iter().filter(|d| d.starts_with("2026-")).count(), 18);
|
||||
assert_eq!(dates.iter().filter(|d| d.starts_with("2027-")).count(), 17);
|
||||
}
|
||||
#[test]
|
||||
fn custom_holidays_validate_and_repeat_without_inventing_leap_days() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.show_adjacent_days);
|
||||
settings.custom_holidays = vec![
|
||||
Holiday {
|
||||
date: "02-29".into(),
|
||||
name: "Leap day".into(),
|
||||
},
|
||||
Holiday {
|
||||
date: "2026-11-03".into(),
|
||||
name: "My holiday".into(),
|
||||
},
|
||||
];
|
||||
assert!(settings.validate().is_ok());
|
||||
assert_eq!(
|
||||
settings
|
||||
.holiday_name("2024-02-29".parse().unwrap())
|
||||
.as_deref(),
|
||||
Some("Leap day")
|
||||
);
|
||||
assert_eq!(
|
||||
settings
|
||||
.holiday_name("2026-11-03".parse().unwrap())
|
||||
.as_deref(),
|
||||
Some("文化の日 · My holiday")
|
||||
);
|
||||
for date in ["2026-02-29", "13-01", "2026-1-1", "02-30"] {
|
||||
assert!(
|
||||
Holiday {
|
||||
date: date.into(),
|
||||
name: "Test".into()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use jiff::civil::Date;
|
||||
use rhai::{AST, Engine, EvalAltResult, INT, ImmutableString, Scope};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
};
|
||||
|
||||
pub const DEFAULT_JAPANESE_HOLIDAY_FUNCTION: &str = include_str!("../data/japanese-holidays.rhai");
|
||||
pub type Holidays = Arc<BTreeMap<Date, String>>;
|
||||
|
||||
fn evaluation_error(error: &EvalAltResult) -> String {
|
||||
match error.unwrap_inner() {
|
||||
EvalAltResult::ErrorTooManyOperations(_) => {
|
||||
"Holiday function exceeded its operation limit.".into()
|
||||
}
|
||||
EvalAltResult::ErrorStackOverflow(_) => {
|
||||
"Holiday function exceeded its call depth limit.".into()
|
||||
}
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn script_date(value: &str) -> Result<Date, Box<EvalAltResult>> {
|
||||
value
|
||||
.parse::<Date>()
|
||||
.map_err(|_| format!("Invalid calendar date: {value}").into())
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
let mut engine = Engine::new();
|
||||
engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
|
||||
for symbol in ["import", "export", "eval", "print", "debug"] {
|
||||
engine.disable_symbol(symbol);
|
||||
}
|
||||
engine
|
||||
.set_max_operations(100_000)
|
||||
.set_max_call_levels(16)
|
||||
.set_max_expr_depths(32, 32)
|
||||
.set_max_variables(64)
|
||||
.set_max_functions(16)
|
||||
.set_max_string_size(1024)
|
||||
.set_max_array_size(400)
|
||||
.set_max_map_size(400);
|
||||
engine.on_print(|_| {}).on_debug(|_, _, _| {});
|
||||
engine.register_fn(
|
||||
"date",
|
||||
|year: INT, month: INT, day: INT| -> Result<String, Box<EvalAltResult>> {
|
||||
script_date(&format!("{year:04}-{month:02}-{day:02}")).map(|d| d.to_string())
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"weekday",
|
||||
|date: ImmutableString| -> Result<INT, Box<EvalAltResult>> {
|
||||
script_date(&date).map(|d| INT::from(d.weekday().since(jiff::civil::Weekday::Sunday)))
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"add_days",
|
||||
|date: ImmutableString, days: INT| -> Result<String, Box<EvalAltResult>> {
|
||||
if !(-3660..=3660).contains(&days) {
|
||||
return Err("Calendar date offset must be within ten years.".into());
|
||||
}
|
||||
script_date(&date)?
|
||||
.checked_add(jiff::Span::new().days(days))
|
||||
.map(|d| d.to_string())
|
||||
.map_err(|error| error.to_string().into())
|
||||
},
|
||||
);
|
||||
engine.register_fn("days_in_year", |year: INT| -> INT {
|
||||
if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
|
||||
366
|
||||
} else {
|
||||
365
|
||||
}
|
||||
});
|
||||
// Converting float -> integer is explicit, keeping the editable equinox formula readable.
|
||||
engine.register_fn("floor", |value: rhai::FLOAT| -> INT {
|
||||
value.floor() as INT
|
||||
});
|
||||
engine
|
||||
}
|
||||
|
||||
struct Compiled {
|
||||
source: String,
|
||||
engine: Engine,
|
||||
ast: Result<AST, String>,
|
||||
years: BTreeMap<i16, Result<Holidays, String>>,
|
||||
}
|
||||
|
||||
/// Bounded, offline evaluation, compiled once per source and cached per visible year.
|
||||
/// Scripts receive only civil-date helpers; imports, eval and host I/O are unavailable.
|
||||
pub fn japanese_holidays(source: &str, year: i16) -> Result<Holidays, String> {
|
||||
if source.len() > 16_384 {
|
||||
return Err("Holiday function exceeds 16 KiB.".into());
|
||||
}
|
||||
static CACHE: OnceLock<Mutex<Option<Compiled>>> = OnceLock::new();
|
||||
let mut cache = CACHE
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.map_err(|_| "Holiday function cache is unavailable.")?;
|
||||
if cache.as_ref().is_none_or(|cached| cached.source != source) {
|
||||
let engine = engine();
|
||||
let ast = engine
|
||||
.compile(source)
|
||||
.map_err(|error| format!("Holiday function: {error}"));
|
||||
*cache = Some(Compiled {
|
||||
source: source.into(),
|
||||
engine,
|
||||
ast,
|
||||
years: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
let cache = cache.as_mut().unwrap();
|
||||
if let Some(result) = cache.years.get(&year) {
|
||||
return result.clone();
|
||||
}
|
||||
let result = (|| {
|
||||
let ast = cache.ast.as_ref().map_err(Clone::clone)?;
|
||||
let result = cache
|
||||
.engine
|
||||
.call_fn::<rhai::Map>(
|
||||
&mut Scope::new(),
|
||||
ast,
|
||||
"japanese_holidays",
|
||||
(INT::from(year),),
|
||||
)
|
||||
.map_err(|error| evaluation_error(&error))?;
|
||||
let mut holidays = BTreeMap::new();
|
||||
for (date, name) in result {
|
||||
let date = date
|
||||
.parse::<Date>()
|
||||
.map_err(|_| "Holiday function must return YYYY-MM-DD keys.")?;
|
||||
if date.year() != year {
|
||||
return Err("Holiday function returned a date outside the requested year.".into());
|
||||
}
|
||||
let name = name
|
||||
.try_cast::<ImmutableString>()
|
||||
.ok_or("Holiday function must return text names.")?;
|
||||
if name.trim().is_empty() || name.chars().count() > 80 {
|
||||
return Err("Holiday name must contain 1–80 characters.".into());
|
||||
}
|
||||
holidays.insert(date, name.to_string());
|
||||
}
|
||||
Ok(Arc::new(holidays))
|
||||
})();
|
||||
if cache.years.len() >= 8 {
|
||||
cache.years.clear();
|
||||
}
|
||||
cache.years.insert(year, result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
pub fn validate(source: &str) -> Result<(), String> {
|
||||
let year = jiff::Zoned::now().year();
|
||||
for year in [year - 1, year, year + 1] {
|
||||
japanese_holidays(source, year)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn builtin_function_matches_every_official_date_and_name_through_2027() {
|
||||
let mut official: BTreeMap<i16, BTreeMap<Date, String>> = BTreeMap::new();
|
||||
for line in include_str!("../data/jp-holidays.csv").lines() {
|
||||
let (date, name) = line.split_once(',').unwrap();
|
||||
let date = date.parse::<Date>().unwrap();
|
||||
official
|
||||
.entry(date.year())
|
||||
.or_default()
|
||||
.insert(date, name.into());
|
||||
}
|
||||
for (year, expected) in official {
|
||||
let actual = japanese_holidays(DEFAULT_JAPANESE_HOLIDAY_FUNCTION, year).unwrap();
|
||||
assert_eq!(*actual, expected, "official reference mismatch in {year}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn edited_function_changes_dates_and_invalid_or_unbounded_code_is_rejected() {
|
||||
let source =
|
||||
r#"fn japanese_holidays(year) { let h=#{}; h[date(year,10,28)]="测试假日"; h }"#;
|
||||
let result = japanese_holidays(source, 2026).unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.get(&"2026-10-28".parse::<Date>().unwrap())
|
||||
.map(String::as_str),
|
||||
Some("测试假日")
|
||||
);
|
||||
for source in [
|
||||
"fn japanese_holidays(year) { loop {} }",
|
||||
"import \"/etc/passwd\" as x; fn japanese_holidays(year) { #{} }",
|
||||
"fn japanese_holidays(year) { eval(\"1+1\"); #{} }",
|
||||
"fn japanese_holidays(year) { #{invalid: 3} }",
|
||||
"fn japanese_holidays(year) { #{\"2026-02-30\":\"Bad date\"} }",
|
||||
"fn japanese_holidays(year) { #{\"2025-01-01\":\"Wrong year\"} }",
|
||||
] {
|
||||
assert!(
|
||||
japanese_holidays(source, 2026).is_err(),
|
||||
"accepted invalid source {source}"
|
||||
);
|
||||
}
|
||||
assert!(japanese_holidays(&" ".repeat(16_385), 2026).is_err());
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ fn is_records(key: &str, parent: &str) -> bool {
|
||||
| ("start" | "center" | "end", "groups")
|
||||
| ("groups", "items")
|
||||
| ("activities", "rules")
|
||||
| ("calendar", "custom_holidays")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,6 +213,7 @@ pub(crate) fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Valu
|
||||
}
|
||||
let positional: &[&str] = match key {
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"custom_holidays" => &["date", "name"],
|
||||
"binding_modes" => &["name"],
|
||||
"output_profiles" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
@@ -918,6 +920,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
|
||||
if rest.len() == 1 {
|
||||
let positions: &[&str] = match key {
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"custom_holidays" => &["date", "name"],
|
||||
"binding_modes" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
"output_profiles" => &["name"],
|
||||
@@ -1035,6 +1038,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
|
||||
let positions: &[&str] = match key {
|
||||
"rules" if parent == "activities" => &["name"],
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"custom_holidays" => &["date", "name"],
|
||||
"binding_modes" => &["name"],
|
||||
"output_profiles" => &["name"],
|
||||
"outputs" => &["match"],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! KDL documents, validation and persisted configuration values.
|
||||
pub mod activities;
|
||||
pub mod calendar;
|
||||
mod calendar_rules;
|
||||
mod document;
|
||||
pub mod families;
|
||||
pub mod file;
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct Settings {
|
||||
pub low_battery_threshold: u8,
|
||||
pub settings_command: Option<Vec<String>>,
|
||||
pub tray: Tray,
|
||||
pub calendar: crate::calendar::Settings,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
@@ -15,11 +16,13 @@ impl Default for Settings {
|
||||
low_battery_threshold: 20,
|
||||
settings_command: Some(vec!["ferese-settings".into()]),
|
||||
tray: Tray::default(),
|
||||
calendar: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.calendar.validate()?;
|
||||
if self.low_battery_threshold > 100 {
|
||||
return Err("low-battery-threshold must be between 0 and 100".into());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user