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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor
own working hours — with schedule-aware chat answers and an automatic "Due soon" when an
event names a project. Your milestones and pinboard timelines show alongside as markers you
can toggle off. PM only reads your calendars unless you turn on editing for a Google account;
then you can change and delete events you organise without guests, repeating ones included.
Every change
goes straight to Google, and a save never overwrites a field someone changed there in the
meantime.
then you can create events on its calendars (+ New event, or press c), and change and delete
events you organise without guests, repeating ones included. Every change goes straight to
Google, a save never overwrites a field someone changed there in the meantime, and a new
event whose save loses the connection is looked for in Google before it's ever sent again.
- **Bring your own model.** Choose your model through OpenRouter — separate chat and
background models, spend at a glance, and zero-data-retention requested on every call. The
list offers only models a provider will actually serve on those terms, so a model can't
Expand Down
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`), `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. |
| `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 and the last calendar an event was created in, 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 when an account connects and at each sync (a read that fails keeps the last value; a disconnect clears 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
2 changes: 1 addition & 1 deletion design-system-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Drive it with a single `depth` value; gate optional blocks on it. Don't fork lay
Slate = cards; Terminal = table. Depth scales it from name+status only → +meta → full table with cost/active.
- **Project** — file list beside a project-scoped chat thread (two-pane). Treatment per System.
- **Documents** — ingestion: a drop zone + recent items with type/date/index-state. Rules / cards / mono table per System.
- **Calendar** — week/agenda; a Google event you organise can be edited from its details. Serif agenda / day cards / mono list per System.
- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Serif agenda / day cards / mono list per System.
- **Review** — queue of proposed actions as inline approval cards (Approve / Edit / Dismiss).
- **Settings** — appearance (System / Accent / Depth / Mode), models & keys (key field → keychain), help-mode toggle. Editorial = ruled sections, Slate = card panel, Terminal = key/value list.
- **Command palette** (`⌘K`) — overlay with a query line + result rows.
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.1-alpha",
"version": "3.150.0-alpha",
"license": "AGPL-3.0-or-later",
"type": "module",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pm"
version = "3.149.1-alpha"
version = "3.150.0-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
Expand Down
136 changes: 134 additions & 2 deletions src-tauri/src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,61 @@ fn primary_calendar_id(value: &serde_json::Value) -> Option<String> {

/// `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.
/// one read-modify-write under the DB lock. Read from Google when an account connects and at each
/// sync ([`fetch_default_length`]); a disconnect clears it.
pub const DEFAULT_LENGTH_KEY: &str = "calendar_default_length";

/// Google's default length for a new event on the account `token_key` signs in as, in minutes
/// (`settings.get('defaultEventLength')`, readable with the read-only calendar permission every
/// account has), or `None` when it can't be read. Network only: callers hold no lock across it.
pub async fn fetch_default_length(token_key: &str) -> Option<u32> {
let url = format!("{CALENDAR_API}/users/me/settings/defaultEventLength");
let value = google::authorized_get(token_key, &url).await.ok()?;
parse_default_length(&value)
}

/// `{"value": "30", …}` is 30. Google documents a number of minutes, as a string; anything else
/// (missing, not a string, not a number, zero, or over a day) reads as `None`, and the editor keeps
/// its hour.
pub fn parse_default_length(v: &serde_json::Value) -> Option<u32> {
v.get("value")?
.as_str()?
.trim()
.parse::<u32>()
.ok()
.filter(|m| (1..=1440).contains(m))
}

/// Store what a read of one account's default length found, under the caller's lock: a value Google
/// gave replaces the last one; a read that failed (`None`) changes nothing, so the last good value
/// stays rather than falling back to an hour.
fn remember_default_length(conn: &Connection, email: &str, read: Option<u32>) -> Result<()> {
match read {
Some(minutes) => set_default_length(conn, email, Some(minutes)),
None => Ok(()),
}
}

/// [`remember_default_length`] for a read made with no lock held, under the lock taken again after
/// it: only while the Google source `source_id` is still connected. A disconnect during the read
/// cleared the account's length, and a value read with the token it had before must not bring the
/// length back for an account PM no longer holds.
pub fn remember_default_length_if_connected(
conn: &Connection,
source_id: &str,
email: &str,
read: Option<u32>,
) -> Result<()> {
let connected = list_sources(conn, Some("google"))?
.iter()
.any(|s| s.id == source_id);
if connected {
remember_default_length(conn, email, read)
} else {
Ok(())
}
}

/// 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>> {
Expand Down Expand Up @@ -3531,4 +3582,85 @@ mod tests {
clear_default_lengths(&conn).unwrap();
assert!(default_lengths(&conn).unwrap().is_empty());
}

#[test]
fn default_length_reads_googles_minutes_string() {
use serde_json::json;
assert_eq!(parse_default_length(&json!({ "value": "30" })), Some(30));
assert_eq!(parse_default_length(&json!({ "value": " 45 " })), Some(45));
assert_eq!(
parse_default_length(&json!({ "kind": "calendar#setting", "value": "1440" })),
Some(1440)
);
}

