1mod 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
19pub struct VjournalDataProvide {
21 directory: PathBuf,
23 state: VjournalState,
25}
26
27struct EntryLocation {
28 file_path: PathBuf,
30 uid: String,
35}
36
37struct VjournalState {
43 next_id: u32,
45 uid_to_id: HashMap<String, u32>,
47 id_to_location: HashMap<u32, EntryLocation>,
49}
50
51impl VjournalDataProvide {
52 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 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 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
270async 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
292async 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
300async 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 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}