Skip to main content

backend/vjournal/
mod.rs

1//! VJOURNAL-backed journal persistence using iCalendar files.
2
3mod calendar;
4
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, anyhow};
9use uuid::Uuid;
10use vobject::Component;
11use vobject::component::write_component;
12
13use self::calendar::{
14    apply_entry_to_component, build_vcalendar, component_to_entry, date_at_utc_midnight, get_uid,
15    normalize_entry_priority, parse_vcalendar,
16};
17use super::*;
18
19/// Persists journal entries as iCalendar `VJOURNAL` components in a directory.
20pub struct VjournalDataProvide {
21    /// The path to the directory containing `VJOURNAL` components in `.ics` files (CalDAV).
22    directory: PathBuf,
23    /// Internal state.
24    state: VjournalState,
25}
26
27struct EntryLocation {
28    /// The path to the file the entry is in.
29    file_path: PathBuf,
30    /// The UID of the entry within the file.
31    ///
32    /// This is to account for the case when a single `.ics` file contains multiple `VJOURNAL`
33    /// components.
34    uid: String,
35}
36
37/// Bookkeeping between the `VJOURNAL` entries in a store and `tui-journal` state.
38///
39/// Note that there is no synchronization with external editors, so tools which sync the directory
40/// may cause this to become stale if manipulations happen to the same entry while `tui-journal` is
41/// running.
42struct VjournalState {
43    /// The next available `tui-journal` ID.
44    next_id: u32,
45    /// Map of the journal UID to `tui-journal` ID.
46    uid_to_id: HashMap<String, u32>,
47    /// Map of `tui-journal` ID to location within the store.
48    id_to_location: HashMap<u32, EntryLocation>,
49}
50
51impl VjournalDataProvide {
52    /// Creates a provider backed by `directory`.
53    ///
54    /// The directory is created on the first write if it does not exist.
55    pub fn new(directory: PathBuf) -> Self {
56        Self {
57            directory,
58            state: VjournalState::new(),
59        }
60    }
61}
62
63impl DataProvider for VjournalDataProvide {
64    async fn load_all_entries(&mut self) -> anyhow::Result<Vec<Entry>> {
65        if !self.directory.try_exists()? {
66            return Ok(Vec::new());
67        }
68
69        let files = scan_ics_files(&self.directory).await?;
70        let mut entries = Vec::new();
71        let mut seen_uids = HashSet::new();
72
73        for file_path in &files {
74            let vcal = match read_vcalendar_file(file_path).await {
75                Ok(c) => c,
76                Err(e) => {
77                    log::warn!("Skipping {}: {e:#}", file_path.display());
78                    continue;
79                }
80            };
81
82            for sub in &vcal.subcomponents {
83                if sub.name != "VJOURNAL" || !sub.get_all("RECURRENCE-ID").is_empty() {
84                    continue;
85                }
86                let uid = match get_uid(sub) {
87                    Some(uid) => uid,
88                    None => {
89                        log::warn!("Skipping VJOURNAL without UID in {}", file_path.display());
90                        continue;
91                    }
92                };
93                if !seen_uids.insert(uid.clone()) {
94                    log::warn!(
95                        "Skipping duplicate VJOURNAL master {uid} in {}",
96                        file_path.display()
97                    );
98                    continue;
99                }
100
101                let id = self.state.assign_id(&uid, file_path, None);
102                match component_to_entry(sub, id) {
103                    Ok(entry) => entries.push(entry),
104                    Err(e) => {
105                        log::warn!("Skipping VJOURNAL {uid} in {}: {e:#}", file_path.display());
106                    }
107                }
108            }
109        }
110
111        Ok(entries)
112    }
113
114    async fn add_entry(&mut self, entry: EntryDraft) -> Result<Entry, ModifyEntryError> {
115        if entry.title.is_empty() {
116            return Err(ModifyEntryError::ValidationError(
117                "Entry title can't be empty".into(),
118            ));
119        }
120
121        self.write_entry(entry, None).await
122    }
123
124    async fn restore_entry(&mut self, entry: Entry) -> Result<Entry, ModifyEntryError> {
125        if entry.title.is_empty() {
126            return Err(ModifyEntryError::ValidationError(
127                "Entry title can't be empty".into(),
128            ));
129        }
130
131        if self.state.id_to_location.contains_key(&entry.id) {
132            let message = format!("Entry id {} already exists", entry.id);
133            return Err(ModifyEntryError::ValidationError(message));
134        }
135
136        let id = Some(entry.id);
137        self.write_entry(EntryDraft::from_entry(entry), id).await
138    }
139
140    async fn remove_entry(&mut self, entry_id: u32) -> anyhow::Result<()> {
141        let loc = self
142            .state
143            .id_to_location
144            .get(&entry_id)
145            .ok_or_else(|| anyhow!("No entry with id {entry_id}"))?;
146
147        let file_path = loc.file_path.clone();
148        let uid = loc.uid.clone();
149
150        let mut vcal = read_vcalendar_file(&file_path).await?;
151
152        vcal.subcomponents.retain(|component| {
153            component.name != "VJOURNAL"
154                || get_uid(component).is_none_or(|component_uid| component_uid != uid.as_str())
155        });
156
157        if vcal.subcomponents.is_empty() {
158            tokio::fs::remove_file(&file_path).await?;
159        } else {
160            write_vcalendar_file(&file_path, &vcal).await?;
161        }
162
163        self.state.remove_id(entry_id);
164
165        Ok(())
166    }
167
168    async fn update_entry(&mut self, mut entry: Entry) -> Result<Entry, ModifyEntryError> {
169        if entry.title.is_empty() {
170            return Err(ModifyEntryError::ValidationError(
171                "Entry title can't be empty".into(),
172            ));
173        }
174        entry.priority = normalize_entry_priority(entry.priority)?;
175
176        let loc = self.state.id_to_location.get(&entry.id).ok_or_else(|| {
177            ModifyEntryError::ValidationError(format!("No entry with id {}", entry.id))
178        })?;
179
180        let file_path = loc.file_path.clone();
181        let uid = loc.uid.clone();
182
183        let mut vcal = read_vcalendar_file(&file_path)
184            .await
185            .map_err(|e| anyhow!(e))?;
186
187        // Detached instances are hidden, so updates must target the recurrence master.
188        let sub = vcal
189            .subcomponents
190            .iter_mut()
191            .find(|c| {
192                c.name == "VJOURNAL"
193                    && get_uid(c).is_some_and(|component_uid| component_uid == uid.as_str())
194                    && c.get_all("RECURRENCE-ID").is_empty()
195            })
196            .ok_or_else(|| {
197                ModifyEntryError::DataError(anyhow!(
198                    "VJOURNAL {uid} not found in {}",
199                    file_path.display()
200                ))
201            })?;
202
203        entry.date = date_at_utc_midnight(entry.date.date_naive());
204        let draft = EntryDraft::from_entry(entry.clone());
205        *sub = apply_entry_to_component(&draft, &uid, Some(sub.clone()));
206
207        write_vcalendar_file(&file_path, &vcal)
208            .await
209            .map_err(|e| anyhow!(e))?;
210
211        Ok(entry)
212    }
213
214    async fn get_export_object(&mut self, entries_ids: &[u32]) -> anyhow::Result<EntriesDTO> {
215        let entries: Vec<EntryDraft> = self
216            .load_all_entries()
217            .await?
218            .into_iter()
219            .filter(|entry| entries_ids.contains(&entry.id))
220            .map(EntryDraft::from_entry)
221            .collect();
222
223        Ok(EntriesDTO::new(entries))
224    }
225
226    async fn assign_priority_to_entries(&mut self, priority: u32) -> anyhow::Result<()> {
227        let Some(priority) = normalize_entry_priority(Some(priority))? else {
228            return Ok(());
229        };
230
231        let entries = self.load_all_entries().await?;
232
233        for mut entry in entries {
234            if entry.priority.is_none() {
235                entry.priority = Some(priority);
236                self.update_entry(entry).await.map_err(|e| anyhow!("{e}"))?;
237            }
238        }
239
240        Ok(())
241    }
242}
243
244impl VjournalDataProvide {
245    async fn write_entry(
246        &mut self,
247        mut entry: EntryDraft,
248        id: Option<u32>,
249    ) -> Result<Entry, ModifyEntryError> {
250        entry.date = date_at_utc_midnight(entry.date.date_naive());
251        entry.priority = normalize_entry_priority(entry.priority)?;
252        let uid = generate_uid();
253        let vjournal = apply_entry_to_component(&entry, &uid, None);
254        let vcal = build_vcalendar(vjournal);
255
256        // Each new entry gets its own file, named after the UID.
257        let file_name = format!("{uid}.ics");
258        let file_path = self.directory.join(&file_name);
259
260        write_vcalendar_file(&file_path, &vcal)
261            .await
262            .map_err(|e| anyhow!(e))?;
263
264        let id = self.state.assign_id(&uid, &file_path, id);
265
266        Ok(Entry::from_draft(id, entry))
267    }
268}
269
270/// Collect all `.ics` files in `dir`, sorted for deterministic ordering.
271async fn scan_ics_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
272    let mut read_dir = tokio::fs::read_dir(dir)
273        .await
274        .with_context(|| format!("reading directory {}", dir.display()))?;
275
276    let mut files = Vec::new();
277    while let Some(entry) = read_dir
278        .next_entry()
279        .await
280        .with_context(|| format!("iterating directory {}", dir.display()))?
281    {
282        let path = entry.path();
283        if path.extension().and_then(|e| e.to_str()) == Some("ics") && path.is_file() {
284            files.push(path);
285        }
286    }
287
288    files.sort();
289    Ok(files)
290}
291
292/// Read and parse a `.ics` file into a VCALENDAR [`Component`].
293async fn read_vcalendar_file(path: &Path) -> anyhow::Result<Component> {
294    let content = tokio::fs::read_to_string(path)
295        .await
296        .with_context(|| format!("reading {}", path.display()))?;
297    parse_vcalendar(&content).with_context(|| format!("parsing {}", path.display()))
298}
299
300/// Serialise a VCALENDAR [`Component`] and write it to `path`, creating
301/// parent directories as needed.
302async fn write_vcalendar_file(path: &Path, vcal: &Component) -> anyhow::Result<()> {
303    if let Some(parent) = path.parent() {
304        tokio::fs::create_dir_all(parent).await?;
305    }
306    let content = write_component(vcal);
307    tokio::fs::write(path, content)
308        .await
309        .context("Error while writing `VCALENDAR` content")?;
310    Ok(())
311}
312
313fn generate_uid() -> String {
314    Uuid::new_v4().to_string()
315}
316
317impl VjournalState {
318    fn new() -> Self {
319        Self {
320            next_id: 0,
321            uid_to_id: HashMap::new(),
322            id_to_location: HashMap::new(),
323        }
324    }
325
326    /// Return the existing id for `uid`, or assign a fresh one.  Either way
327    /// the id-to-location mapping is updated to point at `file_path`.
328    fn assign_id(&mut self, uid: &str, file_path: &Path, given_id: Option<u32>) -> u32 {
329        let id = *self.uid_to_id.entry(uid.to_string()).or_insert_with(|| {
330            given_id.unwrap_or_else(|| {
331                let fresh_id = self.next_id;
332                self.next_id += 1;
333                fresh_id
334            })
335        });
336        self.next_id = self.next_id.max(id + 1);
337
338        let location = EntryLocation {
339            file_path: file_path.to_path_buf(),
340            uid: uid.to_string(),
341        };
342        self.id_to_location.insert(id, location);
343        id
344    }
345
346    fn remove_id(&mut self, id: u32) {
347        if let Some(loc) = self.id_to_location.remove(&id) {
348            self.uid_to_id.remove(&loc.uid);
349        }
350    }
351}