Skip to main content

backend/
lib.rs

1use chrono::{DateTime, Utc};
2
3use serde::{Deserialize, Serialize};
4
5#[cfg(feature = "json")]
6mod json;
7#[cfg(feature = "json")]
8pub use json::JsonDataProvide;
9
10#[cfg(feature = "sqlite")]
11mod sqlite;
12#[cfg(feature = "sqlite")]
13pub use sqlite::SqliteDataProvide;
14
15#[cfg(feature = "vjournal")]
16mod vjournal;
17#[cfg(feature = "vjournal")]
18pub use vjournal::VjournalDataProvide;
19
20pub const TRANSFER_DATA_VERSION: u16 = 100;
21
22#[derive(Debug, thiserror::Error)]
23pub enum ModifyEntryError {
24    #[error("{0}")]
25    ValidationError(String),
26    #[error("{0}")]
27    DataError(#[from] anyhow::Error),
28}
29
30// The warning can be suppressed since this will be used with the code base of this app only
31#[allow(async_fn_in_trait)]
32pub trait DataProvider {
33    async fn load_all_entries(&mut self) -> anyhow::Result<Vec<Entry>>;
34    async fn add_entry(&mut self, entry: EntryDraft) -> Result<Entry, ModifyEntryError>;
35    /// Restores an entry with its existing id. Implementations must not overwrite another entry.
36    async fn restore_entry(&mut self, entry: Entry) -> Result<Entry, ModifyEntryError>;
37    async fn remove_entry(&mut self, entry_id: u32) -> anyhow::Result<()>;
38    async fn update_entry(&mut self, entry: Entry) -> Result<Entry, ModifyEntryError>;
39    async fn get_export_object(&mut self, entries_ids: &[u32]) -> anyhow::Result<EntriesDTO>;
40    async fn import_entries(&mut self, entries_dto: EntriesDTO) -> anyhow::Result<()> {
41        debug_assert_eq!(
42            TRANSFER_DATA_VERSION, entries_dto.version,
43            "Version mismatches check if there is a need to do a converting to the data"
44        );
45
46        for entry_draft in entries_dto.entries {
47            self.add_entry(entry_draft).await?;
48        }
49
50        Ok(())
51    }
52    /// Assigns priority to all entries that don't have a priority assigned to
53    async fn assign_priority_to_entries(&mut self, priority: u32) -> anyhow::Result<()>;
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Entry {
58    pub id: u32,
59    pub date: DateTime<Utc>,
60    pub title: String,
61    pub content: String,
62    #[serde(default)]
63    pub tags: Vec<String>,
64    #[serde(default)]
65    pub priority: Option<u32>,
66}
67
68impl Entry {
69    #[allow(dead_code)]
70    pub fn new(
71        id: u32,
72        date: DateTime<Utc>,
73        title: String,
74        content: String,
75        tags: Vec<String>,
76        priority: Option<u32>,
77    ) -> Self {
78        Self {
79            id,
80            date,
81            title,
82            content,
83            tags,
84            priority,
85        }
86    }
87
88    pub fn from_draft(id: u32, draft: EntryDraft) -> Self {
89        Self {
90            id,
91            date: draft.date,
92            title: draft.title,
93            content: draft.content,
94            tags: draft.tags,
95            priority: draft.priority,
96        }
97    }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct EntryDraft {
102    pub date: DateTime<Utc>,
103    pub title: String,
104    pub content: String,
105    pub tags: Vec<String>,
106    pub priority: Option<u32>,
107}
108
109impl EntryDraft {
110    pub fn new(
111        date: DateTime<Utc>,
112        title: String,
113        tags: Vec<String>,
114        priority: Option<u32>,
115    ) -> Self {
116        let content = String::new();
117        Self {
118            date,
119            title,
120            content,
121            tags,
122            priority,
123        }
124    }
125
126    #[must_use]
127    pub fn with_content(mut self, content: String) -> Self {
128        self.content = content;
129        self
130    }
131
132    pub fn from_entry(entry: Entry) -> Self {
133        Self {
134            date: entry.date,
135            title: entry.title,
136            content: entry.content,
137            tags: entry.tags,
138            priority: entry.priority,
139        }
140    }
141}
142
143/// Entries data transfer object
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct EntriesDTO {
146    pub version: u16,
147    pub entries: Vec<EntryDraft>,
148}
149
150impl EntriesDTO {
151    pub fn new(entries: Vec<EntryDraft>) -> Self {
152        Self {
153            version: TRANSFER_DATA_VERSION,
154            entries,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use chrono::TimeZone;
162
163    use super::*;
164
165    fn sample_draft() -> EntryDraft {
166        EntryDraft {
167            date: Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(),
168            title: String::from("Draft"),
169            content: String::from("Body"),
170            tags: vec![String::from("one"), String::from("two")],
171            priority: Some(3),
172        }
173    }
174
175    struct ImportStubProvider {
176        added_entries: Vec<EntryDraft>,
177        fail_on_call: Option<usize>,
178    }
179
180    impl ImportStubProvider {
181        fn new(fail_on_call: Option<usize>) -> Self {
182            Self {
183                added_entries: Vec::new(),
184                fail_on_call,
185            }
186        }
187    }
188
189    impl DataProvider for ImportStubProvider {
190        async fn load_all_entries(&mut self) -> anyhow::Result<Vec<Entry>> {
191            unreachable!("not used in these tests");
192        }
193
194        async fn add_entry(&mut self, entry: EntryDraft) -> Result<Entry, ModifyEntryError> {
195            let call_idx = self.added_entries.len();
196            self.added_entries.push(entry.clone());
197
198            if self.fail_on_call == Some(call_idx) {
199                return Err(ModifyEntryError::ValidationError(format!(
200                    "fail on {call_idx}"
201                )));
202            }
203
204            Ok(Entry::from_draft(call_idx as u32, entry))
205        }
206
207        async fn restore_entry(&mut self, _entry: Entry) -> Result<Entry, ModifyEntryError> {
208            unreachable!("not used in these tests");
209        }
210
211        async fn remove_entry(&mut self, _entry_id: u32) -> anyhow::Result<()> {
212            unreachable!("not used in these tests");
213        }
214
215        async fn update_entry(&mut self, _entry: Entry) -> Result<Entry, ModifyEntryError> {
216            unreachable!("not used in these tests");
217        }
218
219        async fn get_export_object(&mut self, _entries_ids: &[u32]) -> anyhow::Result<EntriesDTO> {
220            unreachable!("not used in these tests");
221        }
222
223        async fn assign_priority_to_entries(&mut self, _priority: u32) -> anyhow::Result<()> {
224            unreachable!("not used in these tests");
225        }
226    }
227
228    #[test]
229    fn draft_to_entry() {
230        let draft = sample_draft();
231
232        let entry = Entry::from_draft(7, draft.clone());
233
234        assert_eq!(entry.id, 7);
235        assert_eq!(entry.date, draft.date);
236        assert_eq!(entry.title, draft.title);
237        assert_eq!(entry.content, draft.content);
238        assert_eq!(entry.tags, draft.tags);
239        assert_eq!(entry.priority, draft.priority);
240    }
241
242    #[test]
243    fn with_content_replaces_only_body() {
244        let draft = sample_draft();
245
246        let updated = draft.clone().with_content(String::from("Updated"));
247
248        assert_eq!(updated.content, "Updated");
249        assert_eq!(updated.date, draft.date);
250        assert_eq!(updated.title, draft.title);
251        assert_eq!(updated.tags, draft.tags);
252        assert_eq!(updated.priority, draft.priority);
253    }
254
255    #[test]
256    fn from_entry_drops_id_only() {
257        let entry = Entry::new(
258            11,
259            Utc.with_ymd_and_hms(2023, 11, 12, 13, 14, 15).unwrap(),
260            String::from("Title"),
261            String::from("Content"),
262            vec![String::from("tag")],
263            Some(2),
264        );
265
266        let draft = EntryDraft::from_entry(entry.clone());
267
268        assert_eq!(draft.date, entry.date);
269        assert_eq!(draft.title, entry.title);
270        assert_eq!(draft.content, entry.content);
271        assert_eq!(draft.tags, entry.tags);
272        assert_eq!(draft.priority, entry.priority);
273    }
274
275    #[test]
276    fn dto_sets_version() {
277        let dto = EntriesDTO::new(vec![sample_draft()]);
278
279        assert_eq!(dto.version, TRANSFER_DATA_VERSION);
280        assert_eq!(dto.entries, vec![sample_draft()]);
281    }
282
283    #[tokio::test]
284    async fn import_entries_keeps_order() {
285        let mut provider = ImportStubProvider::new(None);
286        let entries = vec![
287            sample_draft(),
288            EntryDraft::new(
289                Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(),
290                String::from("Second"),
291                vec![String::from("x")],
292                None,
293            ),
294        ];
295
296        provider
297            .import_entries(EntriesDTO::new(entries.clone()))
298            .await
299            .unwrap();
300
301        assert_eq!(provider.added_entries, entries);
302    }
303
304    #[tokio::test]
305    async fn import_entries_stops_on_error() {
306        let mut provider = ImportStubProvider::new(Some(1));
307        let entries = vec![
308            sample_draft(),
309            EntryDraft::new(
310                Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
311                String::from("Second"),
312                vec![],
313                None,
314            ),
315            EntryDraft::new(
316                Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap(),
317                String::from("Third"),
318                vec![],
319                None,
320            ),
321        ];
322
323        let err = provider
324            .import_entries(EntriesDTO::new(entries.clone()))
325            .await
326            .unwrap_err();
327
328        assert_eq!(err.to_string(), "fail on 1");
329
330        // The stub records the draft before failing, so the third entry proves import stopped.
331        assert_eq!(provider.added_entries, entries[..2]);
332    }
333}