git.lucas.co / cce-calendar
calendar
git clone https://git.lucas.co/cce-calendar.git

src/lib.rs (6.4K)

  1 //! Shared between the `cce-calendar` app and the `cce-calendar-sync` helper:
  2 //! the on-disk event record and its file, the sync-state sidecar, and the
  3 //! push-target config. Both binaries read and rewrite the same
  4 //! `events.json`, so the record shape lives in one place.
  5 
  6 use std::collections::BTreeMap;
  7 use std::path::PathBuf;
  8 
  9 /// The on-disk shape: a flat list keeps the file trivially mergeable and
 10 /// greppable. `uid`/`source` are set only on records mirrored from a remote
 11 /// calendar by `cce-calendar-sync`; hand-entered events carry neither until
 12 /// the sync has pushed them to the default calendar and written the identity
 13 /// back. `recurring` marks an expanded instance of a repeating event: those
 14 /// are mirrored read-only (deleting one here would mean nothing the server
 15 /// could express), and the app refuses to delete them.
 16 #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
 17 pub struct EventRecord {
 18     pub date: String,
 19     #[serde(default, skip_serializing_if = "Option::is_none")]
 20     pub time: Option<String>,
 21     pub title: String,
 22     #[serde(default, skip_serializing_if = "Option::is_none")]
 23     pub uid: Option<String>,
 24     #[serde(default, skip_serializing_if = "Option::is_none")]
 25     pub source: Option<String>,
 26     #[serde(default, skip_serializing_if = "std::ops::Not::not")]
 27     pub recurring: bool,
 28 }
 29 
 30 fn data_dir() -> PathBuf {
 31     std::env::var_os("XDG_DATA_HOME")
 32         .map(PathBuf::from)
 33         .filter(|p| p.is_absolute())
 34         .unwrap_or_else(|| {
 35             PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
 36         })
 37         .join("cce/calendar")
 38 }
 39 
 40 pub fn data_path() -> PathBuf {
 41     data_dir().join("events.json")
 42 }
 43 
 44 pub fn sync_state_path() -> PathBuf {
 45     data_dir().join("sync-state.json")
 46 }
 47 
 48 pub fn load_records() -> std::io::Result<Vec<EventRecord>> {
 49     let text = match std::fs::read_to_string(data_path()) {
 50         Ok(t) => t,
 51         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
 52         Err(e) => return Err(e),
 53     };
 54     serde_json::from_str(&text).map_err(std::io::Error::other)
 55 }
 56 
 57 /// Write-temp-then-rename in the same directory, so a crash mid-write never
 58 /// leaves a truncated file — two writers (app and sync timer) share this file.
 59 pub fn save_records(records: &[EventRecord]) -> std::io::Result<()> {
 60     atomic_write(&data_path(), &serde_json::to_string_pretty(records).unwrap_or_default())
 61 }
 62 
 63 pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
 64     if let Some(dir) = path.parent() {
 65         std::fs::create_dir_all(dir)?;
 66     }
 67     let tmp = path.with_extension("json.tmp");
 68     std::fs::write(&tmp, content)?;
 69     std::fs::rename(&tmp, path)
 70 }
 71 
 72 /// Stable order for the file: by date, timed before untimed, then title.
 73 pub fn sort_records(records: &mut [EventRecord]) {
 74     records.sort_by(|a, b| {
 75         (&a.date, a.time.is_none(), &a.time, &a.title).cmp(&(&b.date, b.time.is_none(), &b.time, &b.title))
 76     });
 77 }
 78 
 79 // ── Sync state (cce-calendar-sync's merge base; the app never touches it) ─
 80 
 81 /// What the server held for one NON-recurring event at the end of the last
 82 /// sync. Comparing the file and the server against this tells "the user
 83 /// changed it here" apart from "it changed on the phone"; a uid in the state
 84 /// but missing from the file is a local deletion to push.
 85 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 86 pub struct SyncedEvent {
 87     /// "icloud:<email>" / "google:<email>".
 88     pub source: String,
 89     /// Absolute resource URL (PUT/PATCH/DELETE target).
 90     pub url: String,
 91     pub etag: String,
 92     pub date: String,
 93     #[serde(default)]
 94     pub time: Option<String>,
 95     pub title: String,
 96 }
 97 
 98 impl SyncedEvent {
 99     pub fn matches(&self, r: &EventRecord) -> bool {
100         self.date == r.date && self.time == r.time && self.title == r.title
101     }
102 }
103 
104 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
105 pub struct SyncState {
106     #[serde(default)]
107     pub events: BTreeMap<String, SyncedEvent>,
108 }
109 
110 pub fn load_sync_state() -> std::io::Result<SyncState> {
111     let text = match std::fs::read_to_string(sync_state_path()) {
112         Ok(t) => t,
113         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SyncState::default()),
114         Err(e) => return Err(e),
115     };
116     serde_json::from_str(&text).map_err(std::io::Error::other)
117 }
118 
119 pub fn save_sync_state(state: &SyncState) -> std::io::Result<()> {
120     atomic_write(&sync_state_path(), &serde_json::to_string_pretty(state).unwrap_or_default())
121 }
122 
123 // ── Config ────────────────────────────────────────────────────────────────
124 
125 /// Where events typed into the app are created. From
126 /// `~/.config/cce/cce-calendar/config.kdl`:
127 ///
128 /// ```kdl
129 /// push-to "icloud"                      // first iCloud event calendar
130 /// push-to "icloud" calendar="Home"      // a named one
131 /// push-to "google"                      // the Google primary calendar
132 /// push-to "none"                        // keep typed events local
133 /// ```
134 ///
135 /// Absent, the sync picks iCloud if such an account exists, else Google.
136 #[derive(Debug, Clone, PartialEq, Eq)]
137 pub struct PushTarget {
138     /// "icloud", "google", or "none".
139     pub kind: String,
140     pub calendar: Option<String>,
141 }
142 
143 pub fn push_target_config() -> Option<PushTarget> {
144     let path = cce_ui::config::get_app_config_path("cce-calendar");
145     let text = std::fs::read_to_string(path).ok()?;
146     let doc = text.parse::<kdl::KdlDocument>().ok()?;
147     let node = doc.get("push-to")?;
148     let kind = node.entries().iter().find(|e| e.name().is_none())?.value().as_string()?;
149     let calendar = node
150         .entries()
151         .iter()
152         .find(|e| e.name().map(|n| n.value()) == Some("calendar"))
153         .and_then(|e| e.value().as_string())
154         .map(String::from);
155     Some(PushTarget { kind: kind.to_ascii_lowercase(), calendar })
156 }
157 
158 #[cfg(test)]
159 mod tests {
160     use super::*;
161 
162     #[test]
163     fn recurring_flag_is_optional_on_disk() {
164         let plain: EventRecord = serde_json::from_str(r#"{"date":"2026-09-10","title":"x"}"#).unwrap();
165         assert!(!plain.recurring);
166         let json = serde_json::to_string(&plain).unwrap();
167         assert!(!json.contains("recurring"));
168         let rec = EventRecord { recurring: true, ..plain };
169         assert!(serde_json::to_string(&rec).unwrap().contains("\"recurring\":true"));
170     }
171 }