Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
27 changes: 23 additions & 4 deletions src-tauri/src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,17 @@ fn google_conference(it: &serde_json::Value) -> Option<String> {
.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<CalendarEvent> {
value
.get("items")
Expand All @@ -1022,14 +1033,14 @@ fn parse_events(calendar_id: &str, value: &serde_json::Value) -> Vec<CalendarEve
/// back, so a saved event is mirrored exactly as the next sync would mirror it. The row id is the
/// event's own id, an occurrence's instance id included (`<series>_<start>`).
///
/// 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<CalendarEvent> {
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
Expand Down Expand Up @@ -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]
Expand Down
77 changes: 70 additions & 7 deletions src-tauri/src/calendar_recur/expand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<SeriesStart, SpecError> {
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)
Expand All @@ -318,10 +324,26 @@ pub fn sync_start(spec: &RecurrenceSpec, start: &SeriesStart) -> Result<SeriesSt
}
}

/// Whether "on day `day`" of every `interval` months from `from` ever falls: a month it visits has
/// that day (in `from`'s own month, on or after `from`). The months it visits, and which Februaries
/// have a 29th, come round again within 4,800 of its steps (400 years of months), so that many
/// decide it, in microseconds.
fn lands(day: u8, interval: u16, from: NaiveDate) -> 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;

Expand Down Expand Up @@ -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))
);
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/calendar_recur/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading
Loading