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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SYNC-SET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const CALENDAR_WRITES = {
importNames: [
"getCalendarEventForEdit",
"updateCalendarEvent",
"createCalendarEvent",
"deleteCalendarEvent",
"cancelCalendarDelete",
"listHeldDeletes",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pm",
"private": true,
"version": "3.149.0-alpha",
"version": "3.149.1-alpha",
"license": "AGPL-3.0-or-later",
"type": "module",
"engines": {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-calendar-write-fence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const WEB_ROOT = "src";
export const WRITE_COMMANDS = [
["get_calendar_event_for_edit", "getCalendarEventForEdit"],
["update_calendar_event", "updateCalendarEvent"],
["create_calendar_event", "createCalendarEvent"],
["delete_calendar_event", "deleteCalendarEvent"],
["cancel_calendar_delete", "cancelCalendarDelete"],
["list_held_deletes", "listHeldDeletes"],
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pm"
version = "3.149.0-alpha"
version = "3.149.1-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
Expand Down
64 changes: 64 additions & 0 deletions src-tauri/src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,48 @@ fn primary_calendar_id(value: &serde_json::Value) -> Option<String> {
(!id.is_empty()).then(|| id.to_lowercase())
}

// --- each Google account's default event length (A28, C10) ---

/// `settings` key: `{ "<lowercased account email>": 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<std::collections::BTreeMap<String, u32>> {
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<u32>) -> 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
Expand Down Expand Up @@ -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());
}
}
75 changes: 75 additions & 0 deletions src-tauri/src/calendar_write/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ pub struct EventPatchDraft {
pub visibility: Option<Visibility>,
}

/// 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<ShowAs>,
/// `None` or `Default` is the calendar's default, and isn't sent.
#[serde(default)]
pub visibility: Option<Visibility>,
}

/// 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`]).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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::<NewEventDraft>(
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::<NewEventDraft>(r#"{"summary":"x"}"#).is_err());
}

#[test]
fn nothing_is_editable_under_a_blanket_reason() {
let none = FieldPermissions::none(ReadOnlyReason::NotGoogle);
Expand Down
75 changes: 75 additions & 0 deletions src-tauri/src/calendar_write/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ pub fn edit_block(facts: &EditFacts) -> Option<ReadOnlyReason> {
}
}

/// 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<ReadOnlyReason> {
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<ReadOnlyReason> {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading