Consolidate desktop controls with editable holidays and a running-window tray

This commit is contained in:
aria committed 2026-10-28 06:02:46 +09:00
1 parent 9908dde9f8
commit 326465952a
55 files changed
+4207 -450

No files matched your search

+202
View File
@@ -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()
);
}
}
}
+206
View File
@@ -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());
}
}
+4
View File
@@ -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"],
+2
View File
@@ -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;
+3
View 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());
}