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.153.0-alpha",
"version": "3.153.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.153.0-alpha"
version = "3.153.1-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
Expand Down
66 changes: 66 additions & 0 deletions src-tauri/src/calendar_recur/expand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,46 @@ pub fn last_before(
Ok(last)
}

/// The last occurrence a rule that ends (a COUNT or an UNTIL) makes, deleted or not; `None` when it
/// makes none. Only for a rule that ends: one that never does walks to its limit and is
/// [`RecurError::CannotCount`].
pub fn last_occurrence(
facts: &RuleFacts,
start: &SeriesStart,
) -> Result<Option<Point>, RecurError> {
let mut last = None;
walk(facts, start, |p| {
last = Some(p);
true
})?;
Ok(last)
}

/// The rule's first occurrence that `kept` keeps (one not deleted, say), no later than `until`: the
/// one a series that stops repeating still shows (C11 critique 1 H1). `None` when there's none by
/// then.
pub fn first_kept(
facts: &RuleFacts,
start: &SeriesStart,
until: Point,
kept: impl Fn(&Point) -> bool,
) -> Result<Option<Point>, RecurError> {
same_kind(start, &until)?;
let zone = start.zone();
let mut first = None;
walk(facts, start, |p| {
if p.cmp_in(&until, zone) == Ordering::Greater {
return false;
}
if kept(&p) {
first = Some(p);
return false;
}
true
})?;
Ok(first)
}

/// The series' first occurrence on or after `start` under `spec`: "Weekly on Monday and Wednesday"
/// chosen on a Tuesday starts on the Wednesday, at the time chosen. RFC 5545 counts a series' start as
/// an occurrence whether or not its rule makes it, and the crate doesn't, so PM never writes a start
Expand Down Expand Up @@ -753,6 +793,32 @@ mod tests {
format!("{results:?} {moved:?}")
}

/// C11 critique 1 H1: the first occurrence a series that stops repeating keeps is the first not
/// deleted, never past the one asked about.
#[test]
fn the_first_kept_occurrence_skips_deleted_ones_up_to_a_bound() {
let weekly = facts("RRULE:FREQ=WEEKLY;BYDAY=MO");
let start = london(2026, 10, 5, 9);
let gone = [at("2026-10-05T08:00:00Z"), at("2026-10-12T08:00:00Z")];
let kept = |p: &Point| !gone.contains(p);
assert_eq!(
first_kept(&weekly, &start, at("2026-10-26T09:00:00Z"), kept),
Ok(Some(at("2026-10-19T08:00:00Z")))
);
assert_eq!(
first_kept(&weekly, &start, at("2026-10-12T08:00:00Z"), kept),
Ok(None)
);
assert_eq!(
first_kept(&weekly, &start, at("2026-10-26T09:00:00Z"), |_| true),
Ok(Some(at("2026-10-05T08:00:00Z")))
);
assert_eq!(
first_kept(&weekly, &start, Point::Day(day(2026, 10, 26)), kept),
Err(RecurError::Mismatch)
);
}

/// The child half of the next test: in a process started with [`PROBE`] set, prints [`probe`]
/// worked out in that process's zone. Without it, it checks nothing.
#[test]
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/calendar_recur/lines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ impl Rule {
rule
}

/// Whether it says where it ends (a COUNT or an UNTIL), read from its parts as written, so a rule
/// PM can only show as text says it too.
pub fn ends(&self) -> bool {
self.get("COUNT").is_some() || self.get("UNTIL").is_some()
}

