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 }