diff --git a/package-lock.json b/package-lock.json index 92dc429f..155f25b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index b9e282d1..53e8e37b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c20a3f0..bd5de3f8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.152.0-alpha" +version = "3.152.1-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ce25a851..b2dbebca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.152.0-alpha" +version = "3.152.1-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/src/calendar.rs b/src-tauri/src/calendar.rs index db8a43b4..d0018d73 100644 --- a/src-tauri/src/calendar.rs +++ b/src-tauri/src/calendar.rs @@ -1003,6 +1003,17 @@ fn google_conference(it: &serde_json::Value) -> Option { .map(str::to_string) } +/// Whether a Google event resource is a repeating event's master: it carries a rule and no +/// `recurringEventId`. An empty or null `recurrence` is no rule (C11 critique 1 L10), so an event +/// Google echoes with `[]` after its repeat was taken off is a one-off again, here and in every +/// check that asks this. +pub(crate) fn is_master(it: &serde_json::Value) -> bool { + it.get("recurrence") + .and_then(|r| r.as_array()) + .is_some_and(|lines| !lines.is_empty()) + && it.get("recurringEventId").is_none() +} + fn parse_events(calendar_id: &str, value: &serde_json::Value) -> Vec { value .get("items") @@ -1022,14 +1033,14 @@ fn parse_events(calendar_id: &str, value: &serde_json::Value) -> Vec_`). /// -/// A master (it carries the `recurrence` rule and no `recurringEventId`) never becomes a row: the sync -/// asks for `singleEvents=true` and so only ever mirrors occurrences, and a master's `start` is just -/// its first occurrence's. A save that touches a series refetches the occurrences instead. +/// A master ([`is_master`]) never becomes a row: the sync asks for `singleEvents=true` and so only +/// ever mirrors occurrences, and a master's `start` is just its first occurrence's. A save that +/// touches a series refetches the occurrences instead. pub(crate) fn parse_event(calendar_id: &str, it: &serde_json::Value) -> Option { if it.get("status").and_then(|s| s.as_str()) == Some("cancelled") { return None; } - if it.get("recurrence").is_some() && it.get("recurringEventId").is_none() { + if is_master(it) { return None; } let event_id = it @@ -3327,8 +3338,16 @@ mod tests { "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"] }); assert!(parse_event("cal-1", &master).is_none()); + assert!(is_master(&master)); // An occurrence of it is mirrored as usual. assert!(parse_event("cal-1", &google_occurrence()).is_some()); + // C11 critique 1 L10: an empty or null rule is no rule, so that event is a one-off row. + for none in [serde_json::json!([]), serde_json::Value::Null] { + let mut once = master.clone(); + once["recurrence"] = none; + assert!(!is_master(&once)); + assert!(parse_event("cal-1", &once).is_some_and(|row| row.summary == "Standup")); + } } #[test] diff --git a/src-tauri/src/calendar_recur/expand.rs b/src-tauri/src/calendar_recur/expand.rs index 0c908f19..4fff8455 100644 --- a/src-tauri/src/calendar_recur/expand.rs +++ b/src-tauri/src/calendar_recur/expand.rs @@ -15,11 +15,11 @@ use std::cmp::Ordering; -use chrono::{DateTime, Month, NaiveDate, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Month, NaiveDate, NaiveTime, TimeZone, Utc}; use rrule::{Frequency, NWeekday, RRule, RRuleSet}; use super::lines::{self, Freq, RuleFacts, Stamp}; -use super::spec::{rule_from_spec, RecurrenceSpec, SpecError}; +use super::spec::{days_in_month, rule_from_spec, MonthlyOn, Pattern, RecurrenceSpec, SpecError}; use super::{end_of_day, first_instant, rfc_instant, Point, RecurError, SeriesStart}; /// How many starts a count walks past before giving up: far more than the 730 Google allows. @@ -295,13 +295,19 @@ pub fn last_before( /// The day comes from the rule run by days, in UTC, where none is ever skipped: in a zone, the crate /// moves an occurrence whose time the clocks skip, and drops one they skip at midnight. The time is /// the one chosen; if the clocks skip it on that day, it's refused, as any new start in a gap is. -#[cfg_attr( - not(test), - expect(dead_code, reason = "first caller lands in C11, repeat rules") -)] +/// +/// The crate is asked for one occurrence only. In the editor's set, only "on day N" of every few +/// months can have none at all (the 31st of every twelfth April), which the crate would look for to +/// its 100,000-step guard; [`lands`] refuses that first, so this is quick for every rule the +/// editor can send (C11 critique 1 L5). pub fn sync_start(spec: &RecurrenceSpec, start: &SeriesStart) -> Result { let by_day = SeriesStart::AllDay(start.date()); let rule = rule_from_spec(spec, &by_day)?; + if let Pattern::Monthly(MonthlyOn::Day(day)) = spec.pattern { + if !lands(day, spec.interval, start.date()) { + return Err(SpecError::NoOccurrence); + } + } let lines = lines::parse(&[rule]); let (_, facts) = lines::series_rule(&lines).map_err(|_| SpecError::NoOccurrence)?; let day = crate_set(&facts, &by_day) @@ -318,10 +324,26 @@ pub fn sync_start(spec: &RecurrenceSpec, start: &SeriesStart) -> Result bool { + let (day, step) = (u32::from(day), i64::from(interval)); + let first = i64::from(from.year()) * 12 + i64::from(from.month0()); + (0..4_800_i64).any(|k| { + let month = first + k * step; + let month_start = i32::try_from(month.div_euclid(12)).ok().and_then(|year| { + NaiveDate::from_ymd_opt(year, u32::try_from(month.rem_euclid(12)).ok()? + 1, 1) + }); + month_start.is_some_and(|m| days_in_month(m) >= day && (k > 0 || day >= from.day())) + }) +} + #[cfg(test)] mod tests { use super::*; - use crate::calendar_recur::spec::{Ends, Pattern}; + use crate::calendar_recur::spec::Ends; use chrono::{NaiveDate, Weekday}; use chrono_tz::Europe::London; @@ -815,4 +837,45 @@ mod tests { Err(SpecError::NoWeekdays) ); } + + /// "On day N" of every few months, where no month it visits has that day, is refused before the + /// crate is asked (it would look 100,000 months for it); one that lands at last is found. + #[test] + fn a_day_of_the_month_no_visited_month_has_is_refused_at_once() { + let monthly = |day: u8, interval: u16| RecurrenceSpec { + pattern: Pattern::Monthly(MonthlyOn::Day(day)), + interval, + ends: Ends::Never, + }; + // The 31st of every twelfth month, from April: April never has one. + assert!(!lands(31, 12, day(2026, 4, 13))); + assert_eq!( + sync_start(&monthly(31, 12), &london(2026, 4, 13, 9)), + Err(SpecError::NoOccurrence) + ); + // Every sixth month from June: June and December, and December has one. + assert!(lands(31, 6, day(2026, 6, 1))); + // Every second month from April: April and June haven't, August has. + assert!(lands(31, 2, day(2026, 4, 13))); + assert_eq!( + sync_start(&monthly(31, 2), &london(2026, 4, 13, 9)), + Ok(london(2026, 8, 31, 9)) + ); + // The 29th of every second February from 2027: odd years, never a leap one. + assert!(!lands(29, 24, day(2027, 2, 1))); + // From 2025 every year: 2028 has one. + assert!(lands(29, 12, day(2025, 2, 1))); + assert_eq!( + sync_start(&monthly(29, 12), &london(2025, 2, 1, 9)), + Ok(london(2028, 2, 29, 9)) + ); + // Every fourth February from 2100, which skips its leap day: 2104 has one. + assert!(lands(29, 48, day(2100, 2, 1))); + // A start's own month counts from its own day on: the 13th, from the 20th, is next month's. + assert!(lands(13, 1, day(2026, 10, 20))); + assert_eq!( + sync_start(&monthly(13, 1), &london(2026, 10, 20, 9)), + Ok(london(2026, 11, 13, 9)) + ); + } } diff --git a/src-tauri/src/calendar_recur/mod.rs b/src-tauri/src/calendar_recur/mod.rs index f2f6abe3..53293db1 100644 --- a/src-tauri/src/calendar_recur/mod.rs +++ b/src-tauri/src/calendar_recur/mod.rs @@ -14,6 +14,8 @@ //! - [`reanchor`]: what a rule becomes when one of its occurrences moves to another day. //! - [`remap`]: moving a whole series by one occurrence, its start, end and EXDATEs with it, proved //! occurrence by occurrence. +//! - [`wire`]: a rule from the editor's set as the webview sends and shows it, and a new series' +//! start read from the editor's fields. //! //! Three traps in rrule 0.14 shape this module (each read in the crate's source): //! - Its `Display` adds BYHOUR, BYMINUTE and BYSECOND to every rule, so a rule it prints pins the @@ -29,6 +31,7 @@ pub mod reanchor; pub mod remap; pub mod spec; pub mod split; +pub mod wire; use std::cmp::Ordering; use std::fmt; diff --git a/src-tauri/src/calendar_recur/spec.rs b/src-tauri/src/calendar_recur/spec.rs index 771d36a4..838247ba 100644 --- a/src-tauri/src/calendar_recur/spec.rs +++ b/src-tauri/src/calendar_recur/spec.rs @@ -137,8 +137,14 @@ pub enum SpecError { NoOccurrence, /// The clocks skip the chosen time on the series' first day. Gap(NaiveDate), + /// The clocks skip the first occurrence's end time on the day it ends (C11 critique 2 L7). + EndGap(NaiveDate), + /// An end date PM can't read. + BadDate, } +/// Shown on the editor's Repeat row, so no "series", "instance", "exception", "split" or "master" +/// (C9b-1 critique 2 M4): the user made a repeating event and its occurrences. impl fmt::Display for SpecError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -149,19 +155,28 @@ impl fmt::Display for SpecError { SpecError::Count => write!(f, "It has to happen at least once."), SpecError::TooMany => write!( f, - "Google allows at most {MAX_OCCURRENCES} occurrences in a series." + "Google allows at most {MAX_OCCURRENCES} occurrences of a repeating event." ), - SpecError::EndsBeforeStart => write!(f, "The end date is before the series starts."), + SpecError::EndsBeforeStart => { + write!(f, "The end date is before the first occurrence.") + } SpecError::NoOccurrence => write!( f, - "That rule has no occurrence between the start and the end date." + "This repeat has no occurrence between the start and the end date." ), SpecError::Gap(day) => write!( f, - "The series would start on {}, when the clocks skip that time. Pick a time outside \ - that hour.", + "The first occurrence would be on {}, when the clocks skip that time. Pick a time \ + outside that hour.", + day.format("%d-%m-%Y") + ), + SpecError::EndGap(day) => write!( + f, + "The first occurrence would end on {}, when the clocks skip that time. Pick an end \ + time outside that hour.", day.format("%d-%m-%Y") ), + SpecError::BadDate => write!(f, "PM couldn't read that end date."), } } } @@ -247,13 +262,6 @@ pub fn spec_from_rule(f: &RuleFacts, start: &SeriesStart) -> Option Result { if !(1..=MAX_INTERVAL).contains(&spec.interval) { return Err(SpecError::Interval); @@ -465,10 +473,6 @@ pub fn day_phrase>(lines: &[S], start: &SeriesStart) -> Option Vec { let weekday = day.weekday(); let mut patterns = vec![Pattern::Daily, Pattern::Weekly(vec![weekday])]; @@ -618,6 +618,56 @@ fn month_name(month: u32) -> &'static str { .unwrap_or("") } +/// T2: the rules Google's editor writes and the words it says them in, against Tue 13-10-2026 +/// 09:00 in London. The webview's repeat-rule tests read the same rows from +/// `src/lib/calendarEdit/repeatRules.fixture.json`, which `wire`'s tests hold to this list. +#[cfg(test)] +pub(super) const T2_RULES: [(&str, &str); 16] = [ + ("RRULE:FREQ=DAILY", "Daily"), + ( + "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", + "Every 3 days, 10 times", + ), + ("RRULE:FREQ=WEEKLY", "Weekly on Tuesday"), + ("RRULE:FREQ=WEEKLY;BYDAY=TU", "Weekly on Tuesday"), + ( + "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "Every weekday (Monday to Friday)", + ), + ( + "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", + "Every 2 weeks on Monday and Wednesday, until 31-12-2026", + ), + ( + "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", + "Weekly on Monday, Wednesday and Friday", + ), + ("RRULE:FREQ=MONTHLY", "Monthly on day 13"), + ("RRULE:FREQ=MONTHLY;BYMONTHDAY=13", "Monthly on day 13"), + ( + "RRULE:FREQ=MONTHLY;BYDAY=2TU", + "Monthly on the second Tuesday", + ), + ( + "RRULE:FREQ=MONTHLY;BYDAY=-1TU", + "Monthly on the last Tuesday", + ), + ("RRULE:FREQ=YEARLY", "Annually on 13 October"), + ( + "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", + "Annually on 13 October", + ), + ( + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", + "Annually on the second Tuesday of October", + ), + ("RRULE:FREQ=DAILY;COUNT=1", "Daily, once"), + ( + "RRULE:FREQ=YEARLY;INTERVAL=2", + "Every 2 years on 13 October", + ), +]; + #[cfg(test)] mod tests { use super::*; @@ -656,52 +706,55 @@ mod tests { #[test] fn rules_read_as_google_says_them() { let t = tuesday(); - for (rule, expected) in [ - ("RRULE:FREQ=DAILY", "Daily"), - ( - "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", - "Every 3 days, 10 times", - ), - ("RRULE:FREQ=WEEKLY", "Weekly on Tuesday"), - ("RRULE:FREQ=WEEKLY;BYDAY=TU", "Weekly on Tuesday"), - ( - "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", - "Every weekday (Monday to Friday)", - ), - ( - "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", - "Every 2 weeks on Monday and Wednesday, until 31-12-2026", - ), + for (rule, expected) in T2_RULES { + assert_eq!(words(rule, &t), expected, "{rule}"); + } + } + + /// Every word a rule can be refused with is one the editor shows as is (C11 §1.3), pinned here + /// as the webview's problem words are pinned there, so the two can't drift. + #[test] + fn spec_error_words_name_no_jargon() { + let skipped = day(2026, 3, 29); + for (e, expected) in [ + (SpecError::Interval, "Repeat every 1 to 99."), + (SpecError::NoWeekdays, "Pick at least one day of the week."), ( - "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", - "Weekly on Monday, Wednesday and Friday", + SpecError::DayOfMonth, + "Pick a day of the month from 1 to 31.", ), - ("RRULE:FREQ=MONTHLY", "Monthly on day 13"), - ("RRULE:FREQ=MONTHLY;BYMONTHDAY=13", "Monthly on day 13"), + (SpecError::Month, "Pick a month."), + (SpecError::Count, "It has to happen at least once."), ( - "RRULE:FREQ=MONTHLY;BYDAY=2TU", - "Monthly on the second Tuesday", + SpecError::TooMany, + "Google allows at most 730 occurrences of a repeating event.", ), ( - "RRULE:FREQ=MONTHLY;BYDAY=-1TU", - "Monthly on the last Tuesday", + SpecError::EndsBeforeStart, + "The end date is before the first occurrence.", ), - ("RRULE:FREQ=YEARLY", "Annually on 13 October"), ( - "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", - "Annually on 13 October", + SpecError::NoOccurrence, + "This repeat has no occurrence between the start and the end date.", ), ( - "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", - "Annually on the second Tuesday of October", + SpecError::Gap(skipped), + "The first occurrence would be on 29-03-2026, when the clocks skip that time. \ + Pick a time outside that hour.", ), - ("RRULE:FREQ=DAILY;COUNT=1", "Daily, once"), ( - "RRULE:FREQ=YEARLY;INTERVAL=2", - "Every 2 years on 13 October", + SpecError::EndGap(skipped), + "The first occurrence would end on 29-03-2026, when the clocks skip that time. \ + Pick an end time outside that hour.", ), + (SpecError::BadDate, "PM couldn't read that end date."), ] { - assert_eq!(words(rule, &t), expected, "{rule}"); + let words = e.to_string(); + assert_eq!(words, expected); + let lower = words.to_lowercase(); + for jargon in ["series", "instance", "exception", "split", "master"] { + assert!(!lower.contains(jargon), "{words}"); + } } } diff --git a/src-tauri/src/calendar_recur/wire.rs b/src-tauri/src/calendar_recur/wire.rs new file mode 100644 index 00000000..ca532928 --- /dev/null +++ b/src-tauri/src/calendar_recur/wire.rs @@ -0,0 +1,672 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! A repeat rule as the webview holds it (#884, C11): the editor's set ([`super::spec`]) with weekdays +//! and nths as words and dates as `YYYY-MM-DD`, since chrono has no serde feature here (A22). Read by +//! the repeat menu's commands and by the editor's drafts, so both mean one thing by a rule. +//! +//! Where a new series starts is read here too ([`series_start`]), with the one date, time and zone +//! parser the event save reads its times with, so the menu's words and the save can't read a start +//! differently. + +use std::collections::BTreeSet; +use std::fmt; + +use chrono::{NaiveDate, NaiveTime, TimeDelta, Weekday}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; + +use super::spec::{Ends, MonthlyOn, Nth, Pattern, RecurrenceSpec, SpecError, YearlyOn}; +use super::{first_instant, SeriesStart}; + +/// A repeat rule from the editor's set, as the webview sends and shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepeatRule { + pub pattern: RepeatPattern, + /// Every this many days, weeks, months or years: 1 to 99. + pub interval: u16, + pub ends: RepeatEnds, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatPattern { + Daily, + /// Written Monday first, each day once. + Weekly { + days: Vec, + }, + MonthlyDay { + day: u8, + }, + MonthlyWeekday { + nth: NthCode, + weekday: DayCode, + }, + /// On the first occurrence's own date. + YearlyDate, + YearlyWeekday { + month: u8, + nth: NthCode, + weekday: DayCode, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatEnds { + Never, + /// The last day an occurrence can start on, `YYYY-MM-DD`, in the event's zone. + OnDate { + date: String, + }, + After { + count: u16, + }, +} + +/// A day of the week. Ordered Monday first, so a set of them is written in PM's week order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DayCode { + Mon, + Tue, + Wed, + Thu, + Fri, + Sat, + Sun, +} + +/// Which of a month's weekdays: the first four or the last, never a fifth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NthCode { + First, + Second, + Third, + Fourth, + Last, +} + +/// Where a new repeating event starts, as the editor's fields hold it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatStart { + Timed { + date: String, + time: String, + zone: String, + /// Where the first occurrence ends, so the day the rule moves it to is checked for a skipped + /// hour at the end too (C11 critique 2 L7). Left out, only the start is checked. + #[serde(default)] + end: Option, + }, + AllDay { + date: String, + }, +} + +/// A wall-clock time as the editor's fields hold it: `YYYY-MM-DD`, `HH:MM` and an IANA zone. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct WallTime { + pub date: String, + pub time: String, + pub zone: String, +} + +impl WallTime { + /// The day `days` days after this one when the clocks skip this time on it, in this zone: where + /// a first occurrence the rule moved would end, as the save moves its end with its start. `None` + /// when that time happens, or when PM can't read it (the editor's own time check says so). + pub fn skipped_after(&self, days: i64) -> Option { + let day = parse_date(&self.date)?.checked_add_signed(TimeDelta::try_days(days)?)?; + let time = parse_time(&self.time)?; + let zone = parse_zone(&self.zone)?; + first_instant(zone, day.and_time(time)) + .is_none() + .then_some(day) + } +} + +/// One ready-made rule of the repeat menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RepeatPreset { + pub label: String, + pub rule: RepeatRule, +} + +/// A rule in words, and where it would start. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum RepeatView { + /// `starts`: the first occurrence's day, `YYYY-MM-DD`; `moved`: it isn't the day asked for. + Ready { + summary: String, + starts: String, + moved: bool, + }, + /// Why it can't be saved, in words (a [`SpecError`] or a start PM can't read). + Refused { message: String }, +} + +/// Why the editor's start can't be read. Shown as is, never echoing the text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartError { + Date, + Time, + Zone, +} + +impl fmt::Display for StartError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StartError::Date => write!(f, "PM couldn't read that date."), + StartError::Time => write!(f, "PM couldn't read that time."), + StartError::Zone => write!(f, "PM doesn't know that time zone."), + } + } +} + +impl RepeatRule { + /// The rule as one from the editor's set. Days are sorted Monday first and deduplicated; an empty + /// day list stays empty for `rule_from_spec` to refuse (`NoWeekdays`). An end date PM can't read + /// is [`SpecError::BadDate`]; every other limit is `rule_from_spec`'s to check. + pub fn to_spec(&self) -> Result { + let pattern = match &self.pattern { + RepeatPattern::Daily => Pattern::Daily, + RepeatPattern::Weekly { days } => { + let days: BTreeSet = days.iter().copied().collect(); + Pattern::Weekly(days.into_iter().map(Weekday::from).collect()) + } + RepeatPattern::MonthlyDay { day } => Pattern::Monthly(MonthlyOn::Day(*day)), + RepeatPattern::MonthlyWeekday { nth, weekday } => { + Pattern::Monthly(MonthlyOn::Weekday((*nth).into(), (*weekday).into())) + } + RepeatPattern::YearlyDate => Pattern::Yearly(YearlyOn::Date), + RepeatPattern::YearlyWeekday { + month, + nth, + weekday, + } => Pattern::Yearly(YearlyOn::Weekday { + month: *month, + nth: (*nth).into(), + weekday: (*weekday).into(), + }), + }; + let ends = match &self.ends { + RepeatEnds::Never => Ends::Never, + RepeatEnds::OnDate { date } => { + Ends::OnDate(parse_date(date).ok_or(SpecError::BadDate)?) + } + RepeatEnds::After { count } => Ends::After(*count), + }; + Ok(RecurrenceSpec { + pattern, + interval: self.interval, + ends, + }) + } + + pub fn from_spec(spec: &RecurrenceSpec) -> RepeatRule { + let pattern = match &spec.pattern { + Pattern::Daily => RepeatPattern::Daily, + Pattern::Weekly(days) => RepeatPattern::Weekly { + days: days.iter().map(|d| DayCode::from(*d)).collect(), + }, + Pattern::Monthly(MonthlyOn::Day(day)) => RepeatPattern::MonthlyDay { day: *day }, + Pattern::Monthly(MonthlyOn::Weekday(nth, weekday)) => RepeatPattern::MonthlyWeekday { + nth: (*nth).into(), + weekday: (*weekday).into(), + }, + Pattern::Yearly(YearlyOn::Date) => RepeatPattern::YearlyDate, + Pattern::Yearly(YearlyOn::Weekday { + month, + nth, + weekday, + }) => RepeatPattern::YearlyWeekday { + month: *month, + nth: (*nth).into(), + weekday: (*weekday).into(), + }, + }; + let ends = match spec.ends { + Ends::Never => RepeatEnds::Never, + Ends::OnDate(day) => RepeatEnds::OnDate { + date: day.format("%Y-%m-%d").to_string(), + }, + Ends::After(count) => RepeatEnds::After { count }, + }; + RepeatRule { + pattern, + interval: spec.interval, + ends, + } + } +} + +impl From for DayCode { + fn from(day: Weekday) -> DayCode { + match day { + Weekday::Mon => DayCode::Mon, + Weekday::Tue => DayCode::Tue, + Weekday::Wed => DayCode::Wed, + Weekday::Thu => DayCode::Thu, + Weekday::Fri => DayCode::Fri, + Weekday::Sat => DayCode::Sat, + Weekday::Sun => DayCode::Sun, + } + } +} + +impl From for Weekday { + fn from(day: DayCode) -> Weekday { + match day { + DayCode::Mon => Weekday::Mon, + DayCode::Tue => Weekday::Tue, + DayCode::Wed => Weekday::Wed, + DayCode::Thu => Weekday::Thu, + DayCode::Fri => Weekday::Fri, + DayCode::Sat => Weekday::Sat, + DayCode::Sun => Weekday::Sun, + } + } +} + +impl From for NthCode { + fn from(nth: Nth) -> NthCode { + match nth { + Nth::First => NthCode::First, + Nth::Second => NthCode::Second, + Nth::Third => NthCode::Third, + Nth::Fourth => NthCode::Fourth, + Nth::Last => NthCode::Last, + } + } +} + +impl From for Nth { + fn from(nth: NthCode) -> Nth { + match nth { + NthCode::First => Nth::First, + NthCode::Second => Nth::Second, + NthCode::Third => Nth::Third, + NthCode::Fourth => Nth::Fourth, + NthCode::Last => Nth::Last, + } + } +} + +/// A `YYYY-MM-DD` date, spaces around it allowed: how PM reads every date the editor sends. +pub fn parse_date(s: &str) -> Option { + NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok() +} + +/// An `HH:MM` time (or `HH:MM:SS`), spaces around it allowed: how PM reads every time the editor +/// sends. +pub fn parse_time(s: &str) -> Option { + let s = s.trim(); + NaiveTime::parse_from_str(s, "%H:%M") + .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S")) + .ok() +} + +/// An IANA zone name, spaces around it allowed. +pub fn parse_zone(s: &str) -> Option { + s.trim().parse::().ok() +} + +/// Where a series starts, from the editor's text: a date, time and zone for a timed start, the date +/// alone for an all-day one. A time the clocks skip isn't refused here: the rule may move the start +/// to another day, so `sync_start` checks the day it lands on. +pub fn series_start(start: &RepeatStart) -> Result { + match start { + RepeatStart::Timed { + date, time, zone, .. + } => { + let date = parse_date(date).ok_or(StartError::Date)?; + let time = parse_time(time).ok_or(StartError::Time)?; + let zone = parse_zone(zone).ok_or(StartError::Zone)?; + Ok(SeriesStart::Timed { + local: date.and_time(time), + zone, + }) + } + RepeatStart::AllDay { date } => Ok(SeriesStart::AllDay( + parse_date(date).ok_or(StartError::Date)?, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::calendar_recur::lines::{self, Kind}; + use crate::calendar_recur::spec::{describe, presets, spec_from_rule, T2_RULES}; + use serde_json::{json, Value}; + + /// The T2 rules, their words and their wire form, shared with the webview's tests. + const FIXTURE: &str = include_str!("../../../src/lib/calendarEdit/repeatRules.fixture.json"); + + fn fixture() -> (RepeatStart, Vec<(String, String, RepeatRule)>) { + let v: Value = serde_json::from_str(FIXTURE).unwrap(); + let start: RepeatStart = serde_json::from_value(v["start"].clone()).unwrap(); + let rows = v["rows"] + .as_array() + .unwrap() + .iter() + .map(|row| { + ( + row["rule"].as_str().unwrap().to_string(), + row["words"].as_str().unwrap().to_string(), + serde_json::from_value(row["spec"].clone()).unwrap(), + ) + }) + .collect(); + (start, rows) + } + + /// `rule` read as one from the editor's set, as a series' words read it. + fn read(rule: &str, start: &SeriesStart) -> Option { + let lines = lines::parse(&[rule]); + let Kind::Rule(rule) = &lines[0].kind else { + return None; + }; + spec_from_rule(&rule.facts().ok()?, start) + } + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + /// Every row of `rules_read_as_google_says_them`, in its order, with the wire form the webview + /// reads it as: the one file both languages test against (C11 §1.5). + #[test] + fn t2_rules_match_the_shared_fixture() { + let (start, rows) = fixture(); + let start = series_start(&start).unwrap(); + let named: Vec<(&str, &str)> = rows + .iter() + .map(|(rule, words, _)| (rule.as_str(), words.as_str())) + .collect(); + assert_eq!(named, T2_RULES, "the fixture holds every T2 row, in order"); + for (rule, words, wire) in &rows { + let spec = read(rule, &start).unwrap(); + assert_eq!(&RepeatRule::from_spec(&spec), wire, "{rule}"); + assert_eq!(&describe(&spec, &start), words, "{rule}"); + } + } + + #[test] + fn every_spec_round_trips_through_the_wire() { + let (_, rows) = fixture(); + for (rule, _, wire) in &rows { + let spec = wire.to_spec().unwrap(); + assert_eq!(&RepeatRule::from_spec(&spec), wire, "{rule}"); + assert_eq!(RepeatRule::from_spec(&spec).to_spec(), Ok(spec), "{rule}"); + } + for d in [ + day(2026, 10, 13), + day(2026, 10, 27), + day(2026, 10, 31), + day(2028, 2, 29), + ] { + for preset in presets(d) { + assert_eq!( + RepeatRule::from_spec(&preset.spec).to_spec(), + Ok(preset.spec.clone()), + "{}", + preset.label + ); + } + } + } + + /// The exact strings the webview's types name. + #[test] + fn the_wire_strings_are_the_ones_the_webview_names() { + let wire = |rule: &RepeatRule| serde_json::to_value(rule).unwrap(); + let cases = [ + ( + RepeatPattern::Weekly { + days: vec![DayCode::Mon, DayCode::Wed], + }, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + json!({"pattern":{"kind":"weekly","days":["mon","wed"]},"interval":2, + "ends":{"kind":"on_date","date":"2026-12-31"}}), + ), + ( + RepeatPattern::Daily, + RepeatEnds::Never, + json!({"pattern":{"kind":"daily"},"interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::MonthlyDay { day: 31 }, + RepeatEnds::After { count: 10 }, + json!({"pattern":{"kind":"monthly_day","day":31},"interval":2, + "ends":{"kind":"after","count":10}}), + ), + ( + RepeatPattern::MonthlyWeekday { + nth: NthCode::Last, + weekday: DayCode::Fri, + }, + RepeatEnds::Never, + json!({"pattern":{"kind":"monthly_weekday","nth":"last","weekday":"fri"}, + "interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::YearlyDate, + RepeatEnds::Never, + json!({"pattern":{"kind":"yearly_date"},"interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::YearlyWeekday { + month: 10, + nth: NthCode::Second, + weekday: DayCode::Tue, + }, + RepeatEnds::Never, + json!({"pattern":{"kind":"yearly_weekday","month":10,"nth":"second", + "weekday":"tue"},"interval":2,"ends":{"kind":"never"}}), + ), + ]; + for (pattern, ends, expected) in cases { + let rule = RepeatRule { + pattern, + interval: 2, + ends, + }; + assert_eq!(wire(&rule), expected); + assert_eq!( + serde_json::from_value::(expected).unwrap(), + rule + ); + } + for (start, expected) in [ + ( + json!({"kind":"timed","date":"2026-10-13","time":"09:00","zone":"Europe/London"}), + RepeatStart::Timed { + date: "2026-10-13".into(), + time: "09:00".into(), + zone: "Europe/London".into(), + end: None, + }, + ), + ( + json!({"kind":"timed","date":"2026-10-13","time":"09:00","zone":"Europe/London", + "end":{"date":"2026-10-13","time":"10:00","zone":"Europe/London"}}), + RepeatStart::Timed { + date: "2026-10-13".into(), + time: "09:00".into(), + zone: "Europe/London".into(), + end: Some(WallTime { + date: "2026-10-13".into(), + time: "10:00".into(), + zone: "Europe/London".into(), + }), + }, + ), + ( + json!({"kind":"all_day","date":"2026-10-13"}), + RepeatStart::AllDay { + date: "2026-10-13".into(), + }, + ), + ] { + assert_eq!( + serde_json::from_value::(start).unwrap(), + expected + ); + } + let views = [ + ( + RepeatView::Ready { + summary: "Daily".into(), + starts: "2026-10-14".into(), + moved: true, + }, + json!({"outcome":"ready","summary":"Daily","starts":"2026-10-14","moved":true}), + ), + ( + RepeatView::Refused { + message: "No.".into(), + }, + json!({"outcome":"refused","message":"No."}), + ), + ]; + for (view, expected) in views { + assert_eq!(serde_json::to_value(&view).unwrap(), expected); + } + // A kind, day or nth PM doesn't know is refused, never guessed. + for unknown in [ + json!({"pattern":{"kind":"hourly"},"interval":1,"ends":{"kind":"never"}}), + json!({"pattern":{"kind":"weekly","days":["monday"]},"interval":1,"ends":{"kind":"never"}}), + json!({"pattern":{"kind":"monthly_weekday","nth":"fifth","weekday":"fri"},"interval":1, + "ends":{"kind":"never"}}), + ] { + assert!(serde_json::from_value::(unknown).is_err()); + } + } + + #[test] + fn weekdays_are_written_monday_first_and_once() { + let rule = RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Wed, DayCode::Mon, DayCode::Wed], + }, + interval: 1, + ends: RepeatEnds::Never, + }; + assert_eq!( + rule.to_spec().unwrap().pattern, + Pattern::Weekly(vec![Weekday::Mon, Weekday::Wed]) + ); + // No day at all stays empty, for the rule's own check to refuse in words. + let none = RepeatRule { + pattern: RepeatPattern::Weekly { days: Vec::new() }, + ..rule + }; + assert_eq!(none.to_spec().unwrap().pattern, Pattern::Weekly(Vec::new())); + } + + #[test] + fn an_unreadable_end_date_is_refused() { + let ending = |date: &str| RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends: RepeatEnds::OnDate { date: date.into() }, + }; + assert_eq!(ending("31-12-2026").to_spec(), Err(SpecError::BadDate)); + assert_eq!(ending("").to_spec(), Err(SpecError::BadDate)); + assert_eq!( + ending("2026-12-31").to_spec().unwrap().ends, + Ends::OnDate(day(2026, 12, 31)) + ); + assert_eq!( + SpecError::BadDate.to_string(), + "PM couldn't read that end date." + ); + } + + #[test] + fn a_start_is_read_or_refused_in_words() { + let timed = |date: &str, time: &str, zone: &str| RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: zone.into(), + end: None, + }; + assert_eq!( + series_start(&timed(" 2026-10-13 ", "09:00:30", "Europe/London")), + Ok(SeriesStart::Timed { + local: day(2026, 10, 13).and_hms_opt(9, 0, 30).unwrap(), + zone: chrono_tz::Europe::London, + }) + ); + assert_eq!( + series_start(&RepeatStart::AllDay { + date: "2026-10-13".into() + }), + Ok(SeriesStart::AllDay(day(2026, 10, 13))) + ); + // A time the clocks skip is the rule's day's question, not the start's. + assert!(series_start(&timed("2026-03-29", "01:30", "Europe/London")).is_ok()); + for (start, why, words) in [ + ( + timed("13/10/2026", "09:00", "Europe/London"), + StartError::Date, + "PM couldn't read that date.", + ), + ( + timed("2026-10-13", "9am", "Europe/London"), + StartError::Time, + "PM couldn't read that time.", + ), + ( + timed("2026-10-13", "09:00", "Mars/Olympus"), + StartError::Zone, + "PM doesn't know that time zone.", + ), + ( + RepeatStart::AllDay { + date: "soon".into(), + }, + StartError::Date, + "PM couldn't read that date.", + ), + ] { + assert_eq!(series_start(&start), Err(why)); + assert_eq!(why.to_string(), words); + } + } + + /// London skips 01:00-02:00 on 29-03-2026: an end at 01:30 the day before is found there once + /// the rule moves it a day on, and not before; an end PM can't read is the editor's to say. + #[test] + fn an_end_the_clocks_skip_is_found_where_the_rule_moves_it() { + let end = |date: &str, time: &str, zone: &str| WallTime { + date: date.into(), + time: time.into(), + zone: zone.into(), + }; + let night = end("2026-03-28", "01:30", "Europe/London"); + assert_eq!(night.skipped_after(1), Some(day(2026, 3, 29))); + assert_eq!(night.skipped_after(0), None); + assert_eq!(night.skipped_after(2), None); + // The same night in New York is an ordinary one. + assert_eq!( + end("2026-03-28", "01:30", "America/New_York").skipped_after(1), + None + ); + for unread in [ + end("28-03-2026", "01:30", "Europe/London"), + end("2026-03-28", "late", "Europe/London"), + end("2026-03-28", "01:30", "Mars/Olympus"), + ] { + assert_eq!(unread.skipped_after(1), None); + } + } +} diff --git a/src-tauri/src/calendar_write/dto.rs b/src-tauri/src/calendar_write/dto.rs index a76e0462..4f461543 100644 --- a/src-tauri/src/calendar_write/dto.rs +++ b/src-tauri/src/calendar_write/dto.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; +use crate::calendar_recur::wire::RepeatRule; + /// Who Google emails about a change (`sendUpdates`). Every write states it; PM never relies on /// Google's default. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -102,8 +104,8 @@ pub struct EventPatchDraft { } /// A new event as the editor holds it (A22: defined with its first caller, the create command). No -/// repeat (C11), guests (C12), reminders or colour (C13): Google gives the calendar's default -/// reminders when none are sent. Empty text is left out of the insert, never sent as "". +/// guests (C12), reminders or colour (C13): Google gives the calendar's default reminders when none +/// are sent. Empty text is left out of the insert, never sent as "". #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct NewEventDraft { #[serde(default)] @@ -120,6 +122,10 @@ pub struct NewEventDraft { /// `None` or `Default` is the calendar's default, and isn't sent. #[serde(default)] pub visibility: Option, + /// How it repeats; `None` doesn't repeat. Started on the first day the rule makes + /// ([`crate::calendar_recur::expand::sync_start`]), its end moved with it (C11). + #[serde(default)] + pub recurrence: Option, } /// What the user saw of an event when they chose to delete it, taken from the mirror row they @@ -843,7 +849,9 @@ mod tests { r#"{"summary":"Dentist","time":{"kind":"timed","start_date":"2026-10-26", "start_time":"10:00","start_zone":"Europe/London","end_date":"2026-10-26", "end_time":"11:00","end_zone":"Europe/London"},"location":"High St", - "description":"Bring the form","show_as":"free","visibility":"private"}"#, + "description":"Bring the form","show_as":"free","visibility":"private", + "recurrence":{"pattern":{"kind":"weekly","days":["mon"]},"interval":1, + "ends":{"kind":"after","count":10}}}"#, ) .unwrap(); assert!(full.summary == "Dentist" && full.location == "High St"); @@ -851,6 +859,9 @@ mod tests { assert!(matches!(full.time, TimeDraft::Timed { .. })); assert_eq!(full.show_as, Some(ShowAs::Free)); assert_eq!(full.visibility, Some(Visibility::Private)); + assert!(full + .recurrence + .is_some_and(|rule| rule.interval == 1 && rule.to_spec().is_ok())); let bare: NewEventDraft = serde_json::from_str( r#"{"time":{"kind":"all_day","first_day":"2026-10-26","last_day":"2026-10-26"}}"#, @@ -859,6 +870,14 @@ mod tests { assert!(bare.summary.is_empty() && bare.location.is_empty()); assert!(bare.description.is_empty()); assert_eq!((bare.show_as, bare.visibility), (None, None)); + // Left out or null, it doesn't repeat. + assert!(bare.recurrence.is_none()); + let null: NewEventDraft = serde_json::from_str( + r#"{"time":{"kind":"all_day","first_day":"2026-10-26","last_day":"2026-10-26"}, + "recurrence":null}"#, + ) + .unwrap(); + assert!(null.recurrence.is_none()); let unknown = serde_json::from_str::( r#"{"summary":"x","time":{"kind":"floating","day":"2026-10-26"}}"#, diff --git a/src-tauri/src/calendar_write/patch.rs b/src-tauri/src/calendar_write/patch.rs index e40830a7..1d269bea 100644 --- a/src-tauri/src/calendar_write/patch.rs +++ b/src-tauri/src/calendar_write/patch.rs @@ -16,10 +16,14 @@ //! still matches the fresh copy ([`delete_still_matches`]). Re-deleting just because the etag moved //! would delete a version of the event nobody looked at. +use std::fmt; + use serde_json::{Map, Value}; use super::dto::{EventPatchDraft, NewEventDraft, SeenSummary, Visibility}; use super::time::{self, TimeError}; +use crate::calendar_recur::expand::sync_start; +use crate::calendar_recur::spec::{rule_from_spec, SpecError}; /// The only fields a single-event save sends. pub const PATCHABLE: &[&str] = &[ @@ -32,18 +36,73 @@ pub const PATCHABLE: &[&str] = &[ "visibility", ]; -/// The only fields a create sends: [`PATCHABLE`]'s, nothing more (no attendees, recurrence, -/// reminders, conference, colour or private notes until their layers). The same list on purpose: -/// widening what a save sends (C13's reminders) widens creates too, and the create plan's own -/// refusal test then fails, so it is a decision, never a slip (C10 critique 1 L10). -pub const CREATABLE: &[&str] = PATCHABLE; +/// What a create may send: [`PATCHABLE`]'s fields and the repeat rule (C11). Still no attendees, +/// reminders, conference, colour or private notes until their layers. Widening it is a decision, +/// never a slip: `creatable_is_patchable_and_the_rule` pins it to exactly this, and the create +/// plan's own refusal test names what it still refuses (C10 critique 1 L10). +pub const CREATABLE: &[&str] = &[ + "summary", + "location", + "description", + "start", + "end", + "transparency", + "visibility", + "recurrence", +]; + +/// Why a create's body can't be built. Shown to the user as is: the inner words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InsertError { + Time(TimeError), + Rule(SpecError), +} + +impl fmt::Display for InsertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InsertError::Time(e) => e.fmt(f), + InsertError::Rule(e) => e.fmt(f), + } + } +} + +impl From for InsertError { + fn from(e: TimeError) -> Self { + InsertError::Time(e) + } +} + +impl From for InsertError { + fn from(e: SpecError) -> Self { + InsertError::Rule(e) + } +} /// The insert body for `draft`. Google's `start`/`end` come from [`time::resolve`] (explicit zones, /// a skipped hour refused, a repeated hour's first occurrence, an all-day end the day after the /// last day); empty text is left out, so Google keeps no empty strings. No `id`: the insert request /// writes PM's own in ([`super::plan::insert_event`]). -pub fn insert_body(draft: &NewEventDraft) -> Result, TimeError> { - let when = time::resolve(&draft.time)?; +/// +/// A repeating event starts on the first day its rule makes ([`sync_start`]: "Weekly on Monday and +/// Wednesday" chosen on a Tuesday starts on the Wednesday, A27), its end moved the same number of +/// days, and carries one rule line and nothing else (no EXDATE or RDATE). A timed one names its +/// zone on both nodes, as every timed node does; an all-day one sends dates only (probe P-R1). +pub fn insert_body(draft: &NewEventDraft) -> Result, InsertError> { + let (time, rule) = match &draft.recurrence { + None => (draft.time.clone(), None), + Some(rule) => { + let spec = rule.to_spec()?; + let asked = time::series_start(&draft.time)?; + let synced = sync_start(&spec, &asked)?; + let moved = (synced.date() - asked.date()).num_days(); + ( + time::shifted(&draft.time, moved)?, + Some(rule_from_spec(&spec, &synced)?), + ) + } + }; + let when = time::resolve(&time)?; let mut body = Map::new(); for (key, text) in [ ("summary", &draft.summary), @@ -69,6 +128,9 @@ pub fn insert_body(draft: &NewEventDraft) -> Result, TimeErro Value::String(visibility.as_param().into()), ); } + if let Some(line) = rule { + body.insert("recurrence".into(), Value::from(vec![line])); + } Ok(body) } @@ -348,9 +410,14 @@ pub(crate) fn when_eq(a: Option<&Value>, b: Option<&Value>) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, RepeatRule}; use crate::calendar_write::dto::{ShowAs, TimeDraft, Visibility}; use serde_json::json; + fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate { + chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + /// Google's fresh copy of a one-off event, with fields PM must never touch. fn fresh() -> Value { json!({ @@ -747,17 +814,36 @@ mod tests { description: "Bring the form\nand a pen".into(), show_as: Some(ShowAs::Free), visibility: Some(Visibility::Private), + recurrence: None, + } + } + + /// `new_event` repeating by `pattern`, every time, until `ends`. + fn repeating(time: TimeDraft, pattern: RepeatPattern, ends: RepeatEnds) -> NewEventDraft { + NewEventDraft { + recurrence: Some(RepeatRule { + pattern, + interval: 1, + ends, + }), + ..new_event(time) + } + } + + fn weekly_on(days: &[DayCode]) -> RepeatPattern { + RepeatPattern::Weekly { + days: days.to_vec(), } } + /// Every field a create may send, the rule included, and nothing else. #[test] fn insert_body_sends_only_what_a_create_may() { - let body = insert_body(&new_event(timed( - "2026-10-26", - "10:00", - "2026-10-26", - "11:00", - ))) + let body = insert_body(&repeating( + timed("2026-10-26", "10:00", "2026-10-26", "11:00"), + weekly_on(&[DayCode::Mon]), + RepeatEnds::After { count: 10 }, + )) .unwrap(); let mut keys: Vec<&str> = body.keys().map(String::as_str).collect(); keys.sort_unstable(); @@ -767,6 +853,7 @@ mod tests { "description", "end", "location", + "recurrence", "start", "summary", "transparency", @@ -778,7 +865,152 @@ mod tests { assert_eq!(body["description"], "Bring the form\nand a pen"); assert_eq!(body["transparency"], "transparent"); assert_eq!(body["visibility"], "private"); + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]) + ); assert!(!body.contains_key("id"), "the insert request writes the id"); + // No rule, no `recurrence` at all. + let once = insert_body(&new_event(timed( + "2026-10-26", + "10:00", + "2026-10-26", + "11:00", + ))) + .unwrap(); + assert!(!once.contains_key("recurrence")); + } + + /// C11 §3.3: what a create may send is a single save's fields and the rule, exactly. + #[test] + fn creatable_is_patchable_and_the_rule() { + let mut expected: Vec<&str> = PATCHABLE.to_vec(); + expected.push("recurrence"); + assert_eq!(CREATABLE, expected.as_slice()); + } + + /// G6: a weekly event on its own weekday sends one rule line, and both nodes name the zone. + #[test] + fn a_repeating_insert_sends_one_rule_and_names_its_zone() { + let body = insert_body(&repeating( + timed("2026-10-13", "09:00", "2026-10-13", "10:00"), + weekly_on(&[DayCode::Tue]), + RepeatEnds::Never, + )) + .unwrap(); + assert_eq!(body["recurrence"], json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"])); + assert_eq!( + body["start"], + json!({ "dateTime": "2026-10-13T09:00:00+01:00", "timeZone": "Europe/London" }) + ); + assert_eq!(body["end"]["timeZone"], "Europe/London"); + } + + /// A27 (live test L-R10's case): Monday and Wednesday chosen on a Tuesday starts on the + /// Wednesday, and the hour's length goes with it. + #[test] + fn a_repeating_insert_starts_on_the_first_day_its_rule_makes() { + let body = insert_body(&repeating( + timed("2026-10-13", "09:00", "2026-10-13", "10:00"), + weekly_on(&[DayCode::Wed, DayCode::Mon]), + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + )) + .unwrap(); + assert_eq!( + body["start"], + json!({ "dateTime": "2026-10-14T09:00:00+01:00", "timeZone": "Europe/London" }) + ); + assert_eq!( + body["end"], + json!({ "dateTime": "2026-10-14T10:00:00+01:00", "timeZone": "Europe/London" }) + ); + // The end date's last second in London, as UTC (GMT by then). + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T235959Z"]) + ); + } + + /// An all-day repeating event names days only (probe P-R1): no zone on either node. + #[test] + fn an_all_day_repeating_insert_sends_dates_only() { + let body = insert_body(&repeating( + TimeDraft::AllDay { + first_day: "2026-10-26".into(), + last_day: "2026-10-26".into(), + }, + RepeatPattern::Daily, + RepeatEnds::Never, + )) + .unwrap(); + assert_eq!(body["start"], json!({ "date": "2026-10-26" })); + assert_eq!(body["end"], json!({ "date": "2026-10-27" })); + assert_eq!(body["recurrence"], json!(["RRULE:FREQ=DAILY"])); + } + + /// London skips 01:00-02:00 on Sunday 29-03-2026. A Sunday rule chosen at 01:30 on the Saturday + /// would start in it; an end the move puts in it is refused as any end in a gap is. + #[test] + fn a_repeating_insert_in_a_gap_is_refused() { + let gap = insert_body(&repeating( + timed("2026-03-28", "01:30", "2026-03-28", "03:00"), + weekly_on(&[DayCode::Sun]), + RepeatEnds::Never, + )); + assert!(gap == Err(InsertError::Rule(SpecError::Gap(day(2026, 3, 29))))); + assert_eq!( + gap.unwrap_err().to_string(), + "The first occurrence would be on 29-03-2026, when the clocks skip that time. Pick a \ + time outside that hour." + ); + let end_gap = insert_body(&repeating( + timed("2026-03-27", "23:00", "2026-03-28", "01:30"), + weekly_on(&[DayCode::Sat]), + RepeatEnds::Never, + )); + assert!(matches!( + end_gap, + Err(InsertError::Time(TimeError::Gap { .. })) + )); + } + + /// Google allows 730 occurrences (Help 37115): 731 builds nothing to send. + #[test] + fn a_rule_past_730_builds_nothing() { + let draft = |count: u16| { + repeating( + timed("2026-10-26", "10:00", "2026-10-26", "11:00"), + RepeatPattern::Daily, + RepeatEnds::After { count }, + ) + }; + assert!(insert_body(&draft(731)) == Err(InsertError::Rule(SpecError::TooMany))); + assert_eq!( + insert_body(&draft(730)).unwrap()["recurrence"], + json!(["RRULE:FREQ=DAILY;COUNT=730"]) + ); + // An end date PM can't read, or a time it can't, is said in its own words. + let unread = NewEventDraft { + recurrence: Some(RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends: RepeatEnds::OnDate { + date: "31-12-2026".into(), + }, + }), + ..draft(1) + }; + assert!(insert_body(&unread) == Err(InsertError::Rule(SpecError::BadDate))); + let bad_time = repeating( + timed("2026-10-26", "late", "2026-10-26", "11:00"), + RepeatPattern::Daily, + RepeatEnds::Never, + ); + assert!( + insert_body(&bad_time) == Err(InsertError::Time(TimeError::BadTime("late".into()))) + ); } /// Blank text is left out, so Google stores no empty strings; text with words in is sent as @@ -843,11 +1075,14 @@ mod tests { "2026-03-29", "03:00", ))); - assert!(matches!(gap, Err(TimeError::Gap { .. }))); + assert!(matches!(gap, Err(InsertError::Time(TimeError::Gap { .. })))); for end in ["10:00", "09:00"] { let backwards = insert_body(&new_event(timed("2026-10-26", "10:00", "2026-10-26", end))); - assert!(matches!(backwards, Err(TimeError::EndBeforeStart)), "{end}"); + assert!( + matches!(backwards, Err(InsertError::Time(TimeError::EndBeforeStart))), + "{end}" + ); } } diff --git a/src-tauri/src/calendar_write/series/plan.rs b/src-tauri/src/calendar_write/series/plan.rs index cf7465ef..2fa76605 100644 --- a/src-tauri/src/calendar_write/series/plan.rs +++ b/src-tauri/src/calendar_write/series/plan.rs @@ -4017,6 +4017,7 @@ mod tests { description: String::new(), show_as: Some(ShowAs::Busy), visibility: None, + recurrence: None, }) .unwrap() } @@ -4045,12 +4046,11 @@ mod tests { } /// G5: a key a create may not send, a body with no start PM can read, or an id that isn't one - /// path segment builds no plan at all. + /// path segment builds no plan at all. The repeat rule is one a create sends since C11. #[test] fn a_create_plan_refuses_a_key_a_create_may_not_send() { for key in [ "attendees", - "recurrence", "extendedProperties", "reminders", "conferenceData", @@ -4072,6 +4072,38 @@ mod tests { } } + /// C11: a repeating event's body (its one rule line, its zone on both nodes) builds the same + /// one-step plan, the rule in what it sends. + #[test] + fn a_create_plan_sends_a_repeating_events_rule() { + let body = + super::super::super::patch::insert_body(&crate::calendar_write::dto::NewEventDraft { + recurrence: Some(crate::calendar_recur::wire::RepeatRule { + pattern: crate::calendar_recur::wire::RepeatPattern::Daily, + interval: 1, + ends: crate::calendar_recur::wire::RepeatEnds::After { count: 5 }, + }), + summary: "Dentist".into(), + time: TimeDraft::AllDay { + first_day: "2026-10-26".into(), + last_day: "2026-10-26".into(), + }, + location: String::new(), + description: String::new(), + show_as: None, + visibility: None, + }) + .unwrap(); + let plan = SeriesPlan::create(CALENDAR, NEW, body).unwrap(); + let step = &plan.steps[0]; + assert!(step.op.fits()); + assert_eq!(step.notify, Notify::None); + let request = step.op.request(step.notify).unwrap(); + let sent = request.body.unwrap(); + assert_eq!(sent["recurrence"], json!(["RRULE:FREQ=DAILY;COUNT=5"])); + assert_eq!(sent["id"], NEW); + } + /// A live copy under PM's id is this create's, renamed or not; a cancelled one is this create's, /// deleted since; none proves nothing. #[test] diff --git a/src-tauri/src/calendar_write/series/run.rs b/src-tauri/src/calendar_write/series/run.rs index 6f8e09f7..918b7e77 100644 --- a/src-tauri/src/calendar_write/series/run.rs +++ b/src-tauri/src/calendar_write/series/run.rs @@ -2773,6 +2773,7 @@ mod tests { description: String::new(), show_as: None, visibility: None, + recurrence: None, }) .unwrap(); SeriesPlan::create(CALENDAR, NEW, body).unwrap() diff --git a/src-tauri/src/calendar_write/time.rs b/src-tauri/src/calendar_write/time.rs index 3363f430..f0083bca 100644 --- a/src-tauri/src/calendar_write/time.rs +++ b/src-tauri/src/calendar_write/time.rs @@ -12,13 +12,15 @@ use std::fmt; use chrono::{ - DateTime, Days, FixedOffset, LocalResult, NaiveDate, NaiveTime, SecondsFormat, TimeZone, - Timelike, + DateTime, Days, FixedOffset, LocalResult, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, + TimeZone, Timelike, }; use chrono_tz::Tz; use serde_json::{json, Value}; use super::dto::TimeDraft; +use crate::calendar_recur::wire; +use crate::calendar_recur::SeriesStart; /// Why a time can't be saved as given. Shown to the user as is. #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,22 +67,75 @@ pub fn parse_device_zone(zone: Option<&str>) -> Result { } } -/// An IANA zone name as a zone. +/// An IANA zone name as a zone. The three parsers here are the repeat menu's own ([`wire`]), so +/// a save and the menu's words can't read the same text two ways (C11 critique 1 L6). pub fn parse_zone(name: &str) -> Result { - name.trim() - .parse::() - .map_err(|_| TimeError::UnknownZone(name.trim().to_string())) + wire::parse_zone(name).ok_or_else(|| TimeError::UnknownZone(name.trim().to_string())) } fn parse_date(s: &str) -> Result { - NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|_| TimeError::BadDate(s.into())) + wire::parse_date(s).ok_or_else(|| TimeError::BadDate(s.into())) } fn parse_time(s: &str) -> Result { - let s = s.trim(); - NaiveTime::parse_from_str(s, "%H:%M") - .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S")) - .map_err(|_| TimeError::BadTime(s.into())) + wire::parse_time(s).ok_or_else(|| TimeError::BadTime(s.trim().into())) +} + +/// Where a draft's event starts, as a series would, for a repeat rule to start from (C11): read as +/// [`wire::series_start`] reads the repeat menu's start, with the same parsers in the same order, +/// so the menu's words and the save start a rule on the same day. A time the clocks skip isn't +/// refused here: the rule may move the start, and the day it lands on is checked there. +pub fn series_start(draft: &TimeDraft) -> Result { + match draft { + TimeDraft::Timed { + start_date, + start_time, + start_zone, + .. + } => { + let (date, time) = (parse_date(start_date)?, parse_time(start_time)?); + Ok(SeriesStart::Timed { + local: date.and_time(time), + zone: parse_zone(start_zone)?, + }) + } + TimeDraft::AllDay { first_day, .. } => Ok(SeriesStart::AllDay(parse_date(first_day)?)), + } +} + +/// The draft moved `days` days later, start and end alike, wall times and zones kept: where a repeat +/// rule moved its start, the end goes with it and the event keeps its length. +pub fn shifted(draft: &TimeDraft, days: i64) -> Result { + let by = |date: &str| -> Result { + TimeDelta::try_days(days) + .and_then(|delta| parse_date(date).ok()?.checked_add_signed(delta)) + .map(|d| d.format("%Y-%m-%d").to_string()) + .ok_or_else(|| TimeError::BadDate(date.into())) + }; + Ok(match draft { + TimeDraft::Timed { + start_date, + start_time, + start_zone, + end_date, + end_time, + end_zone, + } => TimeDraft::Timed { + start_date: by(start_date)?, + start_time: start_time.clone(), + start_zone: start_zone.clone(), + end_date: by(end_date)?, + end_time: end_time.clone(), + end_zone: end_zone.clone(), + }, + TimeDraft::AllDay { + first_day, + last_day, + } => TimeDraft::AllDay { + first_day: by(first_day)?, + last_day: by(last_day)?, + }, + }) } /// The instant a wall-clock time names in `zone`. The skipped hour is an error; a repeated one takes @@ -516,4 +571,93 @@ mod tests { json!({ "dateTime": "2026-07-01T09:00:00+02:00", "timeZone": "Europe/Paris" }) ); } + + // --- a repeating event's start (C11) --- + + /// C11 critique 1 L6: the save reads a start exactly as the repeat menu does, the text it + /// accepts and the part it refuses first alike. + #[test] + fn the_save_reads_a_start_as_the_repeat_menu_does() { + use crate::calendar_recur::wire::{RepeatStart, StartError}; + for (date, time, zone) in [ + ("2026-10-13", "09:00", "Europe/London"), + (" 2026-10-13 ", " 09:00:30 ", " America/New_York "), + ("2026-03-29", "01:30", "Europe/London"), + ("13-10-2026", "09:00", "Europe/London"), + ("2026-10-13", "9am", "Europe/London"), + ("2026-10-13", "09:00", "Mars/Olympus"), + ("soon", "late", "nowhere"), + ("2026-10-13", "25:00", "nowhere"), + ] { + let draft = timed_draft(date, time, zone, date, time, zone); + let menu = wire::series_start(&RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: zone.into(), + end: None, + }); + let save = series_start(&draft); + match (menu, save) { + (Ok(a), Ok(b)) => assert_eq!(a, b), + (Err(StartError::Date), Err(TimeError::BadDate(_))) + | (Err(StartError::Time), Err(TimeError::BadTime(_))) + | (Err(StartError::Zone), Err(TimeError::UnknownZone(_))) => {} + _ => panic!("the save and the menu read {date} {time} {zone} differently"), + } + } + let all_day = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "2026-11-01".into(), + }; + assert_eq!( + series_start(&all_day), + Ok(SeriesStart::AllDay( + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap() + )) + ); + } + + /// A start the rule moved takes its end with it: the same wall times and zones, whole days on. + #[test] + fn a_draft_moves_whole_days_keeping_its_wall_times_and_zones() { + let flight = timed_draft( + "2026-10-13", + "22:00", + "Europe/London", + "2026-10-14", + "06:30", + "America/New_York", + ); + assert_eq!( + shifted(&flight, 1), + Ok(timed_draft( + "2026-10-14", + "22:00", + "Europe/London", + "2026-10-15", + "06:30", + "America/New_York", + )) + ); + assert_eq!(shifted(&flight, 0), Ok(flight)); + let days = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "2026-11-01".into(), + }; + assert_eq!( + shifted(&days, 2), + Ok(TimeDraft::AllDay { + first_day: "2026-11-02".into(), + last_day: "2026-11-03".into(), + }) + ); + let unread = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "someday".into(), + }; + assert_eq!( + shifted(&unread, 1), + Err(TimeError::BadDate("someday".into())) + ); + } } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index beaa2ee2..47de3e75 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -38,9 +38,10 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::calendar::{self, CalendarEvent, CalendarFacts}; use crate::calendar_editing::{self, EditingStatus}; +use crate::calendar_recur::expand; use crate::calendar_recur::lines::{self, Applied}; use crate::calendar_recur::spec::{day_phrase, describe_lines}; -use crate::calendar_recur::Point; +use crate::calendar_recur::{Point, SeriesStart}; use crate::calendar_write::classify::{Method, Verdict}; use crate::calendar_write::dto::{ ChoiceRequest, DeleteSettled, DeleteStart, EditLoad, EventForEdit, EventPatchDraft, @@ -304,10 +305,10 @@ fn facts_from_fresh<'a>( } /// Google's copy of a single event is now a repeating event's series: it carries a rule and is no -/// occurrence. The sync never mirrors such a copy (`calendar::parse_event`), so only a row that was -/// a one-off can be under one. +/// occurrence ([`calendar::is_master`], where an empty rule is none). The sync never mirrors such a +/// copy (`calendar::parse_event`), so only a row that was a one-off can be under one. fn turned_series(fresh: &Value) -> bool { - fresh.get("recurrence").is_some() && fresh.get("recurringEventId").is_none() + calendar::is_master(fresh) } /// The gate's facts from a mirror row, for the list. Account state isn't read here (that would be @@ -909,8 +910,10 @@ pub async fn create_calendar_event( }; #[cfg(debug_assertions)] { - // A create reaches one new event, never a series. - let allowed = dev_allow::check(&*state.conn()?, &target.remote_calendar, false)?; + // A create reaches one new event, or a whole repeating one: that is series-wide, so an + // unset allow-list refuses it (C11 critique 1 L11). + let series_wide = draft.recurrence.is_some(); + let allowed = dev_allow::check(&*state.conn()?, &target.remote_calendar, series_wide)?; if let Err(message) = allowed { return Ok(WriteOutcome::Failed { message: message.into(), @@ -1067,7 +1070,7 @@ async fn create_event( }; let wire = Posts::new(ctx.wire); match drive(&wire, &mut run).await { - Ran::Done { ends } => landed_create(ctx.host, ctx.target, &ends), + Ran::Done { ends } => landed_create(ctx.wire, ctx.host, ctx.target, &ends).await, // Gone: made, then deleted in Google before PM could look. Ran::NotDone { why, .. } => not_done_outcome(why, ctx.editing, false), Ran::Unknown { .. } => { @@ -1215,7 +1218,7 @@ async fn retry_create( match retry_next(RetryLook::of(get(ctx.wire, calendar, id).await), first) { RetryStep::Land(copy) => { taken.consume(); - return land_created(ctx.host, &held.target, ©); + return land_copy(ctx.wire, ctx.host, &held.target, ©).await; } RetryStep::Gone => { taken.consume(); @@ -1255,7 +1258,7 @@ async fn retry_create( match ran { Ran::Done { ends } => { taken.consume(); - landed_create(ctx.host, &held.target, &ends) + landed_create(ctx.wire, ctx.host, &held.target, &ends).await } Ran::NotDone { why: Why::Gone, .. } => { taken.consume(); @@ -1287,17 +1290,148 @@ async fn retry_create( } /// A create's run proved it landed: Google's copy into the mirror, from the step's end. -fn landed_create(host: &impl Host, target: &CalendarTarget, ends: &[End]) -> WriteOutcome { +async fn landed_create( + wire: &W, + host: &H, + target: &CalendarTarget, + ends: &[End], +) -> WriteOutcome { match ends.first() { Some(End::Done { reply: Some(copy), .. - }) => land_created(host, target, copy), + }) => land_copy(wire, host, target, copy).await, // Proved without a copy to show: under `Shows::Created` every proof carries one, but the // next sync shows it anyway. _ => created_pending(host, target), } } +/// Google's copy of a new event into the mirror. A one-off's copy is its row; a repeating event's is +/// its master, which never becomes a row (`calendar::parse_event`), so its occurrences are listed +/// and landed instead (C11). +async fn land_copy( + wire: &W, + host: &H, + target: &CalendarTarget, + copy: &Value, +) -> WriteOutcome { + if calendar::is_master(copy) { + return land_new_series(wire, host, &CalIds::of(target), copy).await; + } + land_created(host, target, copy) +} + +/// PM's and Google's ids for one calendar, for landing what a write made there. +struct CalIds<'a> { + calendar_id: &'a str, + remote_calendar: &'a str, +} + +impl<'a> CalIds<'a> { + fn of(target: &'a CalendarTarget) -> Self { + CalIds { + calendar_id: &target.calendar_id, + remote_calendar: &target.remote_calendar, + } + } +} + +/// A repeating event Google now holds, from its copy `copy` (its master): its occurrences in the +/// months PM mirrors, listed and landed as CREATED rows, so a list that lags can't take them off the +/// screen (A28). One with no occurrence in those months (it starts after them, or a COUNT or UNTIL +/// ended it before them) is saved and said to be outside, with nothing listed (C10 §6.2, C11 +/// critique 1 L8). Looks up to three times (now, 1 s, 3 s) while the listing is empty or +/// incomplete; then "saved, the calendar will catch up" rather than a guess. No lock is held across +/// a listing; the landing takes the DB, then `recent`. +async fn land_new_series( + wire: &W, + host: &H, + cal: &CalIds<'_>, + copy: &Value, +) -> WriteOutcome { + let pending = || { + drop_hash(host, cal.calendar_id); + WriteOutcome::Saved { + warnings: vec![WriteWarning::MirrorRefreshPending], + left_behind: Vec::new(), + milestone_moves: None, + } + }; + // Its id one path segment, its start and rule readable (never a guessed zone). + let Ok(master) = Master::read(&FreshEvent::from_google(copy.clone())) else { + return pending(); + }; + let Ok(mirror) = host.mirror(cal.calendar_id) else { + return pending(); + }; + let Some(window) = mirror.instants() else { + return pending(); + }; + if series_in_window(&master, window) == Some(false) { + return WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + milestone_moves: None, + }; + } + let mut listing = Listing::default(); + for pause in [None, Some(REFETCH_AGAIN[0]), Some(REFETCH_AGAIN[1])] { + if let Some(pause) = pause { + wire.wait(pause).await; + } + listing = refetch(wire, cal.remote_calendar, &master.id, &mirror.window, false).await; + if listing.complete && !listing.items.is_empty() { + break; + } + } + if !listing.complete { + return pending(); + } + let rows: Vec = listing + .items + .iter() + .filter_map(|item| calendar::parse_event(cal.calendar_id, item)) + .collect(); + if rows.is_empty() { + return pending(); + } + // Every row is new: the mirror never held one of this event's. + let created: Vec = rows.iter().map(|row| row.id.clone()).collect(); + match land_on(host, cal.calendar_id, rows, &created, Vec::new()) { + Ok(()) => WriteOutcome::Saved { + warnings: Vec::new(), + left_behind: Vec::new(), + milestone_moves: None, + }, + Err(_) => pending(), + } +} + +/// Whether a repeating event has an occurrence in the window PM mirrors (`[from, until)`): its +/// first at or after `from` comes before `until`. `None` when PM can't walk its rule, which lists it +/// anyway. An occurrence begun before `from` that runs into the window doesn't count: at worst the +/// next sync shows it. +fn series_in_window( + master: &Master, + (from, until): (chrono::DateTime, chrono::DateTime), +) -> Option { + let parsed = lines::parse(&master.recurrence); + let (_, facts) = lines::series_rule(&parsed).ok()?; + let (from, until) = match master.start { + SeriesStart::AllDay(_) => ( + Point::Day(from.date_naive()), + Point::Day(until.date_naive()), + ), + SeriesStart::Timed { .. } => (Point::At(from), Point::At(until)), + }; + let first = expand::occurrences_from(&facts, &master.start, from, 1).ok()?; + Some( + first + .first() + .is_some_and(|at| at.cmp_in(&until, master.start.zone()) == std::cmp::Ordering::Less), + ) +} + /// A new event Google holds: its copy into the mirror as a CREATED row (a list that lags can't take /// it off the screen, A28), unless it starts outside the months PM mirrors ([`in_mirror`]). fn land_created(host: &impl Host, target: &CalendarTarget, copy: &Value) -> WriteOutcome { @@ -13774,6 +13908,8 @@ mod tests { // --- creating an event (C10) --- + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, RepeatRule}; + /// The id PM picks for the new event in these tests. const NEW: &str = "fedcba9876543210fedcba9876543210"; @@ -13797,6 +13933,21 @@ mod tests { description: String::new(), show_as: None, visibility: None, + recurrence: None, + } + } + + /// The dentist from `day`, weekly on its Monday, `count` times. + fn weekly_dentist(day: &str, count: u16) -> NewEventDraft { + NewEventDraft { + recurrence: Some(RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Mon], + }, + interval: 1, + ends: RepeatEnds::After { count }, + }), + ..on_day(day) } } @@ -14001,11 +14152,12 @@ mod tests { "reminders", "conferenceData", "extendedProperties", - "recurrence", "colorId", ] { assert!(body.get(never).is_none(), "{never}"); } + // A rule goes only with a draft that repeats (C11). + assert!(body.get("recurrence").is_none(), "no rule, no recurrence"); assert_eq!(body["transparency"], "transparent"); assert_eq!(body["visibility"], "private"); } @@ -14606,6 +14758,295 @@ mod tests { assert!(host.nothing_recorded()); } + // --- creating a repeating event (C11) --- + + /// One occurrence of the new repeating event, at `at` for an hour, as an instances listing + /// gives it; `stamp` is its start in an instance id's compact form. + fn new_occurrence(stamp: &str, at: &str, end: &str) -> Value { + json!({ + "kind": "calendar#event", + "id": format!("{NEW}_{stamp}"), + "etag": "\"o1\"", + "status": "confirmed", + "summary": "Dentist", + "recurringEventId": NEW, + "originalStartTime": { "dateTime": at, "timeZone": "Europe/London" }, + "start": { "dateTime": at, "timeZone": "Europe/London" }, + "end": { "dateTime": end, "timeZone": "Europe/London" }, + "iCalUID": "new@google.com", + "updated": SAVED_AT, + }) + } + + /// The weekly dentist from Monday 26-10, three times, as Google lists it. + fn three_mondays() -> Vec { + vec![ + new_occurrence( + "20261026T100000Z", + "2026-10-26T10:00:00Z", + "2026-10-26T11:00:00Z", + ), + new_occurrence( + "20261102T100000Z", + "2026-11-02T10:00:00Z", + "2026-11-02T11:00:00Z", + ), + new_occurrence( + "20261109T100000Z", + "2026-11-09T10:00:00Z", + "2026-11-09T11:00:00Z", + ), + ] + } + + /// The new repeating event's occurrences over the mirror's window, 50 a page. + fn new_listing(answer: Scripted) -> Step { + Step::new( + Method::Get, + &format!("/events/{NEW}/instances"), + "timeMin=2026-09-01T00%3A00%3A00Z timeMax=2027-11-01T00%3A00%3A00Z maxResults=50", + answer, + ) + } + + fn listed(items: Vec) -> Scripted { + Scripted::Json(page(items, None)) + } + + /// The new repeating event's rows in the mirror, in time order. + fn new_rows(host: &TestHost) -> Vec { + ids_of( + host.with_conn(|conn| calendar::series_rows(conn, "cal", &[NEW])) + .unwrap(), + ) + } + + fn three_ids() -> Vec { + ["20261026T100000Z", "20261102T100000Z", "20261109T100000Z"] + .iter() + .map(|stamp| format!("cal:{NEW}_{stamp}")) + .collect() + } + + /// C11 §3.4: Google answers a repeating create with its master, which is never a row, so its + /// occurrences are listed and landed as created rows. Nobody is emailed, and the rule and the + /// zone go out with it. + #[tokio::test] + async fn a_created_series_lands_its_occurrences_from_a_listing() { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + None, + ) + .await; + assert!(outcome == saved(), "saved and landed"); + let sent = script.sent(); + assert!(sent[0] + .url + .ends_with("?sendUpdates=none&conferenceDataVersion=1")); + let body = sent[0].body.as_ref().unwrap(); + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3"]) + ); + assert_eq!(body["start"]["timeZone"], "Europe/London"); + assert_eq!(body["end"]["timeZone"], "Europe/London"); + assert!(script.waits().is_empty(), "the first look found them"); + assert_eq!(new_rows(&host), three_ids()); + assert!(new_row(&host).is_none(), "the master itself is never a row"); + assert_eq!(host.told(), vec!["cal".to_string()]); + // Recorded as created: a complete list that doesn't show them yet keeps them on screen. + let shown = ids_of(host.synced(&[], Instant::now())); + assert!(three_ids().iter().all(|id| shown.contains(id))); + assert_eq!(held_creates(&host), 0); + } + + /// A28: a listing that lags shows nothing at first, or only its first page before the second is + /// refused; the second look, a second later, lands it. + #[tokio::test] + async fn a_created_series_whose_listing_lags_lands_on_the_second_look() { + let empty = vec![new_listing(listed(Vec::new()))]; + let half = vec![ + new_listing(Scripted::Json(page(three_mondays(), Some("p2")))), + new_listing(Scripted::Status(503)), + ]; + for first in [empty, half] { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let mut steps = vec![insert_step(Scripted::Json(copy))]; + steps.extend(first); + steps.push(new_listing(listed(three_mondays()))); + let (outcome, script) = + create_on(&host, &new_target(), EditingStatus::On, steps, draft, None).await; + assert!(outcome == saved(), "landed on the second look"); + assert_eq!(script.waits(), vec![REFETCH_AGAIN[0]]); + assert_eq!(new_rows(&host), three_ids()); + } + } + + /// Three looks that never show it whole: saved, the next sync to show it, and no guess landed. + /// Half a listing is no listing, and one that shows only deleted occurrences lands nothing. + #[tokio::test] + async fn a_created_series_that_never_lists_is_saved_pending() { + let half = vec![ + // A first page, then its second refused. + new_listing(Scripted::Json(page(three_mondays(), Some("p2")))), + new_listing(Scripted::Status(503)), + ]; + let deleted = vec![new_listing(listed( + three_mondays() + .into_iter() + .map(|o| with(o, "status", json!("cancelled"))) + .collect(), + ))]; + for last in [half, deleted] { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let mut steps = vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(Vec::new())), + new_listing(Scripted::Status(503)), + ]; + steps.extend(last); + let (outcome, script) = + create_on(&host, &new_target(), EditingStatus::On, steps, draft, None).await; + assert!(outcome == pending(), "saved, the mirror to catch up"); + assert_eq!(script.waits(), REFETCH_AGAIN.to_vec()); + assert!(new_rows(&host).is_empty()); + assert!(host.hash_dropped()); + assert!(host.nothing_recorded()); + } + } + + /// C10 §6.2, C11 critique 1 L8: a new repeating event with no occurrence in the months PM + /// mirrors, after them or ended before them, is saved and said to be outside, with no listing + /// at all. One begun before them that runs into them is listed. + #[tokio::test] + async fn a_created_series_after_the_window_is_saved_not_landed() { + let outside = WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + milestone_moves: None, + }; + // Monday 10-01-2028 is past the window; Monday 01-06-2026 three times ends before it. + for draft in [ + weekly_dentist("2028-01-10", 3), + weekly_dentist("2026-06-01", 3), + ] { + let host = TestHost::holding(&[]); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![insert_step(Scripted::Json(copy))], + draft, + None, + ) + .await; + assert!( + outcome == outside, + "saved in Google, and said to be outside" + ); + assert_eq!(script.sent().len(), 1, "nothing listed"); + assert!(new_rows(&host).is_empty() && host.nothing_recorded()); + assert!(host.told().is_empty()); + } + // From Monday 03-08-2026, ten times: its last five fall in the window, so it is listed (the + // listing's own occurrences are what land, three here). + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-08-03", 10); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + None, + ) + .await; + assert!(outcome == saved(), "listed and landed"); + assert_eq!(new_rows(&host).len(), 3); + } + + /// A retry that finds the lost repeating create lands its occurrences, never its master. + #[tokio::test] + async fn a_retried_create_of_a_series_lands_its_occurrences() { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(lost()), + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ], + draft.clone(), + None, + ) + .await; + assert!(outcome == ambiguous("ref-1"), "held as ref-1"); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + new_read(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + Some("ref-1"), + ) + .await; + assert!(outcome == saved(), "found and landed"); + script.finished(); + assert_eq!(new_rows(&host), three_ids()); + assert_eq!(held_creates(&host), 0); + } + + /// C11 critique 1 L11: a repeating create reaches a whole series, so a dev build's unset + /// allow-list refuses it, as it does every write that reaches more than one event. + #[test] + fn a_repeating_create_is_series_wide_to_the_dev_allow_list() { + let source = include_str!("calendar_edit.rs"); + let shipped = source + .split("#[cfg(test)]\nmod tests {") + .next() + .unwrap_or_default(); + let start = shipped + .find("pub async fn create_calendar_event(") + .expect("the create command"); + let shell = &shipped[start..]; + let shell = &shell[..shell.find("\n}\n").unwrap_or(shell.len())]; + assert!(shell.contains("let series_wide = draft.recurrence.is_some();")); + assert!(shell + .contains("dev_allow::check(&*state.conn()?, &target.remote_calendar, series_wide)")); + assert_eq!( + dev_allow::decide(None, None, "home@group", true), + Err(dev_allow::UNSET) + ); + } + /// Creating events ships in every build (C10b): the command makes the window and owner checks /// every write makes before it reads anything or sends, and no build refuses it outright. #[test] diff --git a/src-tauri/src/commands/calendar_repeat.rs b/src-tauri/src/commands/calendar_repeat.rs new file mode 100644 index 00000000..7b7593cb --- /dev/null +++ b/src-tauri/src/commands/calendar_repeat.rs @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! The repeat menu's ready-made rules and a rule's words, for the event editor (#884, C11). Pure: no +//! state, no I/O, no window check (they write nothing), so the webview needs no rrule package and PM +//! says a rule one way everywhere. +//! +//! Both are `async`, so they run off the main thread: the editor asks on every change, with no +//! debounce. Each asks the rrule crate for one occurrence at most, and a rule that has none at all is +//! refused before the crate is asked (C11 critique 1 L5), so neither takes more than a moment. + +use crate::calendar_recur::expand::sync_start; +use crate::calendar_recur::spec::{describe, presets, SpecError}; +use crate::calendar_recur::wire::{ + parse_date, series_start, RepeatPreset, RepeatRule, RepeatStart, RepeatView, +}; + +/// The menu's ready-made rules for an event starting on `day` (`YYYY-MM-DD`), as Google offers them. +/// An unreadable day gives none; the menu then has only "Does not repeat" and "Custom…". +#[tauri::command] +pub async fn recurrence_presets(day: String) -> Vec { + parse_date(&day) + .map(presets) + .unwrap_or_default() + .into_iter() + .map(|preset| RepeatPreset { + rule: RepeatRule::from_spec(&preset.spec), + label: preset.label, + }) + .collect() +} + +/// `rule` in words for an event starting at `start`, and the day its first occurrence falls on: +/// "Weekly on Monday and Wednesday" chosen on a Tuesday starts on the Wednesday (A27). A rule outside +/// the limits, or a first occurrence the clocks skip the start or end time of, is refused in words. +#[tauri::command] +pub async fn describe_recurrence(rule: RepeatRule, start: RepeatStart) -> RepeatView { + let refused = |message: String| RepeatView::Refused { message }; + let asked = match series_start(&start) { + Ok(asked) => asked, + Err(e) => return refused(e.to_string()), + }; + let spec = match rule.to_spec() { + Ok(spec) => spec, + Err(e) => return refused(e.to_string()), + }; + let synced = match sync_start(&spec, &asked) { + Ok(synced) => synced, + Err(e) => return refused(e.to_string()), + }; + let moved = (synced.date() - asked.date()).num_days(); + // The save moves the end with the start, so a first occurrence moved onto the night the clocks + // skip its end time would be refused there: say so here (C11 critique 2 L7). + if let RepeatStart::Timed { end: Some(end), .. } = &start { + if let Some(day) = end.skipped_after(moved) { + return refused(SpecError::EndGap(day).to_string()); + } + } + RepeatView::Ready { + summary: describe(&spec, &synced), + starts: synced.date().format("%Y-%m-%d").to_string(), + moved: moved != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, WallTime}; + + fn timed(date: &str, time: &str) -> RepeatStart { + RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: "Europe/London".into(), + end: None, + } + } + + fn all_day(date: &str) -> RepeatStart { + RepeatStart::AllDay { date: date.into() } + } + + fn weekly(days: &[DayCode], ends: RepeatEnds) -> RepeatRule { + RepeatRule { + pattern: RepeatPattern::Weekly { + days: days.to_vec(), + }, + interval: 1, + ends, + } + } + + fn daily(ends: RepeatEnds) -> RepeatRule { + RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends, + } + } + + fn ready(summary: &str, starts: &str, moved: bool) -> RepeatView { + RepeatView::Ready { + summary: summary.into(), + starts: starts.into(), + moved, + } + } + + fn refused(message: impl ToString) -> RepeatView { + RepeatView::Refused { + message: message.to_string(), + } + } + + /// T3's five, in the menu's order, each with the rule it stands for. + #[tokio::test] + async fn presets_for_a_day_come_back_labelled() { + let presets = recurrence_presets("2026-10-13".into()).await; + let labels: Vec<&str> = presets.iter().map(|p| p.label.as_str()).collect(); + assert_eq!( + labels, + [ + "Daily", + "Weekly on Tuesday", + "Monthly on the second Tuesday", + "Annually on 13 October", + "Every weekday (Monday to Friday)", + ] + ); + assert!(presets[1].rule == weekly(&[DayCode::Tue], RepeatEnds::Never)); + assert!(presets + .iter() + .all(|p| p.rule.interval == 1 && p.rule.ends == RepeatEnds::Never)); + } + + #[tokio::test] + async fn an_unreadable_day_gives_no_presets() { + for day in ["13-10-2026", "", "2026-02-30", "tomorrow"] { + assert!(recurrence_presets(day.into()).await.is_empty(), "{day}"); + } + } + + /// A27: a rule whose first day isn't the one typed says where it starts. + #[tokio::test] + async fn a_moved_start_is_said() { + let view = describe_recurrence( + weekly(&[DayCode::Mon, DayCode::Wed], RepeatEnds::Never), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Weekly on Monday and Wednesday", "2026-10-14", true)); + let weekdays = [ + DayCode::Mon, + DayCode::Tue, + DayCode::Wed, + DayCode::Thu, + DayCode::Fri, + ]; + let view = + describe_recurrence(weekly(&weekdays, RepeatEnds::Never), all_day("2026-10-31")).await; + assert!(view == ready("Every weekday (Monday to Friday)", "2026-11-02", true)); + // A rule the typed day already fits doesn't move it. + let view = + describe_recurrence(daily(RepeatEnds::Never), timed("2026-10-13", "09:00")).await; + assert!(view == ready("Daily", "2026-10-13", false)); + } + + /// London skips 01:00-02:00 on Sunday 29-03-2026: a Sunday rule chosen at 01:30 on the + /// Saturday would start then. + #[tokio::test] + async fn a_gap_is_refused_in_words() { + let view = describe_recurrence( + weekly(&[DayCode::Sun], RepeatEnds::Never), + timed("2026-03-28", "01:30"), + ) + .await; + assert!( + view == refused( + "The first occurrence would be on 29-03-2026, when the clocks skip that time. \ + Pick a time outside that hour." + ), + "the skipped day in words" + ); + } + + /// C11 critique 2 L7: an end the move puts in the skipped hour is refused too, and only then. + #[tokio::test] + async fn an_end_the_clocks_skip_is_refused() { + let with_end = |time: &str| RepeatStart::Timed { + date: "2026-03-27".into(), + time: "23:00".into(), + zone: "Europe/London".into(), + end: Some(WallTime { + date: "2026-03-28".into(), + time: time.into(), + zone: "Europe/London".into(), + }), + }; + // Friday 23:00 to Saturday 01:30, made to repeat on Saturdays: it starts on the Saturday and + // would end at 01:30 on Sunday 29-03, which London skips. + let saturdays = weekly(&[DayCode::Sat], RepeatEnds::Never); + let view = describe_recurrence(saturdays.clone(), with_end("01:30")).await; + assert!( + view == refused(SpecError::EndGap( + chrono::NaiveDate::from_ymd_opt(2026, 3, 29).unwrap() + )), + "the end's skipped day in words" + ); + let view = describe_recurrence(saturdays, with_end("02:30")).await; + assert!(view == ready("Weekly on Saturday", "2026-03-28", true)); + } + + #[tokio::test] + async fn a_rule_past_730_is_refused() { + let view = describe_recurrence( + daily(RepeatEnds::After { count: 731 }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("Google allows at most 730 occurrences of a repeating event.")); + } + + #[tokio::test] + async fn seven_hundred_thirty_is_accepted() { + let view = describe_recurrence( + daily(RepeatEnds::After { count: 730 }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Daily, 730 times", "2026-10-13", false)); + } + + #[tokio::test] + async fn an_end_before_the_start_is_refused() { + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "2026-10-12".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("The end date is before the first occurrence.")); + // One PM can't read is said so, never guessed. + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "12-10-2026".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("PM couldn't read that end date.")); + } + + #[tokio::test] + async fn an_end_on_the_start_day_is_accepted() { + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "2026-10-13".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Daily, until 13-10-2026", "2026-10-13", false)); + } + + /// C11 critique 1 L5: "on day 31" of every twelfth April never happens, and is refused at once + /// rather than looked for; a start PM can't read is said so. + #[tokio::test] + async fn a_rule_with_no_occurrence_or_an_unread_start_is_refused() { + let never = RepeatRule { + pattern: RepeatPattern::MonthlyDay { day: 31 }, + interval: 12, + ends: RepeatEnds::After { count: 5 }, + }; + let view = describe_recurrence(never, timed("2026-04-13", "09:00")).await; + assert!(view == refused(SpecError::NoOccurrence)); + let view = describe_recurrence(daily(RepeatEnds::Never), timed("2026-04-13", "9am")).await; + assert!(view == refused("PM couldn't read that time.")); + } + + /// The summary is `describe`'s words for every T2 rule, from the fixture both languages read. + #[tokio::test] + async fn the_words_are_describes() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../src/lib/calendarEdit/repeatRules.fixture.json" + )) + .unwrap(); + let start: RepeatStart = serde_json::from_value(fixture["start"].clone()).unwrap(); + let asked = series_start(&start).unwrap(); + let rows = fixture["rows"].as_array().unwrap(); + assert!(!rows.is_empty()); + for row in rows { + let rule: RepeatRule = serde_json::from_value(row["spec"].clone()).unwrap(); + let spec = rule.to_spec().unwrap(); + let synced = sync_start(&spec, &asked).unwrap(); + let words = describe(&spec, &synced); + assert_eq!(row["words"].as_str(), Some(words.as_str())); + let view = describe_recurrence(rule, start.clone()).await; + assert!( + matches!(&view, RepeatView::Ready { summary, .. } if *summary == words), + "{words}" + ); + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b7df3e06..8f9ffc5a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ mod archivist; mod assistant; mod backups; mod calendar_edit; +mod calendar_repeat; mod calendars; mod canon; mod connectors; @@ -34,6 +35,7 @@ pub use archivist::*; pub use assistant::*; pub use backups::*; pub use calendar_edit::*; +pub use calendar_repeat::*; pub use calendars::*; pub use canon::*; pub use connectors::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e89615ad..5d70686f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1793,6 +1793,8 @@ pub fn run() { commands::series_choices, commands::get_series_summary, commands::settle_series_split, + commands::recurrence_presets, + commands::describe_recurrence, #[cfg(debug_assertions)] commands::dev_series_report, commands::connect_outlook_calendar, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 211e4447..57497bb5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PM", - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/calendar/edit/NewEventEditor.test.tsx b/src/components/calendar/edit/NewEventEditor.test.tsx index 65d0928e..55a5e2d3 100644 --- a/src/components/calendar/edit/NewEventEditor.test.tsx +++ b/src/components/calendar/edit/NewEventEditor.test.tsx @@ -123,7 +123,7 @@ const seed: EditorFields = { /** What the editor sends for the seed titled `summary`, on `calendarId`. */ const sentFor = (summary: string, calendarId = PERSONAL) => ({ calendarId, - draft: { ...seed, summary } as NewEventDraft, + draft: { ...seed, summary, recurrence: null } as NewEventDraft, }); const saved: WriteOutcome = { outcome: "saved", warnings: [], left_behind: [] }; @@ -270,6 +270,7 @@ describe("NewEventEditor", () => { expect(Object.keys(createEvent.mock.calls[0][1]).sort()).toEqual([ "description", "location", + "recurrence", "show_as", "summary", "time", diff --git a/src/components/calendar/edit/QuickCreatePopover.test.tsx b/src/components/calendar/edit/QuickCreatePopover.test.tsx index 1021a60f..a80b88c8 100644 --- a/src/components/calendar/edit/QuickCreatePopover.test.tsx +++ b/src/components/calendar/edit/QuickCreatePopover.test.tsx @@ -128,7 +128,7 @@ const seed: EditorFields = { /** What the box sends for the seed titled `summary`, on Personal. */ const sentFor = (summary: string) => ({ calendarId: PERSONAL, - draft: { ...seed, summary } as NewEventDraft, + draft: { ...seed, summary, recurrence: null } as NewEventDraft, }); const saved: WriteOutcome = { outcome: "saved", warnings: [], left_behind: [] }; @@ -277,11 +277,12 @@ describe("QuickCreatePopover", () => { fireEvent.change(title(), { target: { value: "Dentist" } }); fireEvent.keyDown(title(), { key: "Enter" }); await waitFor(() => expect(onClose).toHaveBeenCalledWith(true)); - // The calendar, every field of the draft, and no token: no one to notify, no guests. + // The calendar, every field of the draft, and no token: no one to notify, no repeat, no guests. expect(createEvent.mock.calls).toEqual([[PERSONAL, sentFor("Dentist").draft, null]]); expect(Object.keys(createEvent.mock.calls[0][1]).sort()).toEqual([ "description", "location", + "recurrence", "show_as", "summary", "time", diff --git a/src/components/calendar/edit/WriteNotices.test.tsx b/src/components/calendar/edit/WriteNotices.test.tsx index daba9036..31986d70 100644 --- a/src/components/calendar/edit/WriteNotices.test.tsx +++ b/src/components/calendar/edit/WriteNotices.test.tsx @@ -166,6 +166,7 @@ describe("WriteNotices", () => { description: "", show_as: "free", visibility: "default", + recurrence: null, }, }, }, diff --git a/src/components/calendar/edit/useEventWrites.test.ts b/src/components/calendar/edit/useEventWrites.test.ts index 37acd825..945fa6cd 100644 --- a/src/components/calendar/edit/useEventWrites.test.ts +++ b/src/components/calendar/edit/useEventWrites.test.ts @@ -1050,6 +1050,7 @@ const draft: NewEventDraft = { description: "", show_as: "busy", visibility: "default", + recurrence: null, }; const sent = { calendarId: CAL, draft }; const shown = { name: "Me", hidden: false }; diff --git a/src/lib/calendarEdit/eventDraft.test.ts b/src/lib/calendarEdit/eventDraft.test.ts index f95a1884..e8b87a6d 100644 --- a/src/lib/calendarEdit/eventDraft.test.ts +++ b/src/lib/calendarEdit/eventDraft.test.ts @@ -468,6 +468,7 @@ describe("a new event's starting fields", () => { description: "Bring the form", show_as: "free", visibility: "private", + recurrence: null, }); // A copy: a later edit to the draft can't change what was sent. expect(sent.time).not.toBe(draft.time); diff --git a/src/lib/calendarEdit/eventDraft.ts b/src/lib/calendarEdit/eventDraft.ts index f1039f35..4c432280 100644 --- a/src/lib/calendarEdit/eventDraft.ts +++ b/src/lib/calendarEdit/eventDraft.ts @@ -546,7 +546,8 @@ export function switchNewKind( }; } -/** What creating an event sends: every field, as the editor holds it. */ +/** What creating an event sends: every field, as the editor holds it. The editor has no repeat menu + * yet, so nothing it creates repeats. */ export function toNewEvent(draft: EditorFields): NewEventDraft { return { summary: draft.summary, @@ -555,6 +556,7 @@ export function toNewEvent(draft: EditorFields): NewEventDraft { description: draft.description, show_as: draft.show_as, visibility: draft.visibility, + recurrence: null, }; } diff --git a/src/lib/calendarEdit/repeatRules.fixture.json b/src/lib/calendarEdit/repeatRules.fixture.json new file mode 100644 index 00000000..ffdd4796 --- /dev/null +++ b/src/lib/calendarEdit/repeatRules.fixture.json @@ -0,0 +1,134 @@ +{ + "about": "T2 (#884, C11 §1.5): the repeat rules Google Calendar's editor writes, the words it says them in, and each as the webview holds it, all read against `start`. Rust's calendar_recur::wire tests hold every row to spec.rs's rules_read_as_google_says_them, in order, and the webview's repeat tests read the same file, so both mean the same rules.", + "start": { "kind": "timed", "date": "2026-10-13", "time": "09:00", "zone": "Europe/London" }, + "rows": [ + { + "rule": "RRULE:FREQ=DAILY", + "words": "Daily", + "spec": { "pattern": { "kind": "daily" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", + "words": "Every 3 days, 10 times", + "spec": { + "pattern": { "kind": "daily" }, + "interval": 3, + "ends": { "kind": "after", "count": 10 } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY", + "words": "Weekly on Tuesday", + "spec": { + "pattern": { "kind": "weekly", "days": ["tue"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=TU", + "words": "Weekly on Tuesday", + "spec": { + "pattern": { "kind": "weekly", "days": ["tue"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "words": "Every weekday (Monday to Friday)", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "tue", "wed", "thu", "fri"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", + "words": "Every 2 weeks on Monday and Wednesday, until 31-12-2026", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "wed"] }, + "interval": 2, + "ends": { "kind": "on_date", "date": "2026-12-31" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", + "words": "Weekly on Monday, Wednesday and Friday", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "wed", "fri"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY", + "words": "Monthly on day 13", + "spec": { + "pattern": { "kind": "monthly_day", "day": 13 }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYMONTHDAY=13", + "words": "Monthly on day 13", + "spec": { + "pattern": { "kind": "monthly_day", "day": 13 }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYDAY=2TU", + "words": "Monthly on the second Tuesday", + "spec": { + "pattern": { "kind": "monthly_weekday", "nth": "second", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYDAY=-1TU", + "words": "Monthly on the last Tuesday", + "spec": { + "pattern": { "kind": "monthly_weekday", "nth": "last", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=YEARLY", + "words": "Annually on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", + "words": "Annually on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", + "words": "Annually on the second Tuesday of October", + "spec": { + "pattern": { "kind": "yearly_weekday", "month": 10, "nth": "second", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=DAILY;COUNT=1", + "words": "Daily, once", + "spec": { + "pattern": { "kind": "daily" }, + "interval": 1, + "ends": { "kind": "after", "count": 1 } + } + }, + { + "rule": "RRULE:FREQ=YEARLY;INTERVAL=2", + "words": "Every 2 years on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 2, "ends": { "kind": "never" } } + } + ] +} diff --git a/src/lib/calendarEdit/repeatRules.test.ts b/src/lib/calendarEdit/repeatRules.test.ts new file mode 100644 index 00000000..abc807aa --- /dev/null +++ b/src/lib/calendarEdit/repeatRules.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The shared T2 fixture (#884, C11 §1.5) as the webview reads it: every row a repeat rule of the +// shapes `types.ts` names, against a start of its own shape. Rust holds the same file to its own +// rule table, so a rule the two sides read differently fails one of them. + +import { describe, expect, it } from "vitest"; +import fixture from "./repeatRules.fixture.json"; +import type { DayCode, NthCode, RepeatRule, RepeatStart } from "../types"; + +interface Row { + rule: string; + words: string; + spec: RepeatRule; +} + +const DAYS: readonly string[] = [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +] satisfies DayCode[]; +const NTHS: readonly string[] = ["first", "second", "third", "fourth", "last"] satisfies NthCode[]; + +/** Whether `rule` is one the backend reads: each kind with exactly its own fields, in range. */ +function readable(rule: RepeatRule): boolean { + const p = rule.pattern; + const pattern = (() => { + switch (p.kind) { + case "daily": + case "yearly_date": + return true; + case "weekly": + return p.days.length > 0 && p.days.every((d) => DAYS.includes(d)); + case "monthly_day": + return Number.isInteger(p.day) && p.day >= 1 && p.day <= 31; + case "monthly_weekday": + return NTHS.includes(p.nth) && DAYS.includes(p.weekday); + case "yearly_weekday": + return p.month >= 1 && p.month <= 12 && NTHS.includes(p.nth) && DAYS.includes(p.weekday); + default: + return false; + } + })(); + const e = rule.ends; + const ends = + e.kind === "never" || + (e.kind === "on_date" && /^\d{4}-\d{2}-\d{2}$/.test(e.date)) || + (e.kind === "after" && Number.isInteger(e.count) && e.count >= 1 && e.count <= 730); + return ( + pattern && ends && Number.isInteger(rule.interval) && rule.interval >= 1 && rule.interval <= 99 + ); +} + +describe("the shared repeat-rule fixture", () => { + const rows = fixture.rows as Row[]; + const start = fixture.start as RepeatStart; + + it("reads against the T2 start", () => { + expect(start).toEqual({ + kind: "timed", + date: "2026-10-13", + time: "09:00", + zone: "Europe/London", + }); + }); + + it("holds every T2 row as a rule the backend reads, with Google's words", () => { + expect(rows).toHaveLength(16); + for (const row of rows) { + expect(row.rule.startsWith("RRULE:FREQ="), row.rule).toBe(true); + expect(row.words.length, row.rule).toBeGreaterThan(0); + expect(readable(row.spec), row.rule).toBe(true); + } + expect(new Set(rows.map((r) => r.rule)).size).toBe(rows.length); + }); +}); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index fe5a0096..f0f397f4 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -23,6 +23,13 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "3.152.1-alpha", + date: "2026-10-20", + highlights: [ + "Under the hood, PM can now put repeat rules into words and work out the day a repeating event starts, ready for creating repeating events in the next update.", + ], + }, { version: "3.152.0-alpha", date: "2026-10-20", diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 908fda04..b3a0a527 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -141,6 +141,10 @@ import type { EventPatchDraft, HeldDeleteInfo, NewEventDraft, + RepeatPreset, + RepeatRule, + RepeatStart, + RepeatView, SeenSummary, SeriesChoices, SeriesScope, @@ -1087,6 +1091,18 @@ export const listAllCalendarEvents = () => invoke("list_all_cal /** The active PM flags anchored on a calendar event's iCal UID — for the event detail popup. */ export const eventFlags = (uid: string) => invoke("event_flags", { uid }); +// Repeat rules (#884, C11): pure reads for the editor's Repeat row. They change nothing. + +/** The repeat menu's ready-made rules for an event starting on `day` (YYYY-MM-DD); none when the day + * can't be read. */ +export const recurrencePresets = (day: string) => + invoke("recurrence_presets", { day }); + +/** A rule in the repeat menu's words for an event starting at `start`, and the day its first + * occurrence falls on, or why it can't be saved. */ +export const describeRecurrence = (rule: RepeatRule, start: RepeatStart) => + invoke("describe_recurrence", { rule, start }); + // Calendar editing (#884). Importable only from src/components/calendar/edit/useEventWrites.ts // (eslint.config.js + scripts/check-calendar-write-fence.mjs); every command is main-window only. diff --git a/src/lib/types.ts b/src/lib/types.ts index d53c4256..d625a83b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1583,7 +1583,7 @@ export interface EventPatchDraft { } /** A new event, for creating an event: every field as the editor holds it (empty text is left out - * of what goes to Google). No repeat, guests, reminders or colour yet. */ + * of what goes to Google). No guests, reminders or colour yet. */ export interface NewEventDraft { summary: string; time: TimeDraft; @@ -1591,8 +1591,65 @@ export interface NewEventDraft { description: string; show_as: ShowAs; visibility: EventVisibility; + /** How it repeats; null doesn't repeat. The backend starts it on the first day the rule makes + * (the repeat menu's words say which) and moves the end with it. */ + recurrence: RepeatRule | null; +} + +// --- Repeat rules (#884, C11; mirrors `calendar_recur::wire`) --- + +/** A day of the week, Monday first. */ +export type DayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; +/** Which of a month's weekdays: the first four or the last, never a fifth. */ +export type NthCode = "first" | "second" | "third" | "fourth" | "last"; + +export type RepeatPattern = + | { kind: "daily" } + /** Monday first, each day once. */ + | { kind: "weekly"; days: DayCode[] } + | { kind: "monthly_day"; day: number } + | { kind: "monthly_weekday"; nth: NthCode; weekday: DayCode } + /** On the first occurrence's own date. */ + | { kind: "yearly_date" } + | { kind: "yearly_weekday"; month: number; nth: NthCode; weekday: DayCode }; + +/** `date` is the last day an occurrence can start on (YYYY-MM-DD, in the event's zone); `count` + * is 1 to 730. */ +export type RepeatEnds = + { kind: "never" } | { kind: "on_date"; date: string } | { kind: "after"; count: number }; + +/** A repeat rule from the editor's set. `interval`: every 1 to 99 days, weeks, months or years. */ +export interface RepeatRule { + pattern: RepeatPattern; + interval: number; + ends: RepeatEnds; +} + +/** One ready-made rule of the repeat menu, with the repeat menu's words for it. */ +export interface RepeatPreset { + label: string; + rule: RepeatRule; +} + +/** A wall-clock time as the editor's fields hold it. */ +export interface WallTime { + date: string; + time: string; + zone: string; } +/** Where a new repeating event starts, as the editor's fields hold it. `end`: where its first + * occurrence ends, so an end the rule moves into a skipped hour is said too. */ +export type RepeatStart = + | { kind: "timed"; date: string; time: string; zone: string; end?: WallTime | null } + | { kind: "all_day"; date: string }; + +/** A rule in the repeat menu's words, and the day (YYYY-MM-DD) its first occurrence falls on; + * `moved` when that isn't the day asked for. Or why it can't be saved, in words. */ +export type RepeatView = + | { outcome: "ready"; summary: string; starts: string; moved: boolean } + | { outcome: "refused"; message: string }; + /** What the user saw of an event when they chose to delete it, from the row they clicked. */ export interface SeenSummary { summary: string;