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
@@ -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