/// The rule as an RRULE line, canonical: upper-case, parts in their order.
pub fn line(&self) -> String {
let parts: Vec<String> = self.parts.iter().map(|(k, v)| format!("{k}={v}")).collect();
Expand Down
10 changes: 5 additions & 5 deletions src-tauri/src/calendar_recur/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,12 @@ pub fn describe(spec: &RecurrenceSpec, start: &SeriesStart) -> String {
/// described, or [`CUSTOM_RULE`] for anything outside the editor's set. EXDATEs and RDATEs don't
/// change the words.
pub fn describe_lines<S: AsRef<str>>(lines: &[S], start: &SeriesStart) -> String {
spec_of_lines(lines, start)
.map_or_else(|| CUSTOM_RULE.to_string(), |spec| describe(&spec, start))
spec_of(lines, start).map_or_else(|| CUSTOM_RULE.to_string(), |spec| describe(&spec, start))
}

/// `recurrence` lines' one RRULE as a rule from the editor's set, or `None` for anything else.
fn spec_of_lines<S: AsRef<str>>(lines: &[S], start: &SeriesStart) -> Option<RecurrenceSpec> {
/// `recurrence` lines' one RRULE as a rule from the editor's set, or `None` for anything else: what
/// the editor's Repeat row starts from, and what a change of rule is told from (C11).
pub fn spec_of<S: AsRef<str>>(lines: &[S], start: &SeriesStart) -> Option<RecurrenceSpec> {
let lines = lines::parse(lines);
let mut rules = lines.iter().filter_map(|l| match &l.kind {
Kind::Rule(rule) => Some(rule),
Expand All @@ -423,7 +423,7 @@ fn spec_of_lines<S: AsRef<str>>(lines: &[S], start: &SeriesStart) -> Option<Recu
/// 13 October". `None` for a daily series, which names no day, and for a rule outside the editor's
/// set; the question then says how far every occurrence moves instead.
pub fn day_phrase<S: AsRef<str>>(lines: &[S], start: &SeriesStart) -> Option<String> {
let spec = spec_of_lines(lines, start)?;
let spec = spec_of(lines, start)?;
let n = spec.interval;
let months = |text: String| match n {
1 => format!("{text} of each month"),
Expand Down
73 changes: 69 additions & 4 deletions src-tauri/src/calendar_write/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ pub struct EventPatchDraft {
pub time: Option<TimeDraft>,
pub show_as: Option<ShowAs>,
pub visibility: Option<Visibility>,
/// How it repeats from now on. `None` leaves the repeat as it is (C11).
pub recurrence: Option<RepeatDraft>,
}

/// A change to how an event repeats (C11): a rule from the editor's set, or none.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RepeatDraft {
/// Repeat by this rule from now on.
Rule { rule: RepeatRule },
/// Stop repeating ("Does not repeat").
Once,
}

/// A new event as the editor holds it (A22: defined with its first caller, the create command). No
Expand Down Expand Up @@ -423,8 +435,9 @@ pub enum EditLoad {
seen: Option<SeenSummary>,
/// Its series, when it's an occurrence of one and PM could read Google's copy of it. The
/// session keeps that copy, so "all events" and "this and following" never overwrite a
/// change someone made to the series after the editor opened (G9).
series: Option<SeriesInfo>,
/// change someone made to the series after the editor opened (G9). Boxed, as its rule (C11)
/// makes it the outcome's largest part.
series: Option<Box<SeriesInfo>>,
},
Gone,
Reauth,
Expand All @@ -440,6 +453,13 @@ pub struct SeriesInfo {
pub first: bool,
/// How the series repeats, as Google Calendar says it ("Weekly on Monday").
pub repeats: String,
/// The rule as one from the editor's set, or `None` for one PM can only show (`repeats` says
/// it), which can only be replaced (C11).
pub rule: Option<RepeatRule>,
/// The series' first occurrence's day, `YYYY-MM-DD`, in the zone it repeats in.
pub starts: String,
/// The zone it repeats in; `None` for an all-day one, which repeats by days.
pub zone: Option<String>,
}

/// What the choices for a change to a repeating occurrence are asked for: an open editor's draft, or
Expand Down Expand Up @@ -537,6 +557,10 @@ pub enum SeriesWarningDto {
},
/// The new part is a new event in Google: links to the old one stay with the part before.
NewIdentity,
/// It stops repeating: only the occurrence on `day` stays (C11).
BecomesOneEvent { day: String },
/// This many occurrences changed on their own fall on days the new rule skips: their changes go.
ChangesLost { count: u32 },
}

#[cfg(test)]
Expand Down Expand Up @@ -834,10 +858,51 @@ mod tests {
assert_eq!(
serde_json::to_string(&SeriesInfo {
first: false,
repeats: "Weekly on Monday".into()
repeats: "Weekly on Monday".into(),
rule: None,
starts: "2026-10-05".into(),
zone: Some("Europe/London".into()),
})
.unwrap(),
r#"{"first":false,"repeats":"Weekly on Monday"}"#
r#"{"first":false,"repeats":"Weekly on Monday","rule":null,"starts":"2026-10-05","zone":"Europe/London"}"#
);
for (warning, json) in [
(
SeriesWarningDto::BecomesOneEvent {
day: "2026-10-05".into(),
},
r#"{"warning":"becomes_one_event","day":"2026-10-05"}"#,
),
(
SeriesWarningDto::ChangesLost { count: 2 },
r#"{"warning":"changes_lost","count":2}"#,
),
] {
assert_eq!(serde_json::to_string(&warning).unwrap(), json);
}
}

/// C11: a draft's repeat, as the editor will send it; left out or null, the repeat is untouched.
#[test]
fn a_draft_names_a_new_repeat_or_none() {
let rule: EventPatchDraft = serde_json::from_str(
r#"{"recurrence":{"kind":"rule","rule":{"pattern":{"kind":"daily"},"interval":2,
"ends":{"kind":"never"}}}}"#,
)
.unwrap();
assert!(matches!(
rule.recurrence,
Some(RepeatDraft::Rule { rule }) if rule.interval == 2 && rule.to_spec().is_ok()
));
let once: EventPatchDraft =
serde_json::from_str(r#"{"recurrence":{"kind":"once"}}"#).unwrap();
assert_eq!(once.recurrence, Some(RepeatDraft::Once));
for json in [r#"{"summary":"Gym"}"#, r#"{"recurrence":null}"#] {
let untouched: EventPatchDraft = serde_json::from_str(json).unwrap();
assert!(untouched.recurrence.is_none(), "{json}");
}
assert!(
serde_json::from_str::<EventPatchDraft>(r#"{"recurrence":{"kind":"weekly"}}"#).is_err()
);
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/calendar_write/patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ mod tests {
}),
show_as: Some(ShowAs::Free),
visibility: Some(Visibility::Private),
// A repeat is never a one-event body's: the series planners write it (C11).
recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once),
};
let body = build_patch(&fresh(), &draft).unwrap();
for key in body.keys() {
Expand Down
16 changes: 14 additions & 2 deletions src-tauri/src/calendar_write/series/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum Expect {
Present { series: String },
/// Nothing of the series.
Absent { series: String },
/// The new event `id`, one that doesn't repeat, a split that stops repeating made (C11): it lands
/// from its insert's own answer, never a listing, since what Google lists for a one-off's
/// occurrences is unproven. [`diff_series`] reads no re-fetch for it.
Event { id: String },
}

/// The rows a plan removes from the mirror, so a stale re-fetch can't bring them back (A14): a
Expand Down Expand Up @@ -264,14 +268,15 @@ fn instant(p: Point) -> DateTime<Utc> {

/// Whether the re-fetch shows `expect`. A series that wasn't re-fetched shows nothing.
fn shows(expect: &Expect, refetched: &[Refetch], zone: Tz) -> bool {
let series_of = |e: &Expect| match e {
let series = match expect {
Expect::Updated { series, .. }
| Expect::Cancelled { series, .. }
| Expect::EndsBefore { series, .. }
| Expect::Present { series }
| Expect::Absent { series } => series.clone(),
// Landed from its insert's answer: no listing stands for it.
Expect::Event { .. } => return true,
};
let series = series_of(expect);
let Some(r) = refetched.iter().find(|r| r.series == series) else {
return false;
};
Expand Down Expand Up @@ -302,6 +307,7 @@ fn shows(expect: &Expect, refetched: &[Refetch], zone: Tz) -> bool {
}
Expect::Present { .. } => live().next().is_some(),
Expect::Absent { .. } => r.listing.complete && live().next().is_none(),
Expect::Event { .. } => true,
}
}

Expand Down Expand Up @@ -582,6 +588,12 @@ mod tests {
}];
let diff = diff_series(CAL, &[], &refetch, window(), &[], London, None);
assert!(diff.upserts.is_empty() && diff.created_since(&[]).is_empty());

// C11: one event a split made lands from its insert's answer: no listing stands for it, so
// it never stales one.
let event = Expect::Event { id: "new".into() };
let diff = diff_series(CAL, &[], &refetch, window(), &[event], London, None);
assert!(!diff.stale);
}

/// C8b review R2: an occurrence the run reported it couldn't cancel is still in Google. A
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/calendar_write/series/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ pub struct Entry {
pub cut: Vec<String>,
/// The change also moves or cancels occurrences changed on their own after the split.
pub cleanup: bool,
/// The new part is one event that doesn't repeat ("Does not repeat", C11), for the words. A
/// record from before there was such a change has none: a new part that repeats.
#[serde(default)]
pub one_off: bool,
}

impl Entry {
Expand Down Expand Up @@ -183,6 +187,7 @@ mod tests {
before: vec!["RRULE:FREQ=WEEKLY;BYDAY=MO".into()],
cut: vec!["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261018T225959Z".into()],
cleanup: true,
one_off: false,
}
}

Expand Down Expand Up @@ -307,6 +312,12 @@ mod tests {
assert!(read(Some("not json")).is_empty());
assert!(read(None).is_empty());
assert_ne!(key("a", "bc"), key("ab", "c"));
// A record kept before a new part could be one event still reads, as a part that repeats.
let mut older: Value = serde_json::from_str(&stored).unwrap();
for e in older.as_object_mut().unwrap().values_mut() {
e.as_object_mut().unwrap().remove("one_off");
}
assert_eq!(read(Some(&older.to_string())), journal);
}

#[test]
Expand Down
Loading
Loading