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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor
then you can create events on its calendars (+ New event, or press c; Shift+C opens a quick box,
and in the day, week, month and agenda views the new event shows as you type, except in the
Terminal look), repeating ones too (Google's choices, or your own with Custom…), and change and
delete events you organise without guests, repeating ones included. Every change goes straight to
delete events you organise, repeating ones and ones with guests included (PM asks whether Google
should email the guests, as Google Calendar does). Every change goes straight to
Google, a save never overwrites a field someone changed there in the meantime, and a new
event whose save loses the connection is looked for in Google before it's ever sent again.
- **Bring your own model.** Choose your model through OpenRouter — separate chat and
Expand Down
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.154.2-alpha",
"version": "3.155.0-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.154.2-alpha"
version = "3.155.0-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
Expand Down
125 changes: 124 additions & 1 deletion src-tauri/src/calendar_write/attendees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ pub struct Reach {
reached: BTreeSet<String>,
/// Who it takes off.
removed: BTreeSet<String>,
/// Who only a delete or a cancel reaches: no other write merged reaches them, so all they hear
/// is that an occurrence is cancelled, never an update.
only_cancelled: BTreeSet<String>,
/// Emails it would send: one per guest per event written.
emails: u32,
/// PM saw every guest list it counted.
Expand All @@ -349,6 +352,7 @@ impl Reach {
was: BTreeSet::new(),
reached: BTreeSet::new(),
removed: BTreeSet::new(),
only_cancelled: BTreeSet::new(),
emails: 0,
complete: true,
cancelled: false,
Expand Down Expand Up @@ -388,6 +392,7 @@ impl Reach {
was: before,
reached,
removed,
only_cancelled: BTreeSet::new(),
// A personal-only change emails nobody, however much of the list Google sent.
complete: !cut_short || !shared,
cancelled: false,
Expand All @@ -401,6 +406,7 @@ impl Reach {
Reach {
emails: count(everyone.len()),
reached: everyone.clone(),
only_cancelled: everyone.clone(),
was: everyone,
removed: BTreeSet::new(),
complete: !omitted(event),
Expand All @@ -417,6 +423,7 @@ impl Reach {
reached: everyone,
was: BTreeSet::new(),
removed: BTreeSet::new(),
only_cancelled: BTreeSet::new(),
complete: true,
cancelled: false,
shared: true,
Expand All @@ -425,6 +432,15 @@ impl Reach {

/// The two together, as one plan sends them.
pub fn merge(mut self, other: Reach) -> Reach {
// Anyone a write other than a cancel reaches, on either side, hears more than a cancel.
let told_more: BTreeSet<String> = self
.reached
.difference(&self.only_cancelled)
.chain(other.reached.difference(&other.only_cancelled))
.cloned()
.collect();
self.only_cancelled.extend(other.only_cancelled);
self.only_cancelled.retain(|a| !told_more.contains(a));
self.was.extend(other.was);
self.reached.extend(other.reached);
self.removed.extend(other.removed);
Expand Down Expand Up @@ -467,16 +483,27 @@ impl Reach {
return None;
}
let already = |a: &&String| self.was.contains(*a);
// When everything it reaches is cancelled, those are among `updated`, told it's cancelled.
let only_cancelled = |a: &&String| !self.cancelled && self.only_cancelled.contains(*a);
let kept = |a: &&String| !self.removed.contains(*a);
Some(GuestNotice {
updated: count(
self.reached
.iter()
.filter(already)
.filter(|a| !self.removed.contains(*a))
.filter(kept)
.filter(|a| !only_cancelled(a))
.count(),
),
invited: count(self.reached.iter().filter(|a| !already(a)).count()),
removed: count(self.removed.len()),
told_cancelled: count(
self.reached
.iter()
.filter(kept)
.filter(only_cancelled)
.count(),
),
cancelled: self.cancelled,
people: count(self.reached.len()),
emails: self.emails,
Expand Down Expand Up @@ -523,6 +550,7 @@ pub fn row_delete_notice(guests: &[Attendee], own: &Own) -> Option<GuestNotice>
Reach {
emails: count(everyone.len()),
reached: everyone.clone(),
only_cancelled: everyone.clone(),
was: everyone,
removed: BTreeSet::new(),
complete: true,
Expand Down Expand Up @@ -782,6 +810,7 @@ mod tests {
updated,
invited,
removed,
told_cancelled: 0,
cancelled: false,
people,
emails: people,
Expand Down Expand Up @@ -963,6 +992,7 @@ mod tests {
updated: 2,
invited: 1,
removed: 0,
told_cancelled: 0,
cancelled: false,
people: 3,
emails: 3 + 2 + 2,
Expand All @@ -975,6 +1005,99 @@ mod tests {
assert!(blind.notice().is_some_and(|n| n.invited == 4));
}

/// C12c review round 3: a guest only a cancel reaches, in a plan that also changes something
/// else, is told that occurrence is cancelled, never of an update; anyone another write reaches
/// hears more than the cancel, whichever side of the merge, in whatever order.
#[test]
fn a_guest_only_a_cancel_reaches_is_told_of_the_cancel() {
let series = event(&[me()]);
let own = Own::of(&["cal@x.org"], &[&series]);
let until = body(json!({ "recurrence": ["RRULE:FREQ=WEEKLY;UNTIL=20261018"] }));
let cut = || Reach::of_patch(&series, &until, &own);
let occurrence = event(&[me(), a()]);
let cancel = || Reach::of_delete(&occurrence, &own);
let told = GuestNotice {
told_cancelled: 1,
people: 1,
emails: 1,
complete: true,
..Default::default()
};
assert!(
cut().merge(cancel()).notice() == Some(told.clone()),
"cut, then cancel"
);
assert!(
cancel().merge(cut()).notice() == Some(told),
"cancel, then cut"
);
// A write other than the cancel reaches A too: A hears of an update, on either side.
let shared = event(&[me(), a()]);
let retitle = || Reach::of_patch(&shared, &title(), &own);
let update = GuestNotice {
updated: 1,
people: 1,
emails: 2,
complete: true,
..Default::default()
};
for all in [
cancel().merge(retitle()),
retitle().merge(cancel()),
cut().merge(cancel()).merge(retitle()),
retitle().merge(cut().merge(cancel())),
] {
assert!(all.notice() == Some(update.clone()));
}
// Two cancels of A, and a guest only the other write reaches: one each.
let b_only = event(&[me(), b_guest()]);
let both = cancel()
.merge(Reach::of_patch(&b_only, &title(), &own))
.merge(cancel());
assert!(
both.notice()
== Some(GuestNotice {
updated: 1,
told_cancelled: 1,
people: 2,
emails: 3,
complete: true,
..Default::default()
})
);
// Taken off as well (left off a new part): counted once, as taken off.
let gone = cancel()
.merge(Reach::of_insert(
&body(json!({ "attendees": [me()] })),
&own,
))
.taking_off(BTreeSet::from([key("A@x.org")]));
assert!(
gone.notice()
== Some(GuestNotice {
removed: 1,
people: 1,
emails: 1,
complete: true,
..Default::default()
})
);
// A plain delete, or a delete's plan, keeps its own words: everyone is told it's cancelled.
let deleted = GuestNotice {
updated: 1,
cancelled: true,
people: 1,
emails: 1,
complete: true,
..Default::default()
};
assert!(cancel().notice() == Some(deleted.clone()), "a plain delete");
assert!(
cut().merge(cancel()).cancelling(true).notice() == Some(deleted),
"a delete's plan"
);
}

/// A user's entry Google hasn't marked `self` (the new part's, as PM sent it) is still the user,
/// in every set PM compares by who is on it.
#[test]
Expand Down
Loading
Loading