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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor
Terminal look), repeating ones too (Google's choices, or your own with Custom…) and ones with
guests, and change and delete events you organise, repeating ones and ones with guests included
(PM asks whether Google should email the guests, as Google Calendar does); new or not, an
event's notifications and colour can be set too. Every change goes
event's notifications and colour can be set too. Answer invitations from an event's details
(Yes, No or Maybe, for one occurrence of a repeating one or all of them; Google tells the
organiser), with a note from the editor, where your own notifications, colour and Busy or Free
change too, and its title, time and place where the organiser lets guests change them (how it
repeats and who's invited stay the organiser's); events you've declined are struck through, as
Google shows them. 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
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ event changes you make to Google), a read-only sync of the cloud accounts you co
Drive, Google Sheets, OneDrive), and encrypted backups to your own cloud. There is no telemetry,
analytics, or crash reporting. **Calendar editing raises the stakes of a stolen token:** a Google
account with editing on holds a token that can create, change and delete events on every
calendar that account can edit, and send invitations in its name. "Turn off editing" in PM stops
calendar that account can edit, and send invitations, and replies to invitations, in its name.
"Turn off editing" in PM stops
PM using it at once but doesn't narrow what Google granted. Disconnecting the account removes
the grant at Google only when nothing else in PM uses that account; while Google Drive or
backups still do, the calendar write permission stays granted until you remove PM at
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const CALENDAR_WRITES = {
importNames: [
"getCalendarEventForEdit",
"updateCalendarEvent",
"respondToCalendarEvent",
"createCalendarEvent",
"deleteCalendarEvent",
"cancelCalendarDelete",
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.156.0-alpha",
"version": "3.157.0-alpha",
"license": "AGPL-3.0-or-later",
"type": "module",
"engines": {
Expand Down
8 changes: 7 additions & 1 deletion scripts/check-calendar-write-fence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const WEB_ROOT = "src";
export const WRITE_COMMANDS = [
["get_calendar_event_for_edit", "getCalendarEventForEdit"],
["update_calendar_event", "updateCalendarEvent"],
["respond_to_calendar_event", "respondToCalendarEvent"],
["create_calendar_event", "createCalendarEvent"],
["delete_calendar_event", "deleteCalendarEvent"],
["cancel_calendar_delete", "cancelCalendarDelete"],
Expand Down Expand Up @@ -320,7 +321,12 @@ export function scan(root) {
return { problems, rust: rust.length, web: web.length, modelPath: modelPath.length };
}

const sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
/** The same names, as sets: a name given twice can't stand in for one left out. */
const sameSet = (a, b) => {
const x = new Set(a);
const y = new Set(b);
return x.size === y.size && [...x].every((name) => y.has(name));
};

function main() {
const { problems, rust, web, modelPath } = scan(repoRoot);
Expand Down
16 changes: 13 additions & 3 deletions scripts/check-calendar-write-fence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,24 @@ describe("calendar write fence", () => {
});

it("checks its own lists against the code", () => {
// A new command in calendar_edit.rs that the fence doesn't list.
// A new command in calendar_edit.rs that the fence doesn't list. Its name is one no layer will
// add, so the example can't become a listed command (C14 critique C1-H3).
expect(
problems({
"src-tauri/src/commands/calendar_edit.rs":
WRITE_COMMANDS.map(([c]) => `#[tauri::command]\npub async fn ${c}() {}\n`).join("") +
`#[tauri::command]\npub async fn respond_to_calendar_event() {}\nmod google_io {}`,
`#[tauri::command]\npub async fn send_raw_calendar_write() {}\nmod google_io {}`,
}),
).toMatch(/defines the commands .*respond_to_calendar_event.* but this fence lists/);
).toMatch(/defines the commands .*send_raw_calendar_write.* but this fence lists/);
// A command defined twice can't stand in for a listed one that's missing (here the dev
// command): the lists are compared as sets, not by length.
expect(
problems({
"src-tauri/src/commands/calendar_edit.rs":
WRITE_COMMANDS.map(([c]) => `#[tauri::command]\npub async fn ${c}() {}\n`).join("") +
`#[tauri::command]\npub async fn ${WRITE_COMMANDS[0][0]}() {}\nmod google_io {}`,
}),
).toMatch(/defines the commands .* but this fence lists .*dev_series_report/);
// A plain `pub fn` the commands glob would re-export.
expect(
problems({
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.156.0-alpha"
version = "3.157.0-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
Expand Down
189 changes: 178 additions & 11 deletions src-tauri/src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ pub struct AgendaEvent {
#[serde(flatten)]
pub event: CalendarEvent,
pub ended: bool,
/// The user declined it on every copy that has their own guest-list entry, so the focus view
/// strikes it through, as Google Calendar draws a declined event (#884, C14b). Said here because
/// these rows carry no guest list.
pub declined: bool,
}

/// A calendar as returned by Google's `calendarList`, before PM's selection is applied.
Expand Down Expand Up @@ -2143,7 +2147,8 @@ fn agenda_query(
let mut stmt = conn.prepare(
"SELECT id, calendar_id, summary, description, location, start, end, all_day, html_link, uid, \
(all_day = 0 AND julianday(COALESCE(end, start)) < julianday('now')) AS ended, \
recurring, series_id, original_start \
recurring, series_id, original_start, attendees, \
(SELECT remote_id FROM calendars c WHERE c.id = calendar_events.calendar_id) \
FROM calendar_events \
WHERE (CASE WHEN all_day = 1 \
THEN date(COALESCE(end, date(start, '+1 day'))) > ?1 \
Expand All @@ -2157,11 +2162,15 @@ fn agenda_query(
let all_day: i64 = r.get(7)?;
let ended: i64 = r.get(10)?;
let recurring: i64 = r.get(11)?;
Ok(AgendaEvent {
let attendees: Option<String> = r.get(14)?;
let calendar: Option<String> = r.get(15)?;
let reply = own_declined(attendees.as_deref(), calendar.as_deref());
let row = AgendaEvent {
// The v40 detail fields (show_as/attendees/…) are only surfaced by the Calendar tab's
// event popup via `list_all_events`; the assistant/focus paths this query feeds don't use
// them, so they default to empty here rather than widening this hot query. The series
// facts are read: flag detection keys an occurrence by them (flags::occurrence_of).
// facts are read: flag detection keys an occurrence by them (flags::occurrence_of). The
// guest list is read only for the user's own reply, then dropped (`declined`).
event: CalendarEvent {
id: r.get(0)?,
calendar_id: r.get(1)?,
Expand All @@ -2179,7 +2188,9 @@ fn agenda_query(
..Default::default()
},
ended: ended != 0,
})
declined: false,
};
Ok((row, reply))
})?;
let collected = rows
.collect::<std::result::Result<Vec<_>, _>>()
Expand All @@ -2202,14 +2213,57 @@ fn agenda_query(
// the flag/briefing label joins and `milestones::calendar_dates_by_uid` each want ONE row per
// series ("a daily standup is one flag, not one per day"), which is a different question from
// "which occurrences are on the agenda".
let mut seen = std::collections::HashSet::new();
Ok(collected
.into_iter()
.filter(|a| match &a.event.uid {
Some(uid) if !uid.is_empty() => seen.insert((uid.clone(), a.event.start.clone())),
_ => true,
//
// The copies folded away still count for `declined` (#884, C14b): a kept row is declined only
// when at least one copy has the user's own guest entry and every such copy says they declined,
// as the Calendar tab strikes a merged row through. Google draws each calendar's copy apart; PM
// lists one, so a copy still going keeps it plain.
let mut at = std::collections::HashMap::new();
let mut kept: Vec<AgendaEvent> = Vec::new();
let mut replies: Vec<(bool, bool)> = Vec::new();
for (a, reply) in collected {
let fold = |(any, all): (bool, bool)| match reply {
Some(declined) => (true, all && declined),
None => (any, all),
};
if let Some(uid) = a.event.uid.as_deref().filter(|u| !u.is_empty()) {
let key = (uid.to_string(), a.event.start.clone());
if let Some(&i) = at.get(&key) {
replies[i] = fold(replies[i]);
continue;
}
at.insert(key, kept.len());
}
replies.push(fold((false, true)));
kept.push(a);
}
for (a, (any, all)) in kept.iter_mut().zip(replies) {
a.declined = any && all;
}
Ok(kept)
}

/// Whether the user declined a mirrored row: `Some(declined)` from the guest-list entry marked as
/// the calendar the row is on (Google's `self`, with an address; with two marked, the calendar's
/// own address first, else the first, as the editing side picks it), `None` without one. `attendees`
/// is the row's stored list, `calendar` its calendar's own id (a main calendar's is its address).
fn own_declined(attendees: Option<&str>, calendar: Option<&str>) -> Option<bool> {
fn address(a: &Attendee) -> Option<&str> {
a.email.as_deref().map(str::trim).filter(|e| !e.is_empty())
}
let list: Vec<Attendee> = serde_json::from_str(attendees?).ok()?;
let marked: Vec<&Attendee> = list
.iter()
.filter(|a| a.is_self && address(a).is_some())
.collect();
let own = calendar
.and_then(|c| {
marked
.iter()
.find(|a| address(a).is_some_and(|e| e.to_lowercase() == c.trim().to_lowercase()))
})
.collect())
.or(marked.first())?;
Some(own.response.as_deref() == Some("declined"))
}

/// Upcoming events under the strict "not yet ended" gate (chat preamble, project name-match, briefing,
Expand Down Expand Up @@ -3074,6 +3128,119 @@ mod tests {
assert!(matches!(rows[0].event.id.as_str(), "a1" | "a2"));
}

/// The focus agenda says which events the user declined (#884, C14b): by their own entry on the
/// guest list (marked as the calendar, with an address; the calendar's own address first), and
/// a row mirrored on two calendars only when every copy with an own entry declined.
#[test]
fn the_agenda_says_which_events_the_user_declined() {
let dir = tempfile::tempdir().unwrap();
let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap();
upsert_source(
&conn,
"gcal:me@x.com",
"google",
Some("me@x.com"),
"me@x.com",
)
.unwrap();
conn.execute_batch(
"INSERT INTO calendars (id, source_id, provider, remote_id, name) VALUES \
('cal-me', 'gcal:me@x.com', 'google', 'me@x.com', 'Me'), \
('cal-two', 'gcal:me@x.com', 'google', 'two@x.com', 'Two');",
)
.unwrap();
/// One copy of the occurrence on `day` (its iCal UID and start are the day's, so two copies
/// of one occurrence share them exactly, as every producer writes them).
fn put(conn: &Connection, id: &str, calendar: &str, day: i64, list: Option<&[String]>) {
let start: String = conn
.query_row(
"SELECT datetime(date('now'), ?1, '+12 hours')",
params![format!("+{day} days")],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO calendar_events (id, calendar_id, summary, start, end, all_day, uid, \
attendees) VALUES (?1, ?2, 'Meet', ?3, datetime(?3, '+30 minutes'), 0, ?4, ?5)",
params![
id,
calendar,
start,
format!("day-{day}"),
list.map(|l| format!("[{}]", l.join(",")))
],
)
.unwrap();
}
let guest = |email: &str, response: &str, own: bool| {
format!(r#"{{"email":"{email}","response":"{response}","self":{own}}}"#)
};
let me = |response: &str| guest("me@x.com", response, true);
let two = |response: &str| guest("two@x.com", response, true);
let other = |response: &str| guest("a@x.org", response, false);
// 1: declined, with another guest going: struck through.
put(
&conn,
"d",
"cal-me",
1,
Some(&[me("declined"), other("accepted")]),
);
// 2: another guest declined, the user going: plain.
put(
&conn,
"a",
"cal-me",
2,
Some(&[me("accepted"), other("declined")]),
);
// 3: declined on one copy, going on the other: plain.
put(&conn, "m1", "cal-me", 3, Some(&[me("declined")]));
put(&conn, "m2", "cal-two", 3, Some(&[two("accepted")]));
// 4: declined on both copies: struck through.
put(&conn, "b1", "cal-me", 4, Some(&[me("declined")]));
put(&conn, "b2", "cal-two", 4, Some(&[two("declined")]));
// 5: a copy with no entry of its own (kept, as it sorts first) doesn't count either way.
put(
&conn,
"c1",
"cal-two",
5,
Some(&[guest("me@x.com", "accepted", false)]),
);
put(&conn, "c2", "cal-me", 5, Some(&[me("declined")]));
// 6, 7: two entries marked (an alias): the calendar's own address in any case, else the
// first.
let alias = |second: &str| {
vec![
guest("Me+pm@x.com", "declined", true),
guest(second, "accepted", true),
]
};
put(&conn, "p1", "cal-me", 6, Some(&alias("ME@x.com")));
put(&conn, "p2", "cal-two", 7, Some(&alias("me@x.com")));
// 8, 9: marked without an address, or no guest list at all: nothing to say.
let no_address = String::from(r#"{"response":"declined","self":true}"#);
put(&conn, "n1", "cal-me", 8, Some(&[no_address]));
put(&conn, "n2", "cal-me", 9, None);
// 10: going on the copy kept, declined on the one folded away: plain (whichever comes last).
put(&conn, "q1", "cal-me", 10, Some(&[me("accepted")]));
put(&conn, "q2", "cal-two", 10, Some(&[two("declined")]));
let today: String = conn
.query_row("SELECT date('now')", [], |r| r.get(0))
.unwrap();

let rows = agenda_query(&conn, 21, 250, &today, "now").unwrap();
let declined: Vec<bool> = rows.iter().map(|a| a.declined).collect();
assert!(rows.len() == 10, "each occurrence once");
assert!(
declined == [true, false, false, true, true, false, true, false, false, false],
"declined by day"
);
// The guest list is read for the reply only: the rows still carry none.
assert!(rows.iter().all(|a| a.event.attendees.is_empty()));
}

/// A moved occurrence mirrored twice (Google's own copy, and its iCal feed's): the agenda always
/// keeps the copy that says its place in the series, with the series facts flag detection keys
/// the occurrence by (#884, F6), whichever copy was written first.
Expand Down
Loading
Loading