diff --git a/SYNC-SET.md b/SYNC-SET.md index b162acfd..ee2de2a6 100644 --- a/SYNC-SET.md +++ b/SYNC-SET.md @@ -45,7 +45,7 @@ Two rules that fall out of the classes: | `conversations` | truth | Chat titles and project scope. The turn text is authoritatively in the vault; this row is not recomputable from it alone. | | `messages` | truth | Bodies mirror the vault, but `citations`, `retrieved_chunk_ids` and `model` exist only here. | | `chat_sessions` | derived | Session↔document mapping, rolling summary, cursors, last prompt size. Re-derivable — but regenerating the summary is a **billable** model call. | -| `settings` | mixed | Key-namespaced grab-bag. Truth: user preferences (retrieval k, reranking, backup frequency/retention), the per-Google-account calendar editing choice (`calendar_editing`, keyed by account email; editing also needs the account's keychain token, so a restore onto another machine shows it as needing Google's permission until reconnected) **and the three webview blobs that hold user content** — `milestone_ui` (the per-project milestone sort, keyed by PROJECT NAME, plus the show-completed toggle), `calendar_ui` (the hidden-calendar set, whose ids are routinely the account's email address) and `backup_ui` (dismissed reconcile banners, keyed by destination + cloud account). Those three moved out of the webview's plaintext `localStorage` and are allowlisted to the webview in `settings.rs::WEBVIEW_PREFS`; they each get their own key because `project_ui` is rewritten whole on every divider drag. Truth too, briefly: `calendar_split_journal`, the record of a "this and following" calendar change from just before its first write until it's settled (Google calendar and event ids, which can hold the account's address, the original's repeat rules and start, and a plan digest; never a title or description). A `.pmbackup` carries it verbatim; PM reads Google again before acting on any entry, so a stale or restored one can only refuse a change until Google shows otherwise, and is never sent anywhere. Device: local model scan dir, external CLI paths, sync cursors, last-run timestamps, the On battery policy (`local_llm_power_threshold`, `local_llm_power_roles`, `local_llm_power_cloud_consent`) and `local_llm_battery_idle_minutes` — about this machine's battery and consent to bill this device's key; a `.pmbackup` still carries them verbatim. Derived: the briefing cache, layout caches, the retrieval config stamp, the filing seed vocabulary (`filing_seed_vocabulary` — machine-guessed from the unreviewed backlog, regenerable, and travels verbatim in a `.pmbackup`). **There is no key-prefix convention today** — see Open decisions. | +| `settings` | mixed | Key-namespaced grab-bag. Truth: user preferences (retrieval k, reranking, backup frequency/retention), the per-Google-account calendar editing choice (`calendar_editing`, keyed by account email; editing also needs the account's keychain token, so a restore onto another machine shows it as needing Google's permission until reconnected) **and the three webview blobs that hold user content** — `milestone_ui` (the per-project milestone sort, keyed by PROJECT NAME, plus the show-completed toggle), `calendar_ui` (the hidden-calendar set, whose ids are routinely the account's email address) and `backup_ui` (dismissed reconcile banners, keyed by destination + cloud account). Those three moved out of the webview's plaintext `localStorage` and are allowlisted to the webview in `settings.rs::WEBVIEW_PREFS`; they each get their own key because `project_ui` is rewritten whole on every divider drag. Truth too, briefly: `calendar_split_journal`, the record of a "this and following" calendar change from just before its first write until it's settled (Google calendar and event ids, which can hold the account's address, the original's repeat rules and start, and a plan digest; never a title or description). A `.pmbackup` carries it verbatim; PM reads Google again before acting on any entry, so a stale or restored one can only refuse a change until Google shows otherwise, and is never sent anywhere. Device: local model scan dir, external CLI paths, sync cursors, last-run timestamps, the On battery policy (`local_llm_power_threshold`, `local_llm_power_roles`, `local_llm_power_cloud_consent`) and `local_llm_battery_idle_minutes` — about this machine's battery and consent to bill this device's key; a `.pmbackup` still carries them verbatim. Derived: the briefing cache, layout caches, the retrieval config stamp, the filing seed vocabulary (`filing_seed_vocabulary` — machine-guessed from the unreviewed backlog, regenerable, and travels verbatim in a `.pmbackup`), `calendar_default_length`, each Google account's default event length (minutes), keyed by account email and read from Google at each sync (from C10b of #884; until then nothing writes it but a disconnect clearing it); a `.pmbackup` carries it verbatim. **There is no key-prefix convention today** — see Open decisions. | | `projects` | truth | Triage the user set: deadline, size, blocked-by, parent, importance, active date. | | `project_milestones` | truth | Multi-deadline project structure, incl. status and external-source anchoring. | | `project_activity` | truth | An emit-only historical record of what happened when. Nothing can recompute a past event. | diff --git a/eslint.config.js b/eslint.config.js index b7e249be..4b19a4ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,6 +36,7 @@ const CALENDAR_WRITES = { importNames: [ "getCalendarEventForEdit", "updateCalendarEvent", + "createCalendarEvent", "deleteCalendarEvent", "cancelCalendarDelete", "listHeldDeletes", diff --git a/package-lock.json b/package-lock.json index 17a26baf..646ebc3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.149.0-alpha", + "version": "3.149.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.149.0-alpha", + "version": "3.149.1-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index 928bac18..bf6f2651 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.149.0-alpha", + "version": "3.149.1-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/scripts/check-calendar-write-fence.mjs b/scripts/check-calendar-write-fence.mjs index 3219187a..ec2dfdf3 100644 --- a/scripts/check-calendar-write-fence.mjs +++ b/scripts/check-calendar-write-fence.mjs @@ -43,6 +43,7 @@ export const WEB_ROOT = "src"; export const WRITE_COMMANDS = [ ["get_calendar_event_for_edit", "getCalendarEventForEdit"], ["update_calendar_event", "updateCalendarEvent"], + ["create_calendar_event", "createCalendarEvent"], ["delete_calendar_event", "deleteCalendarEvent"], ["cancel_calendar_delete", "cancelCalendarDelete"], ["list_held_deletes", "listHeldDeletes"], diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c4c93002..b55d2532 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.149.0-alpha" +version = "3.149.1-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59a06aef..c10ddd4c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.149.0-alpha" +version = "3.149.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 2c94c86c..d0dc5308 100644 --- a/src-tauri/src/calendar.rs +++ b/src-tauri/src/calendar.rs @@ -262,6 +262,48 @@ fn primary_calendar_id(value: &serde_json::Value) -> Option { (!id.is_empty()).then(|| id.to_lowercase()) } +// --- each Google account's default event length (A28, C10) --- + +/// `settings` key: `{ "": minutes }`, Google's default length for a new +/// event on each account. The `calendar_editing` shape: one key for every account, so a change is +/// one read-modify-write under the DB lock. Read from Google at each sync from C10b; until then +/// nothing stores one, and a disconnect only clears it. +pub const DEFAULT_LENGTH_KEY: &str = "calendar_default_length"; + +/// Every account's stored default length. A value PM can't read is none at all: the editor then +/// uses an hour, as it would before the first sync. +pub fn default_lengths(conn: &Connection) -> Result> { + let Some(raw) = db::get_setting(conn, DEFAULT_LENGTH_KEY)? else { + return Ok(Default::default()); + }; + Ok(serde_json::from_str(&raw).unwrap_or_default()) +} + +/// Store (or with `None`, forget) one account's default length, under the caller's lock. Writes +/// only on a change, so a sync that reads the same value writes nothing; forgetting the last one +/// removes the key. +pub fn set_default_length(conn: &Connection, email: &str, minutes: Option) -> Result<()> { + let mut all = default_lengths(conn)?; + let key = email.trim().to_lowercase(); + let changed = match minutes { + Some(m) => all.insert(key, m) != Some(m), + None => all.remove(&key).is_some(), + }; + if !changed { + return Ok(()); + } + if all.is_empty() { + return db::delete_setting(conn, DEFAULT_LENGTH_KEY); + } + let json = serde_json::to_string(&all).map_err(|e| Error::Other(e.to_string()))?; + db::set_setting(conn, DEFAULT_LENGTH_KEY, &json) +} + +/// Forget every account's default length (every Google account went). +pub fn clear_default_lengths(conn: &Connection) -> Result<()> { + db::delete_setting(conn, DEFAULT_LENGTH_KEY) +} + /// Fetch events from one Google calendar within `[time_min, time_max]` (RFC3339), with recurring /// events expanded to single instances and ordered by start. `mirror_calendar_id` is the owning /// [`Calendar::id`] the events are stored under; `remote_id` is Google's own calendar id for the API @@ -3467,4 +3509,26 @@ mod tests { assert!(feed_provider(p).is_err(), "{p:?}"); } } + + #[test] + fn default_lengths_round_trip_and_none_removes_one() { + let dir = tempfile::tempdir().unwrap(); + let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap(); + assert!(default_lengths(&conn).unwrap().is_empty()); + set_default_length(&conn, "Me@Example.com", Some(30)).unwrap(); + set_default_length(&conn, "work@example.com", Some(45)).unwrap(); + let all = default_lengths(&conn).unwrap(); + assert_eq!(all.get("me@example.com"), Some(&30)); + assert_eq!(all.get("work@example.com"), Some(&45)); + set_default_length(&conn, "me@example.com", None).unwrap(); + assert_eq!(default_lengths(&conn).unwrap().len(), 1); + set_default_length(&conn, "work@example.com", None).unwrap(); + assert_eq!(db::get_setting(&conn, DEFAULT_LENGTH_KEY).unwrap(), None); + // A value PM can't read is none, never an error. + db::set_setting(&conn, DEFAULT_LENGTH_KEY, "not json").unwrap(); + assert!(default_lengths(&conn).unwrap().is_empty()); + set_default_length(&conn, "me@example.com", Some(20)).unwrap(); + clear_default_lengths(&conn).unwrap(); + assert!(default_lengths(&conn).unwrap().is_empty()); + } } diff --git a/src-tauri/src/calendar_write/dto.rs b/src-tauri/src/calendar_write/dto.rs index 702035a2..69dff243 100644 --- a/src-tauri/src/calendar_write/dto.rs +++ b/src-tauri/src/calendar_write/dto.rs @@ -101,6 +101,27 @@ pub struct EventPatchDraft { pub visibility: Option, } +/// 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 "". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct NewEventDraft { + #[serde(default)] + pub summary: String, + pub time: TimeDraft, + #[serde(default)] + pub location: String, + /// Plain text. + #[serde(default)] + pub description: String, + /// `None` is Google's default (busy). + #[serde(default)] + pub show_as: Option, + /// `None` or `Default` is the calendar's default, and isn't sent. + #[serde(default)] + pub visibility: Option, +} + /// What the user saw of an event when they chose to delete it, taken from the mirror row they /// clicked. A delete sends no fields, so this is what decides whether a version Google changed in the /// meantime may still be deleted ([`super::patch::delete_still_matches`]). @@ -214,6 +235,13 @@ pub enum WriteOutcome { Reauth, /// The answer never arrived and PM couldn't look afterwards: it may or may not have happened. Unconfirmed, + /// A new event whose answer never arrived, and PM couldn't tell whether Google made it. Calling + /// the create command again with `retry_token` looks for it first and sends it again, under the + /// same id, only if Google still doesn't show it (R2), so a second copy is very unlikely. PM's + /// own token, never the event's id (R5); it lapses after eight hours or a restart. + Ambiguous { + retry_token: String, + }, /// Anything else, with Google's message clipped to plain text. Failed { message: String, @@ -302,6 +330,8 @@ pub enum WriteWarning { MirrorRefreshPending, /// Some occurrences changed on their own couldn't be cancelled: `Saved.left_behind` says which. LeftOnOldSeries, + /// Saved in Google, but it starts outside the months PM mirrors, so PM's calendar won't show it. + OutsideMirror, } /// One event as the editor shows it, read from a fresh copy (never the mirror, which clips text). @@ -534,6 +564,19 @@ mod tests { (WriteOutcome::Busy, r#"{"outcome":"busy"}"#), (WriteOutcome::Reauth, r#"{"outcome":"reauth"}"#), (WriteOutcome::Unconfirmed, r#"{"outcome":"unconfirmed"}"#), + ( + WriteOutcome::Ambiguous { + retry_token: "ref-1".into(), + }, + r#"{"outcome":"ambiguous","retry_token":"ref-1"}"#, + ), + ( + WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + }, + r#"{"outcome":"saved","warnings":["outside_mirror"]}"#, + ), ( WriteOutcome::Failed { message: "Backend Error".into(), @@ -700,6 +743,38 @@ mod tests { ); } + /// What the editor sends for a new event: text and choices it leaves out take their defaults; + /// a time of a kind PM doesn't know is refused, never guessed. + #[test] + fn new_event_draft_reads_what_the_editor_sends() { + let full: NewEventDraft = serde_json::from_str( + 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"}"#, + ) + .unwrap(); + assert!(full.summary == "Dentist" && full.location == "High St"); + assert!(full.description == "Bring the form"); + assert!(matches!(full.time, TimeDraft::Timed { .. })); + assert_eq!(full.show_as, Some(ShowAs::Free)); + assert_eq!(full.visibility, Some(Visibility::Private)); + + let bare: NewEventDraft = serde_json::from_str( + r#"{"time":{"kind":"all_day","first_day":"2026-10-26","last_day":"2026-10-26"}}"#, + ) + .unwrap(); + assert!(bare.summary.is_empty() && bare.location.is_empty()); + assert!(bare.description.is_empty()); + assert_eq!((bare.show_as, bare.visibility), (None, None)); + + let unknown = serde_json::from_str::( + r#"{"summary":"x","time":{"kind":"floating","day":"2026-10-26"}}"#, + ); + assert!(unknown.is_err(), "an unknown kind of time is refused"); + assert!(serde_json::from_str::(r#"{"summary":"x"}"#).is_err()); + } + #[test] fn nothing_is_editable_under_a_blanket_reason() { let none = FieldPermissions::none(ReadOnlyReason::NotGoogle); diff --git a/src-tauri/src/calendar_write/gate.rs b/src-tauri/src/calendar_write/gate.rs index 045798ce..c252ef05 100644 --- a/src-tauri/src/calendar_write/gate.rs +++ b/src-tauri/src/calendar_write/gate.rs @@ -86,6 +86,29 @@ pub fn edit_block(facts: &EditFacts) -> Option { } } +/// Why PM may not create an event with `visibility` on a calendar, or `None` when it may: the account +/// and calendar rules of [`edit_rights`], for an event that would be a solo one-off you organise. +/// Q1: a `writerWithoutPrivateAccess` calendar takes every event but a private one. `google` holds +/// already: the caller resolved the calendar, and a feed or an Outlook calendar never gets here. +pub fn create_block( + editing: EditingStatus, + access_role: Option<&str>, + visibility: Option<&str>, +) -> Option { + blanket_reason(&EditFacts { + google: true, + editing, + access_role, + event_type: None, + organizer_self: true, + locked: false, + visibility, + series_master: false, + has_guests: false, + html_description: false, + }) +} + /// The reason nothing at all may change, checked account first, then calendar, then the event /// itself; the first one found is the one shown. fn blanket_reason(facts: &EditFacts) -> Option { @@ -340,6 +363,58 @@ mod tests { } } + /// Who may create where (C10): the account's editing first, then the calendar's role, with Q1's + /// one exception for a private event on a calendar that can't hold one. + #[test] + fn create_block_follows_q1() { + let on = EditingStatus::On; + let cases = [ + (on, Some("owner"), None, None), + (on, Some("owner"), Some("private"), None), + (on, Some("writer"), Some("confidential"), None), + (on, Some("writerWithoutPrivateAccess"), None, None), + ( + on, + Some("writerWithoutPrivateAccess"), + Some("default"), + None, + ), + (on, Some("writerWithoutPrivateAccess"), Some("public"), None), + ( + on, + Some("writerWithoutPrivateAccess"), + Some("private"), + Some(PrivateEvent), + ), + ( + on, + Some("writerWithoutPrivateAccess"), + Some("confidential"), + Some(PrivateEvent), + ), + (on, Some("reader"), None, Some(CalendarReadOnly)), + (on, Some("freeBusyReader"), None, Some(CalendarReadOnly)), + (on, None, None, Some(CalendarReadOnly)), + (on, Some("someNewRole"), None, Some(CalendarReadOnly)), + // Editing comes before the role: an account that can't write says so first. + (EditingStatus::Paused, Some("owner"), None, Some(EditingOff)), + ( + EditingStatus::NeedsConsent, + Some("owner"), + None, + Some(EditingOff), + ), + (EditingStatus::Off, Some("reader"), None, Some(EditingOff)), + ]; + for (editing, role, visibility, want) in cases { + assert_eq!( + create_block(editing, role, visibility), + want, + "{editing:?} {role:?} {visibility:?}" + ); + } + } + #[test] fn a_locked_copy_keeps_only_what_google_lets_it_change() { let perms = edit_rights(&EditFacts { diff --git a/src-tauri/src/calendar_write/patch.rs b/src-tauri/src/calendar_write/patch.rs index bcd4f30a..e40830a7 100644 --- a/src-tauri/src/calendar_write/patch.rs +++ b/src-tauri/src/calendar_write/patch.rs @@ -18,7 +18,7 @@ use serde_json::{Map, Value}; -use super::dto::{EventPatchDraft, SeenSummary}; +use super::dto::{EventPatchDraft, NewEventDraft, SeenSummary, Visibility}; use super::time::{self, TimeError}; /// The only fields a single-event save sends. @@ -32,6 +32,46 @@ 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; + +/// 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)?; + let mut body = Map::new(); + for (key, text) in [ + ("summary", &draft.summary), + ("location", &draft.location), + ("description", &draft.description), + ] { + // As typed, untrimmed, as a save sends it; only text that is all blank is left out. + if !text.trim().is_empty() { + body.insert(key.into(), Value::String(text.clone())); + } + } + body.insert("start".into(), when.start); + body.insert("end".into(), when.end); + if let Some(show_as) = draft.show_as { + body.insert( + "transparency".into(), + Value::String(show_as.as_transparency().into()), + ); + } + if let Some(visibility) = draft.visibility.filter(|v| *v != Visibility::Default) { + body.insert( + "visibility".into(), + Value::String(visibility.as_param().into()), + ); + } + Ok(body) +} + /// The PATCH body for `draft` against `fresh`, Google's current copy. Empty means Google already /// holds exactly this, and nothing should be sent. /// @@ -697,6 +737,134 @@ mod tests { ); } + // --- creating an event (C10) --- + + fn new_event(time: TimeDraft) -> NewEventDraft { + NewEventDraft { + summary: "Dentist".into(), + time, + location: "High St".into(), + description: "Bring the form\nand a pen".into(), + show_as: Some(ShowAs::Free), + visibility: Some(Visibility::Private), + } + } + + #[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", + ))) + .unwrap(); + let mut keys: Vec<&str> = body.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "description", + "end", + "location", + "start", + "summary", + "transparency", + "visibility" + ] + ); + assert!(keys.iter().all(|k| CREATABLE.contains(k))); + assert_eq!(body["summary"], "Dentist"); + assert_eq!(body["description"], "Bring the form\nand a pen"); + assert_eq!(body["transparency"], "transparent"); + assert_eq!(body["visibility"], "private"); + assert!(!body.contains_key("id"), "the insert request writes the id"); + } + + /// Blank text is left out, so Google stores no empty strings; text with words in is sent as + /// typed, spaces and all. + #[test] + fn an_empty_title_and_empty_text_are_left_out() { + let draft = NewEventDraft { + summary: " ".into(), + location: String::new(), + description: "\n ".into(), + ..new_event(timed("2026-10-26", "10:00", "2026-10-26", "11:00")) + }; + let body = insert_body(&draft).unwrap(); + for key in ["summary", "location", "description"] { + assert!(!body.contains_key(key), "{key}"); + } + let spaced = NewEventDraft { + summary: " Dentist ".into(), + ..draft + }; + assert_eq!(insert_body(&spaced).unwrap()["summary"], " Dentist "); + } + + /// A flight: each half carries its own zone and offset. + #[test] + fn a_timed_insert_names_its_zones() { + let body = insert_body(&new_event(TimeDraft::Timed { + start_date: "2026-10-26".into(), + start_time: "10:00".into(), + start_zone: "Europe/London".into(), + end_date: "2026-10-26".into(), + end_time: "13:00".into(), + end_zone: "America/New_York".into(), + })) + .unwrap(); + assert_eq!( + body["start"], + json!({ "dateTime": "2026-10-26T10:00:00+00:00", "timeZone": "Europe/London" }) + ); + assert_eq!( + body["end"], + json!({ "dateTime": "2026-10-26T13:00:00-04:00", "timeZone": "America/New_York" }) + ); + } + + #[test] + fn an_all_day_insert_ends_the_day_after() { + let body = insert_body(&new_event(TimeDraft::AllDay { + first_day: "2026-10-26".into(), + last_day: "2026-10-26".into(), + })) + .unwrap(); + assert_eq!(body["start"], json!({ "date": "2026-10-26" })); + assert_eq!(body["end"], json!({ "date": "2026-10-27" })); + } + + #[test] + fn a_gap_or_an_end_before_start_builds_nothing() { + let gap = insert_body(&new_event(timed( + "2026-03-29", + "01:30", + "2026-03-29", + "03:00", + ))); + assert!(matches!(gap, Err(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}"); + } + } + + #[test] + fn default_visibility_and_no_show_as_are_left_out() { + for visibility in [None, Some(Visibility::Default)] { + let body = insert_body(&NewEventDraft { + show_as: None, + visibility, + ..new_event(timed("2026-10-26", "10:00", "2026-10-26", "11:00")) + }) + .unwrap(); + assert!(!body.contains_key("transparency")); + assert!(!body.contains_key("visibility"), "{visibility:?}"); + } + } + #[test] fn formatted_descriptions_are_recognised() { for html in [ diff --git a/src-tauri/src/calendar_write/plan.rs b/src-tauri/src/calendar_write/plan.rs index 1afb53e5..e4984157 100644 --- a/src-tauri/src/calendar_write/plan.rs +++ b/src-tauri/src/calendar_write/plan.rs @@ -170,8 +170,9 @@ pub fn delete_event(calendar: &str, event: &str, etag: &str, notify: Notify) -> /// Create an event under `id`, an id PM chose ([`client_event_id`]) and wrote into the body here, /// so an insert can't go out without one. After an answer that never arrived or a server error, the -/// caller GETs that id before sending again (rule R2; Google warns it can't always catch a repeated -/// id at creation), so a retry can't make a second event. +/// caller GETs that id before sending again (rule R2). Google warns it can't always catch a repeated +/// id at creation, so a retry very rarely makes a second event, and the posts under one id are +/// capped. pub fn insert_event( calendar: &str, id: &str, diff --git a/src-tauri/src/calendar_write/series/plan.rs b/src-tauri/src/calendar_write/series/plan.rs index ffbef644..cf7465ef 100644 --- a/src-tauri/src/calendar_write/series/plan.rs +++ b/src-tauri/src/calendar_write/series/plan.rs @@ -70,7 +70,7 @@ use crate::calendar_recur::{rfc_instant, Point, RecurError, SeriesStart}; use super::super::dto::{EventPatchDraft, Notify, ReadOnlyReason, SeenSummary}; use super::super::patch::{ build_patch, delete_still_matches, description_is_html, drop_other_kind, is_gone, update_check, - when_eq, + when_eq, CREATABLE, }; use super::super::patch::{DeleteCheck, UpdateCheck}; use super::super::plan::{ @@ -204,9 +204,10 @@ pub enum Stage { /// The request a step sends. #[derive(Debug, Clone, PartialEq)] pub enum Op { - /// A new series under PM's own id, minted for this plan and kept for every retry of it: - /// re-sending the same id can't make a second series, and a 409 means it's there (DS5). A new - /// plan gets a new id. + /// A new event under PM's own id, minted for this plan and kept for every retry of it: + /// re-sending the same id normally can't make a second one (Google warns it can't always + /// catch a repeated id, so the sends stay few), and a 409 means it's there (DS5). A new plan + /// gets a new id. A split's new series, or a new event ([`SeriesPlan::create`]). Insert { calendar: String, id: String, @@ -392,6 +393,11 @@ pub enum Shows { /// GET that finds this mean it landed; a tail another plan left under a reused id reads as /// unknown, never as this one (C8b fix review). Its absence once proves nothing (DS5). Tail { of: String, plan: String }, + /// There at all, under PM's own id: a new event (C10). The id is 128 random bits, so a live copy + /// under it is this create's, whatever has been changed on it since, and a cancelled one is this + /// create's too, deleted in Google since: proof it isn't there to land (C10 critique 1 L11). None + /// proves nothing on its own (DS5). + Created, } /// The compensation for a step, sent with the step's own `notify`, on the etag of the step's reply. @@ -684,6 +690,10 @@ impl SeriesPlan { Applied::Yes } (Shows::Tail { .. }, _) => Applied::Unknown, + (Shows::Created, Some(v)) if !is_gone(v) => Applied::Yes, + // Made, then deleted in Google: the run reads it as gone ([`super::run`]). + (Shows::Created, Some(_)) => Applied::No, + (Shows::Created, None) => Applied::Unknown, (Shows::Fields { .. }, None) if step.gone_is_done => Applied::Yes, // The event itself is gone: whatever was sent can't be there. (Shows::Fields { .. }, None) => Applied::No, @@ -1428,6 +1438,43 @@ fn draft_fields(draft: &EventPatchDraft) -> Vec<(&'static str, Value)> { out } +impl SeriesPlan { + /// One new event under `id`, PM's own (`plan::client_event_id`), for creating an event (C10): + /// the insert step and nothing else, so a lost answer gets the A9 handling every insert gets + /// (look, wait, look, post again once under the same id; a 409 means look). `None` when the body + /// holds a key a create may not send (G5), the id isn't one path segment, or the body has no + /// start PM can read (never an invented one). + pub fn create(calendar: &str, id: &str, body: Map) -> Option { + if body.keys().any(|k| !CREATABLE.contains(&k.as_str())) || !is_plain_segment(id) { + return None; + } + let start = body.get("start").and_then(start_of)?; + Some(SeriesPlan { + scope: Scope::All, + steps: vec![Step { + op: Op::Insert { + calendar: calendar.into(), + id: id.into(), + body, + }, + // R7: a new event has no guests (C12), so nobody is emailed. + notify: Notify::None, + stage: Stage::Commit, + requires: None, + conflict: Recheck::Never, + shows: Shows::Created, + gone_is_done: false, + undo: None, + }], + warnings: Vec::new(), + expect: Vec::new(), + removes: Removes::Nothing, + start, + guests_allowed: false, + }) + } +} + /// An empty plan, for comparing values the way a plan does. fn probe(start: &SeriesStart) -> SeriesPlan { SeriesPlan { @@ -3949,4 +3996,95 @@ mod tests { None ); } + + // --- creating an event (C10) --- + + const NEW: &str = "fedcba9876543210fedcba9876543210"; + + /// What the editor sends for a new event, as the create command builds it. + fn new_body() -> Map { + super::super::super::patch::insert_body(&crate::calendar_write::dto::NewEventDraft { + summary: "Dentist".into(), + time: TimeDraft::Timed { + start_date: "2026-10-26".into(), + start_time: "10:00".into(), + start_zone: "Europe/London".into(), + end_date: "2026-10-26".into(), + end_time: "11:00".into(), + end_zone: "Europe/London".into(), + }, + location: String::new(), + description: String::new(), + show_as: Some(ShowAs::Busy), + visibility: None, + }) + .unwrap() + } + + #[test] + fn a_create_plan_is_one_commit_step_under_pms_id_with_no_one_notified() { + let plan = SeriesPlan::create(CALENDAR, NEW, new_body()).unwrap(); + assert_eq!(plan.steps.len(), 1); + let step = &plan.steps[0]; + assert!(matches!(&step.op, Op::Insert { calendar, id, body } + if calendar == CALENDAR && id == NEW && *body == new_body())); + assert_eq!(step.notify, Notify::None); + assert_eq!(step.stage, Stage::Commit); + assert!(step.requires.is_none() && step.undo.is_none() && !step.gone_is_done); + assert_eq!(step.conflict, Recheck::Never); + assert_eq!(step.shows, Shows::Created); + assert!(step.op.fits()); + let request = step.op.request(step.notify).unwrap(); + assert_eq!(request.method, Method::Insert); + assert!(request + .url + .ends_with("/events?sendUpdates=none&conferenceDataVersion=1")); + assert_eq!(request.body.unwrap()["id"], NEW); + assert!(plan.expect.is_empty() && plan.warnings.is_empty()); + assert_eq!(plan.removes, Removes::Nothing); + } + + /// 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. + #[test] + fn a_create_plan_refuses_a_key_a_create_may_not_send() { + for key in [ + "attendees", + "recurrence", + "extendedProperties", + "reminders", + "conferenceData", + "colorId", + "id", + ] { + let mut body = new_body(); + body.insert(key.into(), json!([])); + assert!(SeriesPlan::create(CALENDAR, NEW, body).is_none(), "{key}"); + } + let mut no_start = new_body(); + no_start.insert("start".into(), json!({ "dateTime": "soon" })); + assert!(SeriesPlan::create(CALENDAR, NEW, no_start).is_none()); + for id in ["", "..", "a\nb"] { + assert!( + SeriesPlan::create(CALENDAR, id, new_body()).is_none(), + "{id:?}" + ); + } + } + + /// 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] + fn created_reads_a_live_copy_as_landed_a_cancelled_one_as_not_and_none_as_unknown() { + let plan = SeriesPlan::create(CALENDAR, NEW, new_body()).unwrap(); + let mut copy = Value::Object(new_body()); + copy["id"] = json!(NEW); + copy["status"] = json!("confirmed"); + assert_eq!(plan.verdict(0, Some(©)), Applied::Yes); + let renamed = with(copy.clone(), "summary", json!("Dentist (moved)")); + assert_eq!(plan.verdict(0, Some(&renamed)), Applied::Yes); + let cancelled = with(copy, "status", json!("cancelled")); + assert_eq!(plan.verdict(0, Some(&cancelled)), Applied::No); + assert_eq!(plan.verdict(0, None), Applied::Unknown); + } } diff --git a/src-tauri/src/calendar_write/series/run.rs b/src-tauri/src/calendar_write/series/run.rs index 171da2c3..9d2575b8 100644 --- a/src-tauri/src/calendar_write/series/run.rs +++ b/src-tauri/src/calendar_write/series/run.rs @@ -37,8 +37,13 @@ //! as a rate limit on that retry, doesn't count, and leaves the once-more unspent), and If-Match //! lets at most one per etag apply. //! - **A lost insert** is looked for, looked for again after a second, and only then sent again under -//! the same id, which Google refuses as a duplicate if the first copy landed (plan amendment A9). +//! the same id, which Google normally refuses as a duplicate if the first copy landed (plan +//! amendment A9; it can't always catch a repeated id, so the posts under one id are capped). //! Its absence alone proves nothing. +//! - **Creating an event** is a one-insert plan ([`SeriesPlan::create`]) run the same way. A copy +//! found cancelled under PM's own id was made and deleted since: gone, not unknown. A retry of one +//! left unknown runs as [`Run::retry_insert`], in doubt from its first answer and never posted +//! again within the run (C10). //! //! CodeQL guardrail (DECISIONS, 2026-10-10): nothing here uses `Vec::remove`, `insert` or //! `swap_remove` on fetched data, or puts anything from a Google reply in a log or panic message. @@ -58,7 +63,7 @@ use super::super::plan::{ get_event, instances, is_plain_segment, patch_event, RequestPlan, VersionFlags, }; use super::original_of; -use super::plan::{Op, Recheck, Resend, SeriesPlan, Shows, Stage, Undo}; +use super::plan::{Op, Recheck, Resend, SeriesPlan, Shows, Stage, Step, Undo}; /// Google's answer to one request the machine asked for, as the command layer read it. #[derive(Debug, Clone, PartialEq)] @@ -298,6 +303,9 @@ pub struct Run { /// Steps still to undo, the last one first. undo: Vec, result: Option, + /// The run is a retry of a new event an earlier run sent ([`Run::retry_insert`]): its step + /// starts in doubt and already posted again. + retry: bool, } impl Run { @@ -307,6 +315,29 @@ impl Run { Run::from(plan, ends) } + /// Send a new event again that an earlier run left unknown and two looks since didn't find + /// (creating an event, C10): a plan of that one insert, under the same id. Its step starts in + /// doubt, since the earlier copy may still land, and as already posted again, so nothing this + /// copy meets proves the event isn't there: a 404 or a copy that never went out is looked for, + /// never read as "no calendar" or "nothing was sent", and it is never posted a further time + /// (C10 critiques 1 M1, 2 H1). A plan of anything else is [`Unfit`]. + pub fn retry_insert(plan: SeriesPlan) -> Result { + let one_insert = matches!( + plan.steps.as_slice(), + [Step { + op: Op::Insert { .. }, + stage: Stage::Commit, + .. + }] + ); + if !one_insert { + return Err(Unfit); + } + let mut run = Run::new(plan)?; + run.retry = true; + Ok(run) + } + /// "Finish" the split `ran` left partial, on the proof [`Settle`] found: an unknown cut Google /// holds ([`Settled::CutDone`]) runs the cleanup after it; an unknown insert whose new series is /// there ([`Settled::TailThere`]) runs the cut onward. Anything else is [`Unfit`], so no cut is @@ -379,6 +410,7 @@ impl Run { failed: None, undo: Vec::new(), result: None, + retry: false, }) } @@ -448,6 +480,12 @@ impl Run { } } self.copies = Copies::default(); + if self.retry { + // The copy an earlier run sent may land yet, and that run's own post again was its + // one (A9). + self.copies.in_doubt = true; + self.copies.reposted = true; + } if matches!(step.op, Op::Carry { .. }) { return Some(self.look(1)); } @@ -571,6 +609,9 @@ impl Run { return match body { Some(b) => match self.plan.verdict(self.at, Some(&b)) { Applied::Yes => Read::Done(Some(b), date), + // Only a new event's verdict is ever No for an insert: its own copy, under + // PM's id, deleted in Google (`Shows::Created`). + Applied::No if method == Method::Insert => Read::NotDone(Why::Gone), // This copy was applied, on the etag it went out on, and Google kept what was // there: it ignored the change. No earlier copy can land after it. Applied::No => Read::NotDone(Why::Ignored), @@ -665,6 +706,17 @@ impl Run { let Some(fresh) = fresh else { return Read::NotDone(Why::Gone); }; + if self.method() == Method::Insert { + // An insert has no etag, so nothing below applies. Only a new event's verdict is + // ever No: its own copy, under PM's 128-bit id, cancelled in Google since. It was + // made and then deleted, and the id is spent, so no copy can land after it + // (`Shows::Created`; C10 critique 1 L11). Anything else stays unknown. + return if is_gone(&fresh) { + Read::NotDone(Why::Gone) + } else { + Read::Unknown + }; + } // A copy was applied, yet Google shows what was there: changed back since? Don't guess. if self.copies.answered_ok { return Read::Unknown; @@ -682,11 +734,6 @@ impl Run { Read::Again(etag) }; } - if self.method() == Method::Insert { - // An insert has no etag, so nothing above applies; a step that shows what was there - // can't be an insert (its verdict is never No). - return Read::Unknown; - } // Google is at an etag no copy went out on: none of them can land. match self.plan.resend(self.at, &fresh) { Resend::Proceed if self.copies.moved_on => Read::NotDone(Why::Conflict(Vec::new())), @@ -703,11 +750,18 @@ impl Run { verdict: Verdict::Ok, body: Some(b), .. - } if self.plan.verdict(self.at, Some(&b)) == Applied::Yes => { - self.apply(Read::Done(Some(b), None)) + } => { + let read = match self.plan.verdict(self.at, Some(&b)) { + Applied::Yes => Read::Done(Some(b), None), + // Its own copy, cancelled in Google since: gone, as on the first look (C10 + // critique 1 L11). + Applied::No => self.not_landed(Some(b)), + Applied::Unknown => Read::Unknown, + }; + self.apply(read) } // Still not there after a pause: post it again under the same id (A9). If the first - // copy landed after all, Google refuses this one as a duplicate. + // copy landed after all, Google normally refuses this one as a duplicate. Answer::Reply { verdict: Verdict::Gone, .. @@ -2679,6 +2733,370 @@ mod tests { ); } + // --- Run: creating an event (C10) --- + + const NEW: &str = "fedcba9876543210fedcba9876543210"; + + /// A new event's plan: Dentist on Monday 26-10, 10:00 to 11:00 in London, under [`NEW`]. + fn create_plan() -> SeriesPlan { + let body = + crate::calendar_write::patch::insert_body(&crate::calendar_write::dto::NewEventDraft { + summary: "Dentist".into(), + time: crate::calendar_write::dto::TimeDraft::Timed { + start_date: "2026-10-26".into(), + start_time: "10:00".into(), + start_zone: "Europe/London".into(), + end_date: "2026-10-26".into(), + end_time: "11:00".into(), + end_zone: "Europe/London".into(), + }, + location: String::new(), + description: String::new(), + show_as: None, + visibility: None, + }) + .unwrap(); + SeriesPlan::create(CALENDAR, NEW, body).unwrap() + } + + /// Google's copy of the new event. + fn new_copy(plan: &SeriesPlan) -> Value { + let Op::Insert { body, id, .. } = &plan.steps[0].op else { + panic!("not a create"); + }; + let mut v = Value::Object(body.clone()); + v["id"] = json!(id); + v["etag"] = json!("\"n1\""); + v["status"] = json!("confirmed"); + v + } + + fn posts(sent: &[RequestPlan]) -> Vec<&RequestPlan> { + sent.iter().filter(|r| r.method == Method::Insert).collect() + } + + #[test] + fn a_create_answered_2xx_is_done() { + let plan = create_plan(); + let copy = new_copy(&plan); + let (ran, sent, waits) = drive(&mut Run::new(plan).unwrap(), |_| ok(copy.clone())); + assert!(matches!(&ran, Ran::Done { ends } + if matches!(ends.as_slice(), [End::Done { reply: Some(r), .. }] if *r == copy))); + assert_eq!(sent.len(), 1); + assert!(waits.is_empty()); + // A 2xx PM can't read is looked up under the id. + let no_body = Answer::Reply { + verdict: Verdict::Ok, + body: None, + date: None, + maybe_applied: false, + }; + let (ran, sent, _) = drive(&mut Run::new(create_plan()).unwrap(), |r| match r.method { + Method::Insert => no_body.clone(), + _ => ok(copy.clone()), + }); + assert!(matches!(ran, Ran::Done { .. })); + assert!(sent[1].method == Method::Get && to(&sent[1], NEW)); + } + + #[test] + fn a_lost_create_found_on_the_first_look_is_done_with_one_post() { + let plan = create_plan(); + let copy = new_copy(&plan); + let (ran, sent, waits) = drive(&mut Run::new(plan).unwrap(), |r| match r.method { + Method::Insert => lost(), + _ => ok(copy.clone()), + }); + assert!(matches!(ran, Ran::Done { .. })); + assert_eq!(posts(&sent).len(), 1); + assert!(sent[1].method == Method::Get && to(&sent[1], NEW)); + assert!(waits.is_empty()); + } + + /// A9: look, wait a second, look, then post the very same request once more. + #[test] + fn a_lost_create_absent_twice_is_posted_again_once_under_the_same_id() { + let plan = create_plan(); + let copy = new_copy(&plan); + let mut n = 0; + let (ran, sent, waits) = drive(&mut Run::new(plan).unwrap(), |r| match r.method { + Method::Insert => { + n += 1; + if n == 1 { + lost() + } else { + ok(copy.clone()) + } + } + _ => status(Verdict::Gone), + }); + assert!(matches!(ran, Ran::Done { .. })); + assert_eq!(waits, vec![Duration::from_secs(1)]); + let methods: Vec = sent.iter().map(|r| r.method).collect(); + assert_eq!( + methods, + [Method::Insert, Method::Get, Method::Get, Method::Insert] + ); + assert_eq!(sent[0], sent[3]); + } + + #[test] + fn a_reposted_create_answered_409_is_looked_up_and_done() { + let plan = create_plan(); + let copy = new_copy(&plan); + let (mut posted, mut looked) = (0, 0); + let (ran, sent, _) = drive(&mut Run::new(plan).unwrap(), |r| match r.method { + Method::Insert => { + posted += 1; + if posted == 1 { + lost() + } else { + status(Verdict::Duplicate) + } + } + _ => { + looked += 1; + if looked <= 2 { + status(Verdict::Gone) + } else { + ok(copy.clone()) + } + } + }); + assert!(matches!(ran, Ran::Done { .. })); + assert_eq!(posts(&sent).len(), 2); + assert_eq!(sent.last().map(|r| r.method), Some(Method::Get)); + } + + #[test] + fn a_create_whose_repost_is_lost_is_unknown() { + let (ran, sent, _) = drive(&mut Run::new(create_plan()).unwrap(), |r| match r.method { + Method::Insert => lost(), + _ => status(Verdict::Gone), + }); + assert!(matches!(ran, Ran::Unknown { step: 0, .. })); + assert_eq!(posts(&sent).len(), 2); + } + + #[test] + fn a_first_create_404_is_refused_not_looked_for() { + let (ran, sent, waits) = drive(&mut Run::new(create_plan()).unwrap(), |_| { + status(Verdict::Gone) + }); + assert!(matches!( + ran, + Ran::NotDone { + why: Why::Refused(Verdict::Gone), + .. + } + )); + assert_eq!(sent.len(), 1); + assert!(waits.is_empty()); + } + + /// A server error may have been applied: never a second POST before a look. + #[test] + fn a_create_5xx_is_looked_for_before_any_second_post() { + let plan = create_plan(); + let copy = new_copy(&plan); + for answer in [ + after_5xx(Verdict::ServerError), + status(Verdict::ServerError), + ] { + let (ran, sent, _) = drive(&mut Run::new(plan.clone()).unwrap(), |r| match r.method { + Method::Insert => answer.clone(), + _ => ok(copy.clone()), + }); + assert!(matches!(ran, Ran::Done { .. })); + assert_eq!(sent.len(), 2); + assert_eq!(sent[1].method, Method::Get); + } + } + + /// C10 critique 1 L11: a copy under PM's id that Google holds cancelled was made, then deleted + /// in Google. The run says gone, as the retry does, never unknown. + #[test] + fn a_cancelled_copy_of_a_new_event_is_gone() { + let plan = create_plan(); + let cancelled = with(new_copy(&plan), "status", json!("cancelled")); + for first in [lost(), status(Verdict::Duplicate)] { + let (ran, sent, _) = drive(&mut Run::new(plan.clone()).unwrap(), |r| match r.method { + Method::Insert => first.clone(), + _ => ok(cancelled.clone()), + }); + assert!(matches!(ran, Ran::NotDone { why: Why::Gone, .. })); + assert_eq!(posts(&sent).len(), 1); + } + // On the second look too, after a first that found nothing: gone, never posted again. + let mut looks = 0; + let (ran, sent, _) = drive(&mut Run::new(plan.clone()).unwrap(), |r| match r.method { + Method::Insert => lost(), + _ => { + looks += 1; + if looks == 1 { + status(Verdict::Gone) + } else { + ok(cancelled.clone()) + } + } + }); + assert!(matches!(ran, Ran::NotDone { why: Why::Gone, .. })); + assert_eq!((posts(&sent).len(), looks), (1, 2)); + let (ran, _, _) = drive(&mut Run::new(plan).unwrap(), |_| ok(cancelled.clone())); + assert!(matches!(ran, Ran::NotDone { why: Why::Gone, .. })); + } + + /// Every sequence of answers to depth five (two POSTs, three looks): at most two POSTs, each + /// the same request under PM's id, nobody emailed and no Meet of Google's own; done only once + /// Google showed the new event; gone only once it showed it cancelled; not done otherwise only + /// on a first answer that proves nothing went (unsent) or a 404 to the first POST; unknown + /// never once Google showed a copy, live or cancelled. + #[test] + fn every_create_answer_sequence_sends_at_most_two_posts_under_one_id() { + let plan = create_plan(); + let copy = new_copy(&plan); + let cancelled = with(copy.clone(), "status", json!("cancelled")); + let post_answers = [ + ok(copy.clone()), + status(Verdict::Duplicate), + status(Verdict::Gone), + after_5xx(Verdict::ServerError), + lost(), + Answer::Unsent(Unsent::Offline), + ]; + let look_answers = [ + ok(copy.clone()), + ok(cancelled.clone()), + status(Verdict::Gone), + lost(), + ]; + let shows = + |a: &Answer, v: &Value| matches!(a, Answer::Reply { body: Some(b), .. } if b == v); + let pick = |n: usize, i: usize, base: usize| (n / base.pow(i as u32)) % base; + let mut seen = (0, 0, 0, 0); + for p in 0..post_answers.len().pow(2) { + for g in 0..look_answers.len().pow(3) { + let (mut pi, mut gi) = (0, 0); + let (mut live, mut gone) = (false, false); + let (ran, sent, _) = drive(&mut Run::new(plan.clone()).unwrap(), |r| { + let a = match r.method { + Method::Insert => { + pi += 1; + post_answers[pick(p, pi - 1, post_answers.len())].clone() + } + _ => { + gi += 1; + assert!(gi <= 3, "{p}/{g}: a fourth look"); + look_answers[pick(g, gi - 1, look_answers.len())].clone() + } + }; + live |= shows(&a, ©); + gone |= shows(&a, &cancelled); + a + }); + let inserts = posts(&sent); + assert!(inserts.len() <= 2, "{p}/{g}: {} posts", inserts.len()); + assert!(inserts.iter().all(|r| **r == *inserts[0]), "{p}/{g}"); + let first = inserts[0]; + assert!( + first.url.contains("sendUpdates=none") + && first.url.contains("conferenceDataVersion=1") + && first.body.as_ref().is_some_and(|b| b["id"] == NEW), + "{p}/{g}" + ); + let first_answer = &post_answers[pick(p, 0, post_answers.len())]; + let proof_first = matches!( + first_answer, + Answer::Unsent(_) + | Answer::Reply { + verdict: Verdict::Gone, + .. + } + ); + match &ran { + Ran::Done { .. } => { + seen.0 += 1; + assert!(live, "{p}/{g}: done unseen"); + } + Ran::NotDone { why: Why::Gone, .. } => { + seen.1 += 1; + assert!(gone, "{p}/{g}: gone unseen"); + } + Ran::NotDone { .. } => { + seen.2 += 1; + assert!(proof_first && sent.len() == 1, "{p}/{g}"); + } + Ran::Unknown { .. } => { + seen.3 += 1; + // A copy Google showed, live or cancelled, always settles it (C10 + // critique 1 L11): on the first look or the second. + assert!(!proof_first && !live && !gone, "{p}/{g}"); + } + } + } + } + assert!(seen.0 > 0 && seen.1 > 0 && seen.2 > 0 && seen.3 > 0); + } + + /// A retry of a new event (C10 critiques 1 M1, 2 H1): the earlier copy may still land, so + /// nothing this copy meets proves it isn't there. One POST at most, and every end but a copy + /// Google showed (live, or cancelled) is unknown. + #[test] + fn a_retried_create_starts_in_doubt_and_posts_once_at_most() { + let plan = create_plan(); + let copy = new_copy(&plan); + let cancelled = with(copy.clone(), "status", json!("cancelled")); + let post_answers = [ + ok(copy.clone()), + status(Verdict::Duplicate), + status(Verdict::Gone), + status(Verdict::ReadOnly), + status(Verdict::Reauth), + after_5xx(Verdict::ServerError), + lost(), + Answer::Unsent(Unsent::Offline), + Answer::Unsent(Unsent::SignIn), + ]; + let look_answers = [ + ok(copy.clone()), + ok(cancelled.clone()), + status(Verdict::Gone), + lost(), + ]; + let shows = + |a: &Answer, v: &Value| matches!(a, Answer::Reply { body: Some(b), .. } if b == v); + for post in &post_answers { + for look in &look_answers { + let (mut live, mut gone) = (shows(post, ©), false); + let (ran, sent, waits) = + drive(&mut Run::retry_insert(plan.clone()).unwrap(), |r| { + match r.method { + Method::Insert => post.clone(), + _ => { + live |= shows(look, ©); + gone |= shows(look, &cancelled); + look.clone() + } + } + }); + assert_eq!(posts(&sent).len(), 1); + assert!(waits.is_empty(), "a retry never waits to post again"); + assert!(sent.len() <= 2); + match &ran { + Ran::Done { .. } => assert!(live), + Ran::NotDone { why: Why::Gone, .. } => assert!(gone), + Ran::NotDone { .. } => panic!("a retry read something as proof of not done"), + Ran::Unknown { .. } => assert!(!live && !gone), + } + } + } + // Only a plan of one insert is retried this way. + let (_, patch) = this_case(); + assert_eq!(Run::retry_insert(patch).err(), Some(Unfit)); + let (_, split) = split_case(Vec::new()); + assert_eq!(Run::retry_insert(split).err(), Some(Unfit)); + } + // --- Run: deletes and cancels --- #[test] diff --git a/src-tauri/src/calendar_write/time.rs b/src-tauri/src/calendar_write/time.rs index 9a161219..3363f430 100644 --- a/src-tauri/src/calendar_write/time.rs +++ b/src-tauri/src/calendar_write/time.rs @@ -127,10 +127,6 @@ pub struct GoogleWhen { /// A time draft as Google's `start` and `end`, for a new event. A timed event keeps its own /// zone(s): `dateTime` with that zone's offset, plus `timeZone`, so Google shows it as the user set it /// and a repeating event follows that zone's daylight saving. -#[cfg_attr( - not(test), - expect(dead_code, reason = "first caller lands in C10, creating events") -)] pub fn resolve(draft: &TimeDraft) -> Result { resolve_against(draft, None, None) } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index d584daf2..2dbb8aeb 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -15,6 +15,11 @@ //! 6. Google's reply into the mirror (never the draft), remembered so a sync that started earlier //! can't put the old version back, and the briefing told to look again. //! +//! Creating an event (C10) has nothing to read first: it goes out under an id PM chose, and an +//! answer that never arrives is looked for under that id before anything is sent again (A9), so a +//! dropped connection very rarely makes a second copy (Google can't always catch a repeated id, +//! which is why the posts under one id are capped, [`MAX_POSTS`]). +//! //! Every outcome comes back as data (`WriteOutcome`), so the editor keeps the draft and explains. //! Nothing here is reachable from a model: no model call has tools, the commands refuse any window //! but the main one, and `scripts/check-calendar-write-fence.mjs` keeps the command names and @@ -34,12 +39,12 @@ use crate::calendar_editing::{self, EditingStatus}; use crate::calendar_recur::lines::{self, Applied}; use crate::calendar_recur::spec::{day_phrase, describe_lines}; use crate::calendar_recur::Point; -use crate::calendar_write::classify::Verdict; +use crate::calendar_write::classify::{Method, Verdict}; use crate::calendar_write::dto::{ ChoiceRequest, DeleteSettled, DeleteStart, EditLoad, EventForEdit, EventPatchDraft, - FieldPermissions, HeldDeleteInfo, Notify, ReadOnlyReason, ScopeRow, SeenSummary, SeriesChoices, - SeriesInfo, SeriesScope, SeriesWarningDto, ShowAs, SplitAction, TimeDraft, Visibility, - WriteOutcome, WriteWarning, + FieldPermissions, HeldDeleteInfo, NewEventDraft, Notify, ReadOnlyReason, ScopeRow, SeenSummary, + SeriesChoices, SeriesInfo, SeriesScope, SeriesWarningDto, ShowAs, SplitAction, TimeDraft, + Visibility, WriteOutcome, WriteWarning, }; use crate::calendar_write::gate::{self, EditFacts}; use crate::calendar_write::patch::{self, DeleteCheck, UpdateCheck}; @@ -70,8 +75,8 @@ use super::shared::require_main_window; use super::vaults::require_vault_owner; /// What editing keeps in memory, in [`AppState`]. Lock order everywhere: the DB, then `recent`; -/// `partials`, `splitting` and `in_flight` are each taken alone, never with the DB or another of these -/// held. +/// `partials`, `splitting`, `creates` and `in_flight` are each taken alone, never with the DB or +/// another of these held. #[derive(Default)] pub struct CalendarEditState { /// Saves of the last ten minutes, laid over each sync's fetch (rule R4). @@ -88,6 +93,11 @@ pub struct CalendarEditState { /// The new parts of the "this and following" changes running now, a send or a settle, by /// [`series_key`]: their journal records are theirs, no orphans. splitting: Mutex>, + /// New events whose answer never arrived, by the retry token PM gave the webview (R5): what to + /// look for and, if it's still not there, send again under the same id. Lapses after + /// `SESSION_TTL`; lost on quit, which loses nothing (a create that landed shows at the next + /// sync, and one that didn't is simply absent: a create has no half to leave behind). + creates: Mutex>, /// One calendar sync at a time, so a fetch that started earlier can never finish after a later /// one has settled a save (plan A23). A second caller waits, then runs; the change check /// ([`super::check_google_calendars`]) never waits, it skips. @@ -163,6 +173,51 @@ struct Target { facts: CalendarFacts, } +/// Where a PM calendar lives at Google, from the DB alone: an iCal feed or an Outlook calendar comes +/// back as `NotGoogle`, never a target. +#[derive(Clone, Debug, PartialEq)] +struct CalendarTarget { + calendar_id: String, + remote_calendar: String, + email: String, + token_key: String, + facts: CalendarFacts, + /// Synced into the mirror (`calendars.selected`): a write to one that isn't lands nothing + /// (`calendar::apply_write_effect`). + selected: bool, +} + +/// Resolve PM's calendar `calendar_id` to its Google calendar. `missing`: the words for a calendar +/// that isn't in PM any more, each caller its own (an event's calendar, or the one chosen). +fn resolve_calendar( + conn: &Connection, + calendar_id: &str, + missing: &str, +) -> Result> { + let Some(cal) = calendar::calendar_by_id(conn, calendar_id)? else { + return Err(Error::Other(missing.into())); + }; + let email = match (cal.provider.as_str(), cal.source_id.strip_prefix("gcal:")) { + ("google", Some(email)) => email.to_string(), + _ => return Ok(Err(ReadOnlyReason::NotGoogle)), + }; + let remote_calendar = cal.remote_id.clone().unwrap_or_else(|| cal.id.clone()); + if !is_plain_segment(&remote_calendar) { + return Ok(Err(ReadOnlyReason::NotGoogle)); + } + let Some(token_key) = secrets::token_key_for("google", "calendar", &email) else { + return Ok(Err(ReadOnlyReason::NotGoogle)); + }; + Ok(Ok(CalendarTarget { + calendar_id: cal.id, + remote_calendar, + email, + token_key, + facts: cal.facts, + selected: cal.selected, + })) +} + /// Resolve a mirror row to its Google calendar and event. A row from an iCal feed or an Outlook /// calendar isn't Google OAuth, so it comes back as that reason rather than a target. fn resolve_target( @@ -174,14 +229,13 @@ fn resolve_target( "That event is no longer in PM's calendar. Refresh and try again.".into(), )); }; - let Some(cal) = calendar::calendar_by_id(conn, &row.calendar_id)? else { - return Err(Error::Other( - "That event's calendar is no longer connected.".into(), - )); - }; - let email = match (cal.provider.as_str(), cal.source_id.strip_prefix("gcal:")) { - ("google", Some(email)) => email.to_string(), - _ => return Ok(Err(ReadOnlyReason::NotGoogle)), + let cal = match resolve_calendar( + conn, + &row.calendar_id, + "That event's calendar is no longer connected.", + )? { + Ok(cal) => cal, + Err(reason) => return Ok(Err(reason)), }; let Some(remote_event) = row .id @@ -190,20 +244,13 @@ fn resolve_target( else { return Ok(Err(ReadOnlyReason::NotGoogle)); }; - let remote_calendar = cal.remote_id.clone().unwrap_or_else(|| cal.id.clone()); - if !is_plain_segment(&remote_calendar) { - return Ok(Err(ReadOnlyReason::NotGoogle)); - } - let Some(token_key) = secrets::token_key_for("google", "calendar", &email) else { - return Ok(Err(ReadOnlyReason::NotGoogle)); - }; Ok(Ok(Target { row_id: row.id.clone(), calendar_id: row.calendar_id.clone(), - remote_calendar, + remote_calendar: cal.remote_calendar, remote_event: remote_event.to_string(), - email, - token_key, + email: cal.email, + token_key: cal.token_key, facts: cal.facts, })) } @@ -800,6 +847,563 @@ async fn save_event( WriteOutcome::Conflict { fields: Vec::new() } } +// --- creating an event (C10) --- + +/// C10a only: release builds refuse creates until the UI ships (C10b). +const CREATE_NOT_YET: &str = "Creating events from PM isn't available yet."; +/// A calendar PM doesn't sync: a new event there would land nothing in the mirror. +const NOT_SYNCED: &str = "PM doesn't sync this calendar, so it won't create events in it. Choose \ + it in Settings › Connectors first."; +/// A retry into a calendar PM stopped syncing after the first send, which may have landed: the +/// token stays, so choosing the calendar again lets Check again finish it. +const CREATE_NOT_SYNCED: &str = "PM doesn't sync this calendar any more, so it won't send this \ + new event again. PM may already have made it: choose the \ + calendar in Settings › Connectors and Check again, or look for \ + it in Google Calendar before creating it again, so it isn't made \ + twice."; +/// The retry token is unknown: PM restarted, or the eight hours passed. A constant, the title left +/// to the webview's notice, so the webview can tell it apart (it ends the token, C10 critiques). +const CREATE_LOST: &str = "PM lost touch with Google and can't tell whether this new event was \ + made. Look for it in Google Calendar before creating it again, so it \ + isn't made twice."; +/// A retry naming another calendar, or a draft other than the one sent (C10 critique 2 M2). The +/// webview drops its token on this, so the words can't offer a Check again; the earlier event may +/// be there all the same. +const CREATE_CHANGED: &str = "This differs from the new event PM was creating when it lost touch \ + with Google, so PM didn't send it. PM may already have made the \ + earlier one: look for it in Google Calendar before creating it \ + again, so it isn't made twice."; +/// The new event went to Google [`MAX_POSTS`] times without a readable answer (C10 critique 1 M5). +const CREATE_LATER: &str = "PM has already sent this new event to Google more than once without \ + hearing back, so it won't send it again. Check again later: PM looks \ + for it first. If it still isn't there, look for it in Google Calendar \ + before creating it again, so it isn't made twice."; + +/// The most POSTs one new event's id gets, across its first run and every retry: the first and +/// two more. A run posts twice at most (A9) and a retry once, so the bound is what stops "Check +/// again" from sending it for ever (Google warns it can't always catch a repeated id). +const MAX_POSTS: usize = 3; + +/// The pause between a retry's two looks for a new event: the same second `run.rs` waits before a +/// lost insert's second look (`LOOK_AGAIN`), kept here so that one stays private. +const RETRY_PAUSE: Duration = Duration::from_secs(1); + +/// Create an event on one of the user's Google calendars, under an id PM chose. `calendar_id` is +/// PM's calendar row id; `draft` is what the editor holds; `retry_token` names a create whose +/// answer never arrived (from an `Ambiguous` outcome), which is looked for before anything is sent +/// again (R2). Nobody is emailed: a new event has no guests (R7). +#[tauri::command] +pub async fn create_calendar_event( + app: AppHandle, + window: tauri::Window, + calendar_id: String, + draft: NewEventDraft, + retry_token: Option, +) -> Result { + require_main_window(&window)?; + require_owner(&app)?; + // C10a only (§16): no UI creates yet, so release builds refuse; C10b removes this. + if !cfg!(debug_assertions) { + return Ok(WriteOutcome::Failed { + message: CREATE_NOT_YET.into(), + }); + } + let state = app.state::(); + let target = match resolve_calendar( + &*state.conn()?, + &calendar_id, + "That calendar is no longer connected.", + )? { + Ok(target) => target, + Err(reason) => return Ok(WriteOutcome::ReadOnly { reason }), + }; + #[cfg(debug_assertions)] + { + // A create reaches one new event, never a series. + let allowed = dev_allow::check(&*state.conn()?, &target.remote_calendar, false)?; + if let Err(message) = allowed { + return Ok(WriteOutcome::Failed { + message: message.into(), + }); + } + } + let editing_choice = calendar_editing::choice(&*state.conn()?, &target.email)?; + // A keychain read, with no lock held. + let editing = editing_status(editing_choice, &target.token_key); + let token_key = target.token_key.clone(); + let ctx = CreateWrite { + wire: &GoogleWire(&token_key), + host: &app, + target: &target, + editing, + new_id: plan::client_event_id, + new_ref: new_create_ref, + }; + Ok(create_event(&ctx, draft, retry_token.as_deref()).await) +} + +/// What one create works with: Google through `wire`, the mirror and the held creates through +/// `host`, the calendar, the account's editing status, and the two ids PM mints: the new event's +/// ([`plan::client_event_id`]) and the retry token ([`new_create_ref`]), fixed ones in the tests. +struct CreateWrite<'a, W, H> { + wire: &'a W, + host: &'a H, + target: &'a CalendarTarget, + editing: EditingStatus, + new_id: fn() -> String, + new_ref: fn() -> String, +} + +/// A retry token: PM's own random id, a separate one from the event's, so the webview never holds +/// or learns the id PM chose for Google (R5). +fn new_create_ref() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +/// A held create's key in the claims: never a row id (those start with PM's calendar id) or a +/// [`series_key`]. +fn create_key(token: &str) -> String { + format!("create\u{1f}{token}") +} + +/// A new event whose answer never arrived, held under its retry token. +#[derive(Clone)] +struct HeldCreate { + /// Where it was sent: the retry must name the same calendar. + target: CalendarTarget, + /// The one-step plan, PM's id inside it. + plan: SeriesPlan, + /// What was sent, for telling a retry of the same event from a changed one. + body: serde_json::Map, + /// POSTs under its id so far, the first run's and every retry's ([`MAX_POSTS`]). + posts: usize, + /// When it was last sent, for [`SESSION_TTL`]. + at: Instant, +} + +impl CalendarEditState { + fn hold_create(&self, token: &str, held: HeldCreate) { + if let Ok(mut creates) = self.creates.lock() { + creates.insert(token.to_string(), held); + } + } + + /// Let held creates sent [`SESSION_TTL`] or more before `now` go. + fn prune_creates(&self, now: Instant) { + if let Ok(mut creates) = self.creates.lock() { + creates.retain(|_, held| now.saturating_duration_since(held.at) < SESSION_TTL); + } + } +} + +/// A held create taken out for a retry: held again under its token when this goes, unless it was +/// consumed (a landing, or Google showing it deleted), a panic or an early return included, so a +/// retry that stops partway never loses it (the [`TakenSplit`] precedent; C10 critique 1 M1). +struct TakenCreate<'a> { + edit: &'a CalendarEditState, + token: String, + back: Option, +} + +impl<'a> TakenCreate<'a> { + /// Take the create out, so a second retry of it finds nothing: the guard, and a copy to work on. + /// Only a token PM still holds; the map's key is PM's own, never fetched data. + fn take(edit: &'a CalendarEditState, token: &str) -> Option<(Self, HeldCreate)> { + let held = edit.creates.lock().ok()?.remove(token)?; + let taken = TakenCreate { + edit, + token: token.to_string(), + back: Some(held.clone()), + }; + Some((taken, held)) + } + + /// What goes back: `held`, as the retry left it. + fn keep(&mut self, held: HeldCreate) { + self.back = Some(held); + } + + /// Nothing goes back: Google showed the new event, or showed it deleted. + fn consume(&mut self) { + self.back = None; + } +} + +impl Drop for TakenCreate<'_> { + fn drop(&mut self) { + if let Some(held) = self.back.take() { + self.edit.hold_create(&self.token, held); + } + } +} + +/// [`create_calendar_event`] past its shell: Google through `ctx.wire`, the mirror and the held +/// creates through `ctx.host`. +async fn create_event( + ctx: &CreateWrite<'_, W, H>, + draft: NewEventDraft, + retry_token: Option<&str>, +) -> WriteOutcome { + let body = match patch::insert_body(&draft) { + Ok(body) => body, + Err(e) => { + return WriteOutcome::Failed { + message: e.to_string(), + } + } + }; + ctx.host.edit().prune_creates(Instant::now()); + if let Some(token) = retry_token { + return retry_create(ctx, token, body).await; + } + if !ctx.target.selected { + return WriteOutcome::Failed { + message: NOT_SYNCED.into(), + }; + } + if let Some(reason) = create_blocked(ctx, &body) { + return WriteOutcome::ReadOnly { reason }; + } + let id = (ctx.new_id)(); + let Some(plan) = SeriesPlan::create(&ctx.target.remote_calendar, &id, body.clone()) else { + return WriteOutcome::Failed { + message: UNFIT.into(), + }; + }; + let Ok(mut run) = Run::new(plan.clone()) else { + return WriteOutcome::Failed { + message: UNFIT.into(), + }; + }; + let wire = Posts::new(ctx.wire); + match drive(&wire, &mut run).await { + Ran::Done { ends } => landed_create(ctx.host, ctx.target, &ends), + // Gone: made, then deleted in Google before PM could look. + Ran::NotDone { why, .. } => not_done_outcome(why, ctx.editing, false), + Ran::Unknown { .. } => { + let token = (ctx.new_ref)(); + ctx.host.edit().hold_create( + &token, + HeldCreate { + target: ctx.target.clone(), + plan, + body, + posts: wire.count(), + at: Instant::now(), + }, + ); + WriteOutcome::Ambiguous { retry_token: token } + } + } +} + +/// Why the account and calendar can't take the new event `body`: the gate's rules for a solo +/// one-off you organise ([`gate::create_block`]), on the calendar's role as the DB has it. A role +/// Google lowered since gets its own 403, read by [`outcome_for`]. +fn create_blocked( + ctx: &CreateWrite<'_, W, H>, + body: &serde_json::Map, +) -> Option { + gate::create_block( + ctx.editing, + ctx.target.facts.access_role.as_deref(), + body.get("visibility").and_then(Value::as_str), + ) +} + +/// A look a retry makes for its new event, read off the GET. No `Debug`: it holds Google's copy, +/// which never goes into a message. +enum RetryLook { + Live(Value), + /// There, cancelled: made, then deleted in Google. + Cancelled, + /// 404 or 410: no copy yet, which proves nothing on its own (DS5). + Absent, + Reauth, + Busy, + /// Offline, a server error, an answer PM can't read, a refused read. + Unknown, +} + +impl RetryLook { + fn of(fetched: Fetched) -> RetryLook { + match fetched { + Fetched::Event(v) if patch::is_gone(&v) => RetryLook::Cancelled, + Fetched::Event(v) => RetryLook::Live(v), + Fetched::Gone => RetryLook::Absent, + Fetched::Reauth => RetryLook::Reauth, + Fetched::Busy => RetryLook::Busy, + Fetched::Failed(_) => RetryLook::Unknown, + } + } +} + +/// Why a retry stops with the create still held. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kept { + Reauth, + Busy, + Ambiguous, +} + +impl Kept { + fn outcome(self, token: &str) -> WriteOutcome { + match self { + Kept::Reauth => WriteOutcome::Reauth, + Kept::Busy => WriteOutcome::Busy, + Kept::Ambiguous => WriteOutcome::Ambiguous { + retry_token: token.to_string(), + }, + } + } +} + +/// What a retry does after one look. +enum RetryStep { + /// It's there: land it. + Land(Value), + /// Made, then deleted in Google. + Gone, + /// Not there yet: wait [`RETRY_PAUSE`] and look again. + LookAgain, + /// Not there after two looks: send it again under the same id. + Send, + /// Stop, the create held as it was. + Keep(Kept), +} + +/// A retry's next step after `look`, `first` saying whether it was the first of its two looks. +/// Google's insert-then-get isn't guaranteed consistent, so one 404 proves nothing (DS5): only two, +/// a second apart, let the same request go again, and the same id normally makes that safe (a copy +/// that landed meanwhile turns it into a 409, which the run then looks up; Google can't always catch +/// a repeated id, hence [`MAX_POSTS`]). +fn retry_next(look: RetryLook, first: bool) -> RetryStep { + match look { + RetryLook::Live(copy) => RetryStep::Land(copy), + RetryLook::Cancelled => RetryStep::Gone, + RetryLook::Absent if first => RetryStep::LookAgain, + RetryLook::Absent => RetryStep::Send, + RetryLook::Reauth => RetryStep::Keep(Kept::Reauth), + RetryLook::Busy => RetryStep::Keep(Kept::Busy), + RetryLook::Unknown => RetryStep::Keep(Kept::Ambiguous), + } +} + +/// A retry of the create `token` named: look for it under its id first, and only after looking +/// twice and not finding it, send it again under the same id (A9). Never with a changed calendar or +/// body: a copy that landed holds what was first sent, and a retry can't edit it. The looks are +/// reads, so the account's editing and the calendar's role are asked only before a re-send (C10 +/// critique 1 M1). Every end but Google showing the event, or showing it deleted, keeps it held +/// under the same token. +async fn retry_create( + ctx: &CreateWrite<'_, W, H>, + token: &str, + body: serde_json::Map, +) -> WriteOutcome { + let edit = ctx.host.edit(); + // Claim before take, so a second retry of the same token is Busy and never finds it missing. + let Some(_claim) = InFlight::claim(edit, &create_key(token)) else { + return WriteOutcome::Busy; + }; + let Some((mut taken, held)) = TakenCreate::take(edit, token) else { + return WriteOutcome::Failed { + message: CREATE_LOST.into(), + }; + }; + if held.target.calendar_id != ctx.target.calendar_id || held.body != body { + return WriteOutcome::Failed { + message: CREATE_CHANGED.into(), + }; + } + let Some(Op::Insert { calendar, id, .. }) = held.plan.steps.first().map(|s| &s.op) else { + return WriteOutcome::Failed { + message: UNFIT.into(), + }; + }; + let mut first = true; + loop { + match retry_next(RetryLook::of(get(ctx.wire, calendar, id).await), first) { + RetryStep::Land(copy) => { + taken.consume(); + return land_created(ctx.host, &held.target, ©); + } + RetryStep::Gone => { + taken.consume(); + return WriteOutcome::Gone; + } + RetryStep::LookAgain => { + ctx.wire.wait(RETRY_PAUSE).await; + first = false; + } + RetryStep::Send => break, + RetryStep::Keep(kept) => return kept.outcome(token), + } + } + if held.posts >= MAX_POSTS { + return WriteOutcome::Failed { + message: CREATE_LATER.into(), + }; + } + // A re-send meets the refusals a first send does: a calendar PM stopped syncing since would + // land nothing. The create stays held, so a look can still find it; the words say the first + // send may have landed. + if !ctx.target.selected { + return WriteOutcome::Failed { + message: CREATE_NOT_SYNCED.into(), + }; + } + if let Some(reason) = create_blocked(ctx, &held.body) { + return WriteOutcome::ReadOnly { reason }; + } + let Ok(mut run) = Run::retry_insert(held.plan.clone()) else { + return WriteOutcome::Failed { + message: UNFIT.into(), + }; + }; + let wire = Posts::new(ctx.wire); + let ran = drive(&wire, &mut run).await; + match ran { + Ran::Done { ends } => { + taken.consume(); + landed_create(ctx.host, &held.target, &ends) + } + Ran::NotDone { why: Why::Gone, .. } => { + taken.consume(); + WriteOutcome::Gone + } + ran => { + // Sent again: counted, and its eight hours start over. + taken.keep(HeldCreate { + posts: held.posts + wire.count(), + at: Instant::now(), + ..held + }); + let kept = match ran { + Ran::NotDone { + why: Why::Refused(Verdict::Reauth) | Why::Unsent(Unsent::SignIn), + .. + } => Kept::Reauth, + Ran::NotDone { + why: Why::Refused(Verdict::RateLimited), + .. + } => Kept::Busy, + // A retry starts in doubt (`Run::retry_insert`), so nothing it meets is proof the + // first copy isn't there. + _ => Kept::Ambiguous, + }; + kept.outcome(token) + } + } +} + +/// 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 { + match ends.first() { + Some(End::Done { + reply: Some(copy), .. + }) => land_created(host, target, copy), + // Proved without a copy to show: under `Shows::Created` every proof carries one, but the + // next sync shows it anyway. + _ => created_pending(host, target), + } +} + +/// 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 { + let Some(row) = calendar::parse_event(&target.calendar_id, copy) else { + return created_pending(host, target); + }; + let window = host + .mirror(&target.calendar_id) + .ok() + .and_then(|m| m.instants()); + if in_mirror(&row, window) == Some(false) { + return WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + }; + } + let id = row.id.clone(); + match land_on(host, &target.calendar_id, vec![row], &[id], Vec::new()) { + Ok(()) => WriteOutcome::Saved { + warnings: Vec::new(), + left_behind: Vec::new(), + }, + Err(_) => created_pending(host, target), + } +} + +/// Created in Google, not mirrored yet: the next sync shows it. +fn created_pending(host: &impl Host, target: &CalendarTarget) -> WriteOutcome { + drop_hash(host, &target.calendar_id); + WriteOutcome::Saved { + warnings: vec![WriteWarning::MirrorRefreshPending], + left_behind: Vec::new(), + } +} + +/// Whether a mirror row overlaps the window PM syncs (`[start, end)`, as instants): what the next +/// sync will fetch. Landing one outside it would show it until the created entry's ten minutes ran +/// out, then the next complete sync would drop it, so it isn't landed (C10 §6.2). `None` when the +/// row's times or the window can't be read, which lands it. All-day dates read as UTC midnight, +/// while Google bounds them in the calendar's zone: at the window's very edges a day can read a few +/// hours off, which at worst skips one that the next sync shows, or lands one it then drops. Never +/// a wrong write (C10 critique 1 L9). +fn in_mirror( + row: &CalendarEvent, + window: Option<(chrono::DateTime, chrono::DateTime)>, +) -> Option { + let (from, until) = window?; + let instant = |s: &str| -> Option> { + if let Ok(at) = chrono::DateTime::parse_from_rfc3339(s.trim()) { + return Some(at.with_timezone(&chrono::Utc)); + } + chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") + .ok() + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .map(|t| t.and_utc()) + }; + let start = instant(&row.start)?; + let end = match row.end.as_deref() { + Some(end) => instant(end)?, + None => start, + }; + Some(start < until && end > from) +} + +/// A wire that counts the POSTs it passes on: the new event's copies, for [`MAX_POSTS`]. +struct Posts<'w, W> { + wire: &'w W, + count: std::sync::atomic::AtomicUsize, +} + +impl<'w, W: Wire> Posts<'w, W> { + fn new(wire: &'w W) -> Self { + Posts { + wire, + count: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn count(&self) -> usize { + self.count.load(std::sync::atomic::Ordering::SeqCst) + } +} + +impl Wire for Posts<'_, W> { + fn send(&self, plan: &RequestPlan) -> impl std::future::Future + Send { + if plan.method == Method::Insert { + self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + self.wire.send(plan) + } + + fn wait(&self, pause: Duration) -> impl std::future::Future + Send { + self.wire.wait(pause) + } +} + /// Delete an event after an Undo window (plan Q2). The delete is held here in the backend, so it /// outlives the Calendar tab unmounting, and `cancel_calendar_delete` within `UNDO` keeps the event. /// When the hold ends the delete goes out only if what the user saw (`seen`, from the row they @@ -3558,7 +4162,9 @@ fn still_held(edit: &CalendarEditState, token: &str, outcome: WriteOutcome) -> W | WriteOutcome::NoChange | WriteOutcome::Gone | WriteOutcome::IgnoredByGoogle { .. } - | WriteOutcome::SplitRemoved => return outcome, + | WriteOutcome::SplitRemoved + // Only creating an event answers this; a settle never does. + | WriteOutcome::Ambiguous { .. } => return outcome, }; let Some((offers, from)) = edit.held_split(token) else { return outcome; @@ -12689,4 +13295,977 @@ mod tests { )); assert!(dev_allow::permits(None, None, &to("home@group"))); } + + // --- creating an event (C10) --- + + /// The id PM picks for the new event in these tests. + const NEW: &str = "fedcba9876543210fedcba9876543210"; + + /// Dentist on Monday 26-10, 10:00 to 11:00 in London: inside the tests' mirror window. + fn dentist_draft() -> NewEventDraft { + on_day("2026-10-26") + } + + fn on_day(day: &str) -> NewEventDraft { + NewEventDraft { + summary: "Dentist".into(), + time: TimeDraft::Timed { + start_date: day.into(), + start_time: "10:00".into(), + start_zone: "Europe/London".into(), + end_date: day.into(), + end_time: "11:00".into(), + end_zone: "Europe/London".into(), + }, + location: String::new(), + description: String::new(), + show_as: None, + visibility: None, + } + } + + /// The tests' calendar "cal" ([`CALENDAR`] at Google), as the create command resolves it. + fn new_target() -> CalendarTarget { + CalendarTarget { + calendar_id: "cal".into(), + remote_calendar: CALENDAR.into(), + email: "me@example.com".into(), + token_key: "key".into(), + facts: CalendarFacts { + access_role: Some("owner".into()), + time_zone: Some("Europe/London".into()), + ..Default::default() + }, + selected: true, + } + } + + /// A create through `script` against `host`, editing as `editing` says, the new event under + /// [`NEW`] and its retry token "ref-1". + fn creator<'a>( + script: &'a Script, + host: &'a TestHost, + target: &'a CalendarTarget, + editing: EditingStatus, + ) -> CreateWrite<'a, Script, TestHost> { + CreateWrite { + wire: script, + host, + target, + editing, + new_id: || NEW.to_string(), + new_ref: || "ref-1".to_string(), + } + } + + /// Google's copy of the new event `draft` makes, at `etag`. + fn google_copy(draft: &NewEventDraft, etag: &str) -> Value { + let mut v = Value::Object(patch::insert_body(draft).unwrap()); + v["kind"] = json!("calendar#event"); + v["id"] = json!(NEW); + v["etag"] = json!(etag); + v["status"] = json!("confirmed"); + v["iCalUID"] = json!("new@google.com"); + v["updated"] = json!(SAVED_AT); + v + } + + fn new_read(answer: Scripted) -> Step { + event_read(NEW, answer) + } + + fn absent() -> Scripted { + Scripted::Status(404) + } + + /// Create `draft` (or retry `token`) on `target` through `steps`, which must all be asked for. + async fn create_on( + host: &TestHost, + target: &CalendarTarget, + editing: EditingStatus, + steps: Vec, + draft: NewEventDraft, + token: Option<&str>, + ) -> (WriteOutcome, Script) { + let script = Script::new(steps); + let outcome = create_event(&creator(&script, host, target, editing), draft, token).await; + script.asked_all(); + (outcome, script) + } + + /// The dentist created on "cal", editing on. + async fn create( + host: &TestHost, + steps: Vec, + token: Option<&str>, + ) -> (WriteOutcome, Script) { + create_on( + host, + &new_target(), + EditingStatus::On, + steps, + dentist_draft(), + token, + ) + .await + } + + /// A create whose answer is lost and whose event two looks don't find; posted again under + /// the same id, that answer is lost too: held as "ref-1", two POSTs spent. + async fn lost_create(host: &TestHost) -> WriteOutcome { + let (outcome, _) = create( + host, + vec![ + insert_step(lost()), + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ], + None, + ) + .await; + outcome + } + + fn held_creates(host: &TestHost) -> usize { + host.edit.creates.lock().unwrap().len() + } + + fn held_posts(host: &TestHost, token: &str) -> Option { + host.edit + .creates + .lock() + .unwrap() + .get(token) + .map(|h| h.posts) + } + + /// Say the create `token` names was last sent `at`, to age it without waiting. + fn set_held_at(host: &TestHost, token: &str, at: Instant) { + if let Some(held) = host.edit.creates.lock().unwrap().get_mut(token) { + held.at = at; + } + } + + fn new_row(host: &TestHost) -> Option { + host.with_conn(|conn| calendar::event_by_id(conn, &format!("cal:{NEW}"))) + .unwrap() + } + + fn ambiguous(token: &str) -> WriteOutcome { + WriteOutcome::Ambiguous { + retry_token: token.into(), + } + } + + fn failed(message: &str) -> WriteOutcome { + WriteOutcome::Failed { + message: message.into(), + } + } + + fn inserts(script: &Script) -> Vec { + script + .sent() + .into_iter() + .filter(|r| r.method == Method::Insert) + .collect() + } + + #[tokio::test] + async fn a_create_posts_under_pms_id_with_send_updates_none_and_lands_as_created() { + let host = TestHost::holding(&[]); + let copy = google_copy(&dentist_draft(), "\"n1\""); + let (outcome, script) = create(&host, vec![insert_step(Scripted::Json(copy))], None).await; + assert!(outcome == saved(), "the new event is saved"); + let sent = script.sent(); + assert_eq!(sent.len(), 1); + assert!(sent[0].url.ends_with(&format!( + "/calendars/{CALENDAR}/events?sendUpdates=none&conferenceDataVersion=1" + ))); + let body = sent[0].body.as_ref().unwrap().as_object().unwrap(); + assert_eq!(body["id"], NEW); + assert!(body + .keys() + .all(|k| k == "id" || patch::CREATABLE.contains(&k.as_str()))); + let row = new_row(&host).expect("the new event is in the mirror"); + assert!(row.summary == "Dentist" && row.start == "2026-10-26T10:00:00Z"); + assert_eq!(host.told(), vec!["cal".to_string()]); + // A complete list that doesn't show it yet keeps it on screen: it was recorded as new. + let shown = host.synced(&[], Instant::now()); + assert!(shown.iter().any(|r| r.id == format!("cal:{NEW}"))); + assert_eq!(held_creates(&host), 0); + } + + #[tokio::test] + async fn a_create_sends_no_guests_reminders_conference_or_notes() { + let host = TestHost::holding(&[]); + let draft = NewEventDraft { + location: "High St".into(), + description: "Bring the form".into(), + show_as: Some(ShowAs::Free), + visibility: Some(Visibility::Private), + ..dentist_draft() + }; + 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 == saved(), "saved"); + let body = script.sent()[0].body.clone().unwrap(); + for never in [ + "attendees", + "reminders", + "conferenceData", + "extendedProperties", + "recurrence", + "colorId", + ] { + assert!(body.get(never).is_none(), "{never}"); + } + assert_eq!(body["transparency"], "transparent"); + assert_eq!(body["visibility"], "private"); + } + + #[tokio::test] + async fn a_create_the_account_or_calendar_cant_take_sends_nothing() { + let host = TestHost::holding(&[]); + let with_role = |role: &str| CalendarTarget { + facts: CalendarFacts { + access_role: Some(role.into()), + ..Default::default() + }, + ..new_target() + }; + let private = NewEventDraft { + visibility: Some(Visibility::Private), + ..dentist_draft() + }; + let cases = [ + ( + with_role("reader"), + EditingStatus::On, + dentist_draft(), + ReadOnlyReason::CalendarReadOnly, + ), + ( + with_role("writerWithoutPrivateAccess"), + EditingStatus::On, + private, + ReadOnlyReason::PrivateEvent, + ), + ( + new_target(), + EditingStatus::Off, + dentist_draft(), + ReadOnlyReason::EditingOff, + ), + ( + new_target(), + EditingStatus::NeedsConsent, + dentist_draft(), + ReadOnlyReason::EditingOff, + ), + ]; + for (target, editing, draft, reason) in cases { + let (outcome, _) = create_on(&host, &target, editing, Vec::new(), draft, None).await; + assert!( + outcome == WriteOutcome::ReadOnly { reason }, + "refused with its reason, nothing sent" + ); + } + // A calendar PM doesn't sync lands nothing, so it takes no creates. + let unsynced = CalendarTarget { + selected: false, + ..new_target() + }; + let (outcome, _) = create_on( + &host, + &unsynced, + EditingStatus::On, + Vec::new(), + dentist_draft(), + None, + ) + .await; + assert!( + outcome == failed(NOT_SYNCED), + "an unsynced calendar is refused" + ); + // A time PM can't save builds nothing to send. + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + Vec::new(), + on_day("2026-02-30"), + None, + ) + .await; + assert!(matches!(outcome, WriteOutcome::Failed { .. })); + assert_eq!(held_creates(&host), 0); + } + + /// R10: a 403 to a create, with editing on, is the calendar refusing; never a consent loop. + #[tokio::test] + async fn a_403_to_a_create_reads_as_the_calendar_or_editing() { + let host = TestHost::holding(&[]); + let forbidden = |reason: &str| Scripted::Reply { + status: 403, + body: Some(json!({ "error": { "errors": [{ "reason": reason }] } })), + date: None, + maybe_applied: false, + }; + for reason in ["requiredAccessLevel", "insufficientPermissions"] { + let (outcome, script) = create(&host, vec![insert_step(forbidden(reason))], None).await; + assert!( + outcome + == WriteOutcome::ReadOnly { + reason: ReadOnlyReason::CalendarReadOnly + }, + "{reason}: the calendar's" + ); + assert_eq!(script.sent().len(), 1); + } + // Without the write scope, the same refusal asks for editing to be turned on again. + assert!( + not_done_outcome( + Why::Refused(Verdict::InsufficientScope), + EditingStatus::NeedsConsent, + false + ) == WriteOutcome::ReadOnly { + reason: ReadOnlyReason::EditingOff + }, + "editing's" + ); + assert_eq!(held_creates(&host), 0); + } + + #[tokio::test] + async fn a_404_create_says_the_calendar_isnt_there() { + let host = TestHost::holding(&[]); + let (outcome, script) = create(&host, vec![insert_step(absent())], None).await; + assert!(outcome == failed(NO_CALENDAR), "no calendar"); + assert_eq!(script.sent().len(), 1); + assert!(script.waits().is_empty()); + assert_eq!(held_creates(&host), 0); + } + + #[tokio::test] + async fn an_unsent_create_sends_nothing_more() { + let host = TestHost::holding(&[]); + for (why, want) in [ + (Unsent::SignIn, WriteOutcome::Reauth), + (Unsent::Offline, failed(OFFLINE)), + (Unsent::Local, failed(NOT_SENT)), + ] { + let (outcome, script) = + create(&host, vec![insert_step(Scripted::Unsent(why))], None).await; + assert!(outcome == want, "nothing was sent, and it says so"); + assert_eq!(script.sent().len(), 1); + } + assert_eq!(held_creates(&host), 0); + assert!(new_row(&host).is_none()); + } + + /// Google made it, but its copy isn't a row PM can mirror: saved, and the next sync shows it. + #[tokio::test] + async fn a_create_reply_pm_cant_parse_is_saved_pending() { + let host = TestHost::holding(&[]); + let unreadable = Scripted::Reply { + status: 200, + body: None, + date: None, + maybe_applied: false, + }; + let bare = json!({ "id": NEW, "status": "confirmed", "etag": "\"n1\"" }); + let (outcome, script) = create( + &host, + vec![insert_step(unreadable), new_read(Scripted::Json(bare))], + None, + ) + .await; + assert!(outcome == pending(), "saved, the mirror to catch up"); + assert_eq!(inserts(&script).len(), 1); + assert!(host.hash_dropped()); + assert!(new_row(&host).is_none()); + } + + #[tokio::test] + async fn a_create_outside_the_mirror_is_saved_but_not_landed() { + let host = TestHost::holding(&[]); + let draft = on_day("2028-01-10"); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![insert_step(Scripted::Json(copy))], + draft, + None, + ) + .await; + assert!( + outcome + == WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + }, + "saved in Google, and said to be outside" + ); + assert!(new_row(&host).is_none()); + assert!(host.nothing_recorded()); + assert!(host.told().is_empty()); + } + + /// R5: the webview gets PM's own token, never the id PM chose for Google. + #[tokio::test] + async fn a_lost_create_is_held_under_a_retry_token_never_the_event_id() { + let host = TestHost::holding(&[]); + let outcome = lost_create(&host).await; + assert!(outcome == ambiguous("ref-1"), "held as ref-1"); + let said = serde_json::to_string(&outcome).unwrap(); + assert!( + !said.contains(NEW), + "the event's id never reaches the webview" + ); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + assert!(new_row(&host).is_none()); + } + + #[tokio::test] + async fn a_retry_finds_the_event_and_lands_it_without_posting() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let copy = google_copy(&dentist_draft(), "\"n1\""); + let (outcome, script) = + create(&host, vec![new_read(Scripted::Json(copy))], Some("ref-1")).await; + assert!(outcome == saved(), "found and landed"); + script.finished(); + assert_eq!(held_creates(&host), 0); + assert!(new_row(&host).is_some()); + } + + #[tokio::test] + async fn a_retry_finding_nothing_twice_posts_the_same_id_once() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let copy = google_copy(&dentist_draft(), "\"n1\""); + let (outcome, script) = create( + &host, + vec![ + new_read(absent()), + new_read(absent()), + insert_step(Scripted::Json(copy)), + ], + Some("ref-1"), + ) + .await; + assert!(outcome == saved(), "sent again, and landed"); + assert_eq!(script.waits(), vec![RETRY_PAUSE]); + let posts = inserts(&script); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].body.as_ref().unwrap()["id"], NEW); + assert_eq!(held_creates(&host), 0); + assert!(new_row(&host).is_some()); + } + + /// Made, then deleted in Google: gone, the token with it, nothing landed. + #[tokio::test] + async fn a_retry_finding_it_cancelled_says_gone() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let cancelled = with( + google_copy(&dentist_draft(), "\"n2\""), + "status", + json!("cancelled"), + ); + let (outcome, script) = create( + &host, + vec![new_read(Scripted::Json(cancelled))], + Some("ref-1"), + ) + .await; + assert!(outcome == WriteOutcome::Gone, "gone"); + script.finished(); + assert_eq!(held_creates(&host), 0); + assert!(new_row(&host).is_none()); + } + + #[tokio::test] + async fn a_retry_whose_look_is_lost_keeps_the_token() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + for (look, want) in [ + (lost(), ambiguous("ref-1")), + (Scripted::Status(503), ambiguous("ref-1")), + (Scripted::Status(401), WriteOutcome::Reauth), + (Scripted::Status(429), WriteOutcome::Busy), + ] { + let (outcome, script) = create(&host, vec![new_read(look)], Some("ref-1")).await; + assert!(outcome == want, "kept, and said why"); + script.finished(); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + } + } + + /// The re-send's answer lost too: held again under the same token, never a new one. + #[tokio::test] + async fn a_retry_that_is_unknown_again_keeps_the_same_token() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let script = Script::new(vec![ + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ]); + let target = new_target(); + let ctx = CreateWrite { + new_ref: || "ref-2".to_string(), + ..creator(&script, &host, &target, EditingStatus::On) + }; + let outcome = create_event(&ctx, dentist_draft(), Some("ref-1")).await; + script.asked_all(); + assert!(outcome == ambiguous("ref-1"), "the same token"); + assert_eq!(held_creates(&host), 1); + assert_eq!(held_posts(&host, "ref-1"), Some(3)); + } + + /// C10 critiques 1 M1, 2 H1: after two empty looks, a re-send that never left, or one answered + /// 404, proves nothing about the first copy: still ambiguous, the token still held. + #[tokio::test] + async fn a_resend_unsent_or_answered_404_is_never_proof() { + for answer in [ + Scripted::Unsent(Unsent::Offline), + Scripted::Unsent(Unsent::SignIn), + absent(), + Scripted::Reply { + status: 403, + body: None, + date: None, + maybe_applied: false, + }, + ] { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let (outcome, script) = create( + &host, + vec![ + new_read(absent()), + new_read(absent()), + insert_step(answer), + new_read(absent()), + ], + Some("ref-1"), + ) + .await; + assert!(outcome == ambiguous("ref-1"), "still unknown"); + assert_eq!(inserts(&script).len(), 1); + assert_eq!(held_creates(&host), 1); + } + } + + /// C10 critique 1 M5: one id gets three POSTs at most, across the first run and every retry; + /// after that a retry only looks. + #[tokio::test] + async fn the_posts_under_one_id_are_bounded_across_retries() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let (outcome, script) = create( + &host, + vec![ + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ], + Some("ref-1"), + ) + .await; + assert!(outcome == ambiguous("ref-1"), "held"); + assert_eq!(inserts(&script).len(), 1); + assert_eq!(held_posts(&host, "ref-1"), Some(MAX_POSTS)); + let (outcome, script) = create( + &host, + vec![new_read(absent()), new_read(absent())], + Some("ref-1"), + ) + .await; + assert!(outcome == failed(CREATE_LATER), "looks only, then says so"); + script.finished(); + assert_eq!(held_posts(&host, "ref-1"), Some(MAX_POSTS)); + // Still found when it shows. + let copy = google_copy(&dentist_draft(), "\"n1\""); + let (outcome, _) = create(&host, vec![new_read(Scripted::Json(copy))], Some("ref-1")).await; + assert!(outcome == saved(), "landed at last"); + assert_eq!(held_creates(&host), 0); + } + + /// The looks are reads: editing turned off since doesn't stop a retry finding the event, and + /// only a re-send is refused, the token kept (C10 critique 1 M1). + #[tokio::test] + async fn a_retry_looks_whatever_editing_says_and_only_a_resend_needs_it() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::Paused, + vec![new_read(absent()), new_read(absent())], + dentist_draft(), + Some("ref-1"), + ) + .await; + assert!( + outcome + == WriteOutcome::ReadOnly { + reason: ReadOnlyReason::EditingOff + }, + "the re-send is refused" + ); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + let copy = google_copy(&dentist_draft(), "\"n1\""); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::Paused, + vec![new_read(Scripted::Json(copy))], + dentist_draft(), + Some("ref-1"), + ) + .await; + assert!(outcome == saved(), "found with editing paused"); + } + + #[tokio::test] + async fn a_retry_with_a_changed_draft_or_calendar_is_refused_and_kept() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let renamed = NewEventDraft { + summary: "Dentist (moved)".into(), + ..dentist_draft() + }; + let other = CalendarTarget { + calendar_id: "cal2".into(), + ..new_target() + }; + for (target, draft) in [(new_target(), renamed), (other, dentist_draft())] { + let (outcome, script) = create_on( + &host, + &target, + EditingStatus::On, + Vec::new(), + draft, + Some("ref-1"), + ) + .await; + assert!(outcome == failed(CREATE_CHANGED), "refused, nothing sent"); + assert!(script.sent().is_empty()); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + } + } + + /// A retry running holds its claim and has taken the create out, as `retry_create` does: a + /// second one is Busy, never told the token is lost, and sends nothing (claim before take). + #[tokio::test] + async fn a_retry_while_one_runs_is_busy() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let claim = InFlight::claim(&host.edit, &create_key("ref-1")).unwrap(); + let (taken, _held) = TakenCreate::take(&host.edit, "ref-1").unwrap(); + assert_eq!(held_creates(&host), 0); + let (outcome, script) = create(&host, Vec::new(), Some("ref-1")).await; + assert!(outcome == WriteOutcome::Busy, "busy, not lost"); + assert!(script.sent().is_empty()); + // The running one ends: the create goes back under its token. + drop(taken); + drop(claim); + assert_eq!(held_creates(&host), 1); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + } + + /// The eight hours run out with no prune but the command's own: a retry after them is told + /// the token is lost, and nothing is sent. + #[tokio::test] + async fn a_retry_past_the_eight_hours_finds_the_token_lapsed() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + // Only where the clock reaches back that far (every Unix one), as `local_slot`'s TTL test. + let Some(old) = Instant::now().checked_sub(SESSION_TTL + Duration::from_secs(1)) else { + return; + }; + set_held_at(&host, "ref-1", old); + let (outcome, script) = create(&host, Vec::new(), Some("ref-1")).await; + assert!(outcome == failed(CREATE_LOST), "lapsed"); + assert!(script.sent().is_empty()); + assert_eq!(held_creates(&host), 0); + } + + /// A re-send starts the eight hours over: a create sent again just before they ran out is + /// still held after them. + #[tokio::test] + async fn a_resend_starts_the_eight_hours_over() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let Some(old) = Instant::now().checked_sub(SESSION_TTL - Duration::from_secs(60)) else { + return; + }; + set_held_at(&host, "ref-1", old); + let (outcome, _) = create( + &host, + vec![ + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ], + Some("ref-1"), + ) + .await; + assert!(outcome == ambiguous("ref-1"), "held"); + host.edit + .prune_creates(Instant::now() + Duration::from_secs(120)); + assert_eq!( + held_posts(&host, "ref-1"), + Some(3), + "kept past the old eight hours" + ); + } + + /// A re-send Google answers with the copy cancelled, or one whose answer is lost and then + /// found cancelled: made, then deleted in Google. Gone, the token with it. + #[tokio::test] + async fn a_resend_that_meets_a_cancelled_copy_says_gone() { + let cancelled = with( + google_copy(&dentist_draft(), "\"n2\""), + "status", + json!("cancelled"), + ); + for tail in [ + vec![insert_step(Scripted::Json(cancelled.clone()))], + vec![ + insert_step(lost()), + new_read(Scripted::Json(cancelled.clone())), + ], + ] { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let mut steps = vec![new_read(absent()), new_read(absent())]; + steps.extend(tail); + let (outcome, script) = create(&host, steps, Some("ref-1")).await; + assert!(outcome == WriteOutcome::Gone, "gone"); + assert_eq!(inserts(&script).len(), 1); + assert_eq!(held_creates(&host), 0); + assert!(new_row(&host).is_none()); + } + } + + /// A retry into a calendar PM stopped syncing still looks, but never sends it again there: + /// refused as a first create is, the token kept. + #[tokio::test] + async fn a_retry_never_resends_into_a_calendar_pm_doesnt_sync() { + let host = TestHost::holding(&[]); + lost_create(&host).await; + let unsynced = CalendarTarget { + selected: false, + ..new_target() + }; + let (outcome, script) = create_on( + &host, + &unsynced, + EditingStatus::On, + vec![new_read(absent()), new_read(absent())], + dentist_draft(), + Some("ref-1"), + ) + .await; + assert!( + outcome == failed(CREATE_NOT_SYNCED), + "refused before a re-send" + ); + assert!(inserts(&script).is_empty()); + assert_eq!(held_posts(&host, "ref-1"), Some(2)); + } + + #[tokio::test] + async fn an_unknown_or_lapsed_token_says_so_and_sends_nothing() { + let host = TestHost::holding(&[]); + let (outcome, _) = create(&host, Vec::new(), Some("nope")).await; + assert!(outcome == failed(CREATE_LOST), "unknown"); + lost_create(&host).await; + host.edit + .prune_creates(Instant::now() + SESSION_TTL + Duration::from_secs(1)); + let (outcome, _) = create(&host, Vec::new(), Some("ref-1")).await; + assert!(outcome == failed(CREATE_LOST), "lapsed"); + } + + /// A28: a list that lags behind the new event can't take it off the screen, and a fetch that + /// began before the landing can't settle its entry. + #[tokio::test] + async fn a_lagging_list_keeps_a_new_event_on_screen() { + let host = TestHost::holding(&[]); + let before = Instant::now(); + let copy = google_copy(&dentist_draft(), "\"n1\""); + create(&host, vec![insert_step(Scripted::Json(copy.clone()))], None).await; + let id = format!("cal:{NEW}"); + let after = Instant::now(); + assert!(host.synced(&[], after).iter().any(|r| r.id == id)); + // An older fetch that happens to show it settles nothing. + assert!(host + .synced(std::slice::from_ref(©), before) + .iter() + .any(|r| r.id == id)); + assert!(!host.nothing_recorded()); + // A fetch that began after the landing and shows it ends the entry. + host.synced(&[copy], after); + assert!(host.nothing_recorded()); + } + + /// C10a ships the create command inert: a release build refuses it before reading anything, + /// and only after the window and owner checks every write makes (C10 §16). + #[test] + fn the_create_command_is_refused_in_release_builds_until_its_ui_ships() { + 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())]; + let at = |needle: &str| shell.find(needle).unwrap_or(usize::MAX); + let gate = at("if !cfg!(debug_assertions) {"); + assert!( + at("require_owner(&app)?;") < gate, + "the gate comes after the window and owner checks" + ); + assert!( + gate < at("message: CREATE_NOT_YET.into()") + && at("message: CREATE_NOT_YET.into()") < at("state.conn()") + && at("state.conn()") < at("create_event(&ctx"), + "a release build refuses before reading anything or sending" + ); + } + + /// The retry's table: two empty looks before a re-send, a cancelled copy gone, anything it + /// can't read keeping the create held. + #[test] + fn retry_next_follows_the_table() { + let copy = json!({ "id": NEW }); + for first in [true, false] { + assert!( + matches!(retry_next(RetryLook::Live(copy.clone()), first), RetryStep::Land(c) if c == copy) + ); + assert!(matches!( + retry_next(RetryLook::Cancelled, first), + RetryStep::Gone + )); + assert!(matches!( + retry_next(RetryLook::Reauth, first), + RetryStep::Keep(Kept::Reauth) + )); + assert!(matches!( + retry_next(RetryLook::Busy, first), + RetryStep::Keep(Kept::Busy) + )); + assert!(matches!( + retry_next(RetryLook::Unknown, first), + RetryStep::Keep(Kept::Ambiguous) + )); + } + assert!(matches!( + retry_next(RetryLook::Absent, true), + RetryStep::LookAgain + )); + assert!(matches!( + retry_next(RetryLook::Absent, false), + RetryStep::Send + )); + let cancelled = json!({ "id": NEW, "status": "cancelled" }); + assert!(matches!( + RetryLook::of(Fetched::Event(cancelled)), + RetryLook::Cancelled + )); + assert!(matches!( + RetryLook::of(Fetched::Event(copy)), + RetryLook::Live(_) + )); + assert!(matches!(RetryLook::of(Fetched::Gone), RetryLook::Absent)); + assert!(matches!( + RetryLook::of(Fetched::Failed("x".into())), + RetryLook::Unknown + )); + } + + #[test] + fn in_mirror_reads_overlap_at_both_edges() { + let window = mirror(&[]).instants(); + let row = |start: &str, end: Option<&str>, all_day: bool| CalendarEvent { + start: start.into(), + end: end.map(str::to_string), + all_day, + ..Default::default() + }; + // Ends exactly where the window starts: out. + let edge = row("2026-08-31T23:00:00Z", Some("2026-09-01T00:00:00Z"), false); + assert_eq!(in_mirror(&edge, window), Some(false)); + let across = row("2026-08-31T23:00:00Z", Some("2026-09-01T00:30:00Z"), false); + assert_eq!(in_mirror(&across, window), Some(true)); + // The last day before the window ends: in. The day the window ends on: out. + let last = row("2027-10-31", Some("2027-11-01"), true); + assert_eq!(in_mirror(&last, window), Some(true)); + let after = row("2027-11-01", Some("2027-11-02"), true); + assert_eq!(in_mirror(&after, window), Some(false)); + // No end: its start alone. + let point = row("2026-10-26T10:00:00Z", None, false); + assert_eq!(in_mirror(&point, window), Some(true)); + // What can't be read lands. + assert_eq!(in_mirror(&row("soon", None, false), window), None); + assert_eq!(in_mirror(&point, None), None); + } + + #[test] + fn resolve_calendar_turns_feeds_and_outlook_into_not_google() { + let (_d, conn) = store(); + let cal = "gcal:me@x.com:primary"; + add_calendar(&conn, "gcal:me@x.com", "google", cal, Some("me@x.com")); + let google = resolve_calendar(&conn, cal, "gone").unwrap().unwrap(); + assert!(google.remote_calendar == "me@x.com" && google.email == "me@x.com"); + assert_eq!(google.token_key, "google_oauth_token_calendar::me@x.com"); + assert!(google.selected); + calendar::set_calendar_selected(&conn, cal, false).unwrap(); + assert!( + !resolve_calendar(&conn, cal, "gone") + .unwrap() + .unwrap() + .selected + ); + add_calendar(&conn, "ics:feed", "ics", "ics:feed:1", None); + add_calendar( + &conn, + "outlook:me@x.com", + "outlook", + "outlook:me@x.com:1", + None, + ); + for other in ["ics:feed:1", "outlook:me@x.com:1"] { + assert!(matches!( + resolve_calendar(&conn, other, "gone").unwrap(), + Err(ReadOnlyReason::NotGoogle) + )); + } + // Each caller says it its own way when the calendar has gone. + let missing = resolve_calendar(&conn, "gcal:me@x.com:nope", "That calendar is gone."); + assert!(matches!(missing, Err(Error::Other(m)) if m == "That calendar is gone.")); + } } diff --git a/src-tauri/src/commands/calendars.rs b/src-tauri/src/commands/calendars.rs index 6d4009b2..5eb976d5 100644 --- a/src-tauri/src/commands/calendars.rs +++ b/src-tauri/src/commands/calendars.rs @@ -67,6 +67,9 @@ pub struct CalendarOverview { /// Whether PM may edit each Google account's calendars, keyed by account id (`gcal:`). /// Computed here only, from the stored choice and the account's calendar token. pub editing: BTreeMap, + /// Google's default length for a new event, in minutes, keyed by account id (`gcal:`) + /// like `editing`. An account with none stored is missing: the editor then uses an hour. + pub default_length: BTreeMap, } /// The unified calendar state across every provider. Runs the one-time legacy Google migration first @@ -75,7 +78,7 @@ pub struct CalendarOverview { pub async fn calendar_overview(app: AppHandle) -> Result { let _ = migrate_legacy_google_calendar(&app).await; let state = app.state::(); - let (accounts, calendars, last_sync, (mirror_start, mirror_end), choices) = { + let (accounts, calendars, last_sync, (mirror_start, mirror_end), choices, lengths) = { let conn = state.conn()?; ( calendar::list_sources(&conn, None)?, @@ -83,8 +86,17 @@ pub async fn calendar_overview(app: AppHandle) -> Result { calendar::last_sync(&conn)?, calendar::time_window(&conn)?, calendar_editing::choices(&conn)?, + calendar::default_lengths(&conn)?, ) }; + let default_length = accounts + .iter() + .filter(|a| a.provider == "google") + .filter_map(|a| { + let email = a.email.as_deref()?.trim().to_lowercase(); + Some((a.id.clone(), *lengths.get(&email)?)) + }) + .collect(); // Token scopes come from the keychain, read with the DB lock released. Only an account with a // stored choice needs one: without it, editing is off whatever the token holds. let editing = accounts @@ -112,6 +124,7 @@ pub async fn calendar_overview(app: AppHandle) -> Result { mirror_start, mirror_end, editing, + default_length, }) } @@ -279,6 +292,7 @@ pub async fn disconnect_google_calendar_account( } calendar::remove_source(&conn, &calendar::google_account_id(&email))?; calendar_editing::set_choice(&conn, &email, None)?; + calendar::set_default_length(&conn, &email, None)?; // A grant Google keeps still carries the write scope if editing was ever granted; a revoke ends it. let calendar_write = !plan.revoke && calendar_editing::write_granted(&conn, &email)?; if plan.revoke { @@ -650,6 +664,7 @@ pub fn clear_google_client(app: AppHandle, state: State<'_, AppState>) -> Result } secrets::clear_google_token_for(google::CALENDAR_TOKEN_KEY).ok(); // any not-yet-migrated legacy token calendar_editing::clear_all(&conn)?; + calendar::clear_default_lengths(&conn)?; drive::forget_all_accounts(&conn)?; // A backup-only account has no Drive row, so the loop above never saw its sign-in. Clear it before // the destination below forgets which account it was. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e3f62d5..a97766b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1786,6 +1786,7 @@ pub fn run() { commands::set_calendar_editing_paused, commands::get_calendar_event_for_edit, commands::update_calendar_event, + commands::create_calendar_event, commands::delete_calendar_event, commands::cancel_calendar_delete, commands::list_held_deletes, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 163b5bbb..60cad018 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.149.0-alpha", + "version": "3.149.1-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/CalendarConnection.test.tsx b/src/components/CalendarConnection.test.tsx index 30ea6b20..9780e125 100644 --- a/src/components/CalendarConnection.test.tsx +++ b/src/components/CalendarConnection.test.tsx @@ -85,6 +85,7 @@ function overview(editing: EditingStatus): CalendarOverview { mirror_start: "2026-09-09T00:00:00Z", mirror_end: "2027-11-09T00:00:00Z", editing: { [ID]: editing }, + default_length: {}, }; } diff --git a/src/components/calendar/edit/EventEditor.tsx b/src/components/calendar/edit/EventEditor.tsx index fc4ca32f..7e6aa745 100644 --- a/src/components/calendar/edit/EventEditor.tsx +++ b/src/components/calendar/edit/EventEditor.tsx @@ -554,7 +554,10 @@ export function EventEditor({ row, calendar, account, milestone, onDelete, onClo case "conflict": await rebaseAfterConflict(outcomeText(outcome).text); return; + // `ambiguous`: only creating an event answers it, never a save; read as a save it would be + // unconfirmed. case "unconfirmed": + case "ambiguous": setUncertain(true); setBanner(outcomeText(outcome)); return; diff --git a/src/lib/calendarEdit/editReasons.test.ts b/src/lib/calendarEdit/editReasons.test.ts index 55648c87..5595e607 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -83,6 +83,7 @@ describe("edit reasons", () => { { outcome: "busy" }, { outcome: "reauth" }, { outcome: "unconfirmed" }, + { outcome: "ambiguous", retry_token: "ref-1" }, { outcome: "failed", message: "Google didn't accept the change: Invalid start" }, ]; for (const o of outcomes) { @@ -184,6 +185,19 @@ describe("edit reasons", () => { ); }); + it("ambiguous has words", () => { + // A new event whose answer never arrived: it may be there, and Check again looks before it + // sends. Never "wasn't created", never a promise that no second copy can be made (Google can't + // always catch a repeated id), and never the token in the words. + const said = outcomeText({ outcome: "ambiguous", retry_token: "ref-1" }); + expect(said).toEqual({ + tone: "warn", + text: "PM lost touch with Google while creating this event, so it may already be in your calendar. Check again: PM looks for it first and sends it again only if Google still doesn't show it.", + }); + expect(said.text).not.toContain("ref-1"); + expect(settleText({ outcome: "ambiguous", retry_token: "ref-1" }, "Dentist").tone).toBe("warn"); + }); + it("explains an editor that couldn't open, and a draft that can't be saved", () => { // A 404 can also mean the calendar stopped being visible, so "gone" doesn't claim a deletion. expect(loadText({ outcome: "gone" })).toBe( @@ -204,7 +218,7 @@ const saved = (warnings: WriteWarning[], left_behind?: string[]) => describe("a write's warnings", () => { it("words every warning, for a save and for a delete", () => { - const all: WriteWarning[] = ["mirror_refresh_pending", "left_on_old_series"]; + const all: WriteWarning[] = ["mirror_refresh_pending", "left_on_old_series", "outside_mirror"]; for (const w of all) { for (const kind of ["save", "delete"] as const) { expect(writeWarningText(w, kind)).toMatch(/^[A-Z].*\.$/); diff --git a/src/lib/calendarEdit/editReasons.ts b/src/lib/calendarEdit/editReasons.ts index 048a6c64..96bdd17f 100644 --- a/src/lib/calendarEdit/editReasons.ts +++ b/src/lib/calendarEdit/editReasons.ts @@ -142,6 +142,9 @@ export function writeWarningText( ? `Some occurrences you changed on their own may still be on the earlier part, or hidden by Google${days}; check them in Google Calendar.` : `Some occurrences changed on their own may still show${days}; check them in Google Calendar.`; } + // Only a new event comes back with it. + case "outside_mirror": + return "It starts outside the months PM syncs, so PM's calendar won't show it."; default: return unreachable(warning); } @@ -243,6 +246,12 @@ export function outcomeText(outcome: WriteOutcome): { tone: Tone; text: string } tone: "warn", text: "PM lost touch with Google before it could confirm this save. Refresh the calendar to see whether it went through.", }; + // Only creating an event answers this; its token is kept so Check again can look first. + case "ambiguous": + return { + tone: "warn", + text: "PM lost touch with Google while creating this event, so it may already be in your calendar. Check again: PM looks for it first and sends it again only if Google still doesn't show it.", + }; case "failed": return { tone: "error", text: outcome.message }; default: @@ -309,6 +318,12 @@ export function deleteText(outcome: WriteOutcome, title: string): { tone: Tone; tone: "warn", text: `PM lost touch with Google before it could confirm whether ${t} was deleted. Refresh the calendar to see.`, }; + // Only creating an event answers this; a delete never does, so it says only what's known. + case "ambiguous": + return { + tone: "warn", + text: `PM lost touch with Google, so it can't tell whether ${t} was deleted. Refresh the calendar to see.`, + }; case "failed": return notDeleted(outcome.message); default: @@ -374,6 +389,7 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t tone: "warn", text: `${t}: Google is busy right now, so PM put the repeating event back as it was. Make the change again in a moment.`, }; + // `ambiguous` is listed for completeness: only creating an event answers it; a settle never does. case "no_change": case "needs_choice": case "ignored_by_google": @@ -382,6 +398,7 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t case "read_only": case "reauth": case "unconfirmed": + case "ambiguous": case "failed": { const said = outcomeText(result); return { tone: said.tone, text: `${t}: ${said.text}` }; diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 60520402..31d94324 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -23,6 +23,13 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "3.149.1-alpha", + date: "2026-10-20", + highlights: [ + "Under the hood, PM can now create events on your Google calendars. If PM loses touch with Google while saving a new event, it looks for it there before sending it again, so a dropped connection is very unlikely to make a second copy. Nothing you can see changes yet: the way to create events in the app comes in a later update.", + ], + }, { version: "3.149.0-alpha", date: "2026-10-19", diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 3a67176d..17a560f8 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -140,6 +140,7 @@ import type { EditLoad, EventPatchDraft, HeldDeleteInfo, + NewEventDraft, SeenSummary, SeriesChoices, SeriesScope, @@ -1099,6 +1100,17 @@ export const updateCalendarEvent = ( choice: string | null = null, ) => invoke("update_calendar_event", { session, draft, scope, choice }); +/** Create an event on one of PM's Google calendars (`calendarId`, PM's own id). Outcomes come back as + * data. `retryToken` (from an `ambiguous` outcome) looks for the earlier attempt and sends it again, + * under the same id, only if Google still doesn't show it, so a retry is very unlikely to make a + * second copy. Nobody is emailed: new events have no guests. + * Release builds refuse it until the editor's create mode ships. */ +export const createCalendarEvent = ( + calendarId: string, + draft: NewEventDraft, + retryToken: string | null = null, +) => invoke("create_calendar_event", { calendarId, draft, retryToken }); + /** Delete a mirrored event after an Undo window held by the backend. It goes to Google when the * window ends, only if Google still holds what the user saw (`seen`); `onCalendarDeleteSettled` * reports how it ended. `scope` and `choice` as for a save. */ diff --git a/src/lib/types.ts b/src/lib/types.ts index d659e96a..dce0afd1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -947,6 +947,9 @@ export interface CalendarOverview { mirror_end: string; /** Whether PM may edit each Google account's calendars, keyed by account id (`gcal:`). */ editing: Record; + /** Google's default length for a new event, in minutes, keyed by account id like `editing`. An + * account missing here has none stored yet: a new event then lasts an hour. */ + default_length: Record; } /** Calendar editing for one Google account (`calendar_editing::EditingStatus`). `paused` is the @@ -1579,6 +1582,17 @@ export interface EventPatchDraft { visibility?: EventVisibility | null; } +/** 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. */ +export interface NewEventDraft { + summary: string; + time: TimeDraft; + location: string; + description: string; + show_as: ShowAs; + visibility: EventVisibility; +} + /** What the user saw of an event when they chose to delete it, from the row they clicked. */ export interface SeenSummary { summary: string; @@ -1689,7 +1703,9 @@ export type SeriesChoices = /** Which occurrences of a repeating event a save or delete is for. */ export type SeriesScope = "this" | "following" | "all"; -export type WriteWarning = "mirror_refresh_pending" | "left_on_old_series"; +/** `outside_mirror`: a new event saved in Google that starts outside the months PM mirrors, so + * PM's calendar won't show it. */ +export type WriteWarning = "mirror_refresh_pending" | "left_on_old_series" | "outside_mirror"; /** What a save came to. `saved` and `no_change` are the successes. */ export type WriteOutcome = @@ -1715,6 +1731,10 @@ export type WriteOutcome = | { outcome: "reauth" } /** The answer never arrived and PM couldn't look afterwards: it may or may not have happened. */ | { outcome: "unconfirmed" } + /** A new event whose answer never arrived: creating it again with `retry_token` looks for it + * first and sends it again, under the same id, only if Google still doesn't show it, so a + * second copy is very unlikely. PM's own token, never the event's id. */ + | { outcome: "ambiguous"; retry_token: string } | { outcome: "failed"; message: string }; /** A delete still waiting out its Undo window, for a webview that reloaded. */