#[test]
fn an_odd_default_length_reads_as_none() {
use serde_json::json;
let odd = [
json!({ "value": "0" }),
json!({ "value": "-5" }),
json!({ "value": "1441" }),
json!({ "value": "half an hour" }),
json!({ "value": "" }),
json!({ "value": 30 }),
json!({ "kind": "calendar#setting" }),
json!("30"),
];
for (i, v) in odd.iter().enumerate() {
assert_eq!(parse_default_length(v), None, "case {i}");
}
}

/// A sync whose read failed leaves the last good length, never the hour the editor falls back to.
#[test]
fn a_failed_default_length_read_keeps_the_last_one() {
let dir = tempfile::tempdir().unwrap();
let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap();
remember_default_length(&conn, "me@example.com", Some(30)).unwrap();
remember_default_length(&conn, "me@example.com", None).unwrap();
assert_eq!(
default_lengths(&conn).unwrap().get("me@example.com"),
Some(&30)
);
remember_default_length(&conn, "Me@Example.com", Some(25)).unwrap();
assert_eq!(
default_lengths(&conn).unwrap().get("me@example.com"),
Some(&25)
);
// Nothing read for an account with none stored stores nothing.
remember_default_length(&conn, "new@example.com", None).unwrap();
assert_eq!(default_lengths(&conn).unwrap().len(), 1);
}

/// A sync reads the length with no lock held; an account disconnected meanwhile (its source gone,
/// its length cleared) gets none back from that read. A connected one does.
#[test]
fn a_default_length_read_lands_only_while_its_account_is_connected() {
let dir = tempfile::tempdir().unwrap();
let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap();
let me = google_account_id("me@example.com");
upsert_source(
&conn,
&me,
"google",
Some("me@example.com"),
"me@example.com",
)
.unwrap();
remember_default_length_if_connected(&conn, &me, "me@example.com", Some(30)).unwrap();
assert_eq!(
default_lengths(&conn).unwrap().get("me@example.com"),
Some(&30)
);
// Disconnected during the read, as the disconnect does it: source removed, length cleared.
remove_source(&conn, &me).unwrap();
set_default_length(&conn, "me@example.com", None).unwrap();
remember_default_length_if_connected(&conn, &me, "me@example.com", Some(45)).unwrap();
assert!(default_lengths(&conn).unwrap().is_empty());
// A source by that id that isn't Google's doesn't count.
upsert_source(&conn, &me, "other", None, "Feed").unwrap();
remember_default_length_if_connected(&conn, &me, "me@example.com", Some(45)).unwrap();
assert!(default_lengths(&conn).unwrap().is_empty());
}
}
48 changes: 30 additions & 18 deletions src-tauri/src/commands/calendar_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -849,8 +849,6 @@ async fn save_event<W: Wire, H: Host>(

// --- 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.";
Expand Down Expand Up @@ -902,12 +900,6 @@ pub async fn create_calendar_event(
) -> Result<WriteOutcome> {
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::<AppState>();
let target = match resolve_calendar(
&*state.conn()?,
Expand Down Expand Up @@ -14129,10 +14121,10 @@ mod tests {
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).
/// Creating events ships in every build (C10b): the command makes the window and owner checks
/// every write makes before it reads anything or sends, and no build refuses it outright.
#[test]
fn the_create_command_is_refused_in_release_builds_until_its_ui_ships() {
fn the_create_command_checks_the_window_and_owner_before_anything_else() {
let source = include_str!("calendar_edit.rs");
let shipped = source
.split("#[cfg(test)]\nmod tests {")
Expand All @@ -14144,19 +14136,39 @@ mod tests {
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"
at("require_main_window(&window)?;") < at("require_owner(&app)?;")
&& at("require_owner(&app)?;") < at("state.conn()")
&& at("state.conn()") < at("create_event(&ctx"),
"the window and owner checks come before reading anything or sending"
);
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"
!shell.contains("if !cfg!(debug_assertions)"),
"no build refuses creating events outright"
);
}

/// The webview ends a retry token on two of the create's refusals, which it knows only by their
/// words: its copies of them must be these very strings (C10 critiques: the token drops only on
/// saved, gone, these two).
#[test]
fn the_webview_knows_the_words_that_end_a_retry_token() {
let words = include_str!("../../../src/lib/calendarEdit/editReasons.ts");
for (name, text) in [
("CREATE_LOST", CREATE_LOST),
("CREATE_CHANGED", CREATE_CHANGED),
] {
let line = format!("export const {name} =");
let at = words.find(&line).expect("the webview names it");
let rest = &words[at..];
let decl = &rest[..rest.find(";\n").unwrap_or(rest.len())];
assert!(
decl.contains(&format!("\"{text}\"")),
"{name}: the webview's copy differs from the backend's"
);
}
}

/// 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]
Expand Down
Loading
Loading