Skip to main content

sioul_app/
letters.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Paper letters, for the window (docs/porch.md, "Paper letters"): the scans
5//! dropped in the inbox folder read in the background (no notification: they
6//! wait for the Porch's window, as mail does), then each a card there: who,
7//! what, how much, by when; a task for its date, its appointment in the agenda,
8//! its scan filed with its project. The place is there before any scan: the
9//! folder they go to, opened or added to from the Porch, and whether this
10//! computer reads them (`reader`).
11
12use crate::backend::{QtThread, Shared, json, load_config, say, tr};
13use serde::Serialize;
14use sioul_core::letters::{Letter, Letters};
15use sioul_core::tasks::{Link, TaskEdit};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19static BUSY: Mutex<()> = Mutex::new(());
20/// Why scans could not be read lately (no OCR program): said on the cards' place.
21static MISSING: Mutex<String> = Mutex::new(String::new());
22/// Whether this computer reads scans, and when that was asked: "" when it
23/// does, else how to install what reads them. None until the first tick asks.
24static READER: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);
25/// How long an answer about the reading programs holds: one installed meanwhile is seen within it.
26const READER_FRESH: std::time::Duration = std::time::Duration::from_secs(600);
27
28const SCANS: &[&str] = &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "webp"];
29
30/// The inbox: the folder chosen, else `letters/inbox` in the notes folder.
31pub(crate) fn inbox() -> Option<PathBuf> {
32    let config = load_config();
33    match config.letters.inbox.as_deref().filter(|f| !f.trim().is_empty()) {
34        Some(folder) => Some(sioul_core::config::expand_home(folder)),
35        None => config.notes_root_path().map(|root| Letters::folder(&root).join("inbox")),
36    }
37}
38
39fn source_of(path: &Path) -> String {
40    let meta = std::fs::metadata(path).ok();
41    let size = meta.as_ref().map_or(0, |m| m.len());
42    let time = meta.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs());
43    format!("{}|{size}|{time}", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
44}
45
46/// The scans in the inbox not read yet, read; off the window's thread. A
47/// phone reads none (docs/porch.md, "Paper letters"): a computer does, and the
48/// cards it makes reach the phone with your notes.
49pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
50    if cfg!(target_os = "android") {
51        return;
52    }
53    let (qt, shared) = (qt.clone(), Arc::clone(shared));
54    std::thread::spawn(move || {
55        let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
56        let config = load_config();
57        let looked = sioul_core::words::Words::of(&config);
58        // Whether reading works here, said on the Porch before any scan comes; asked again every ten minutes.
59        let stale = READER.lock().map(|r| r.as_ref().is_none_or(|(at, _)| at.elapsed() >= READER_FRESH)).unwrap_or(false);
60        if stale {
61            let said = if sioul_sync::ocr::reads_here() { String::new() } else { sioul_sync::ocr::install_hint(&looked.ocr.tesseract) };
62            let changed = READER.lock().map(|mut r| {
63                let changed = r.as_ref().is_none_or(|(_, before)| *before != said);
64                *r = Some((std::time::Instant::now(), said));
65                changed
66            });
67            if changed.unwrap_or(false) {
68                let _ = qt.queue(|mut sioul| sioul.as_mut().letters_changed());
69            }
70        }
71        let (Some(inbox), Some(root)) = (inbox(), config.notes_root_path()) else { return };
72        let Ok(entries) = std::fs::read_dir(&inbox) else { return };
73        let Ok(letters) = Letters::load(&root) else { return };
74        let known: Vec<String> = letters.list.iter().map(|l| l.source.clone()).collect();
75        // Read first (seconds each), then added to the letters as the file holds them then.
76        let mut read: Vec<Letter> = Vec::new();
77        let projects = sioul_core::projects::ProjectStore::load(&root).map(|s| s.projects).unwrap_or_default();
78        let mut added = 0;
79        for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_file() && p.extension().is_some_and(|e| SCANS.contains(&e.to_string_lossy().to_ascii_lowercase().as_str()))) {
80            let source = source_of(&path);
81            if known.contains(&source) {
82                continue;
83            }
84            // A file still being written (a scanner, a sync) is read next time.
85            if std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() < 20) {
86                continue;
87            }
88            let received = std::fs::metadata(&path).and_then(|m| m.modified()).ok().map(|t| jiff::Timestamp::try_from(t).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).date()).unwrap_or_else(|_| jiff::Zoned::now().date())).unwrap_or_else(|| jiff::Zoned::now().date());
89            let id = format!("{received}-{:08x}", source.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193)));
90            let work = sioul_core::config::cache_dir().join("ocr").join(&id);
91            let mut letter = Letter { id: id.clone(), file: path.strip_prefix(&root).map_or_else(|_| path.display().to_string(), |p| p.to_string_lossy().replace('\\', "/")), source, received: Some(received), status: "new".into(), ..Letter::default() };
92            match sioul_sync::ocr::text_of(&path, &work, &looked.ocr.tesseract) {
93                Ok(text) => {
94                    letter.reading = sioul_core::letters::read(&looked, &text, received);
95                    letter.project = sioul_core::letters::project_of(&text, &letter.reading.sender, &projects).unwrap_or_default();
96                    let _ = letters.save_text(&id, &text);
97                    if let Ok(mut missing) = MISSING.lock() {
98                        missing.clear();
99                    }
100                }
101                Err(sioul_sync::ocr::Unread::Missing(hint)) => {
102                    // Kept, unread: it can still be filed by hand; read once a program is there.
103                    if let Ok(mut missing) = MISSING.lock() {
104                        *missing = hint;
105                    }
106                    continue;
107                }
108                Err(sioul_sync::ocr::Unread::Failed(e)) => letter.problem = e,
109            }
110            read.push(letter);
111            added += 1;
112        }
113        let taken = added > 0
114            && Letters::change(&root, |letters| {
115                for letter in read {
116                    if !letters.list.iter().any(|l| l.source == letter.source) {
117                        letters.put(letter);
118                    }
119                }
120            })
121            .is_ok();
122        if taken {
123            let _ = qt.queue(|mut sioul| sioul.as_mut().letters_changed());
124            let _ = shared;
125        }
126    });
127}
128
129#[derive(Serialize)]
130struct LetterView {
131    id: String,
132    sender: String,
133    kind: &'static str,
134    kind_label: String,
135    received: String,
136    /// What it says, a sentence each: what it asks, by when, registered, your number.
137    lines: Vec<String>,
138    /// Its scan, where it is on this computer.
139    file: String,
140    deadline: String,
141    appointment: bool,
142    task: String,
143    project: String,
144    problem: String,
145}
146
147#[derive(Serialize)]
148struct ProjectChoice {
149    id: String,
150    title: String,
151}
152
153#[derive(Serialize, Default)]
154struct View {
155    inbox: String,
156    letters: Vec<LetterView>,
157    projects: Vec<ProjectChoice>,
158    /// No OCR program, and scans waiting: how to install one.
159    missing: String,
160    /// No OCR program, before any scan: how to install one ("" when this computer reads scans, or not known yet).
161    reader: String,
162}
163
164/// The letters not done with, as JSON.
165pub(crate) fn view() -> String {
166    let mut view = View {
167        inbox: inbox().map(|p| p.display().to_string()).unwrap_or_default(),
168        missing: MISSING.lock().map(|m| m.clone()).unwrap_or_default(),
169        reader: READER.lock().ok().and_then(|r| r.as_ref().map(|(_, said)| said.clone())).unwrap_or_default(),
170        ..View::default()
171    };
172    let Some(root) = load_config().notes_root_path() else { return json(&view) };
173    let Ok(letters) = Letters::load(&root) else { return json(&view) };
174    let today = jiff::Zoned::now().date();
175    let projects = sioul_core::projects::ProjectStore::load(&root).map(|s| s.projects).unwrap_or_default();
176    view.projects = projects.iter().filter(|c| c.status.as_deref() != Some("closed")).map(|c| ProjectChoice { id: c.id.clone(), title: c.title.clone() }).collect();
177    for letter in letters.list.iter().filter(|l| l.status != "done") {
178        let r = &letter.reading;
179        let day = |d: jiff::civil::Date| tr().day_in(d, today);
180        let mut lines = Vec::new();
181        if let Some(amount) = r.amount {
182            lines.push(say("letter-amount", &[("amount", tr().money(amount))]));
183        }
184        if let Some(deadline) = r.deadline {
185            lines.push(say("letter-deadline", &[("date", day(deadline)), ("why", r.why.clone())]));
186        }
187        if let Some((date, time)) = r.appointment {
188            let when = match time {
189                Some((h, m)) => format!("{} {h:02}:{m:02}", day(date)),
190                None => day(date),
191            };
192            lines.push(say("letter-appointment", &[("when", when)]));
193        }
194        if r.registered {
195            lines.push(tr().text("letter-registered", None));
196        }
197        if let Some(dated) = r.dated {
198            lines.push(say("letter-dated", &[("date", day(dated))]));
199        }
200        if !r.reference.is_empty() {
201            lines.push(say("letter-reference", &[("reference", r.reference.clone())]));
202        }
203        view.letters.push(LetterView {
204            id: letter.id.clone(),
205            sender: if r.sender.is_empty() { tr().text("letter-unknown-sender", None) } else { r.sender.clone() },
206            kind: r.kind.id(),
207            kind_label: tr().text(&format!("letter-kind-{}", r.kind.id()), None),
208            received: letter.received.map(day).unwrap_or_default(),
209            lines,
210            file: letters.file_path(letter).display().to_string(),
211            deadline: r.deadline.map(|d| d.to_string()).unwrap_or_default(),
212            appointment: r.appointment.is_some(),
213            task: letter.task.clone(),
214            project: letter.project.clone(),
215            problem: letter.problem.clone(),
216        });
217    }
218    json(&view)
219}
220
221fn with_letter(id: &str, change: impl FnOnce(&mut Letters, &mut Letter) -> Result<String, String>) -> String {
222    let result = (|| {
223        let root = load_config().notes_root_path().ok_or_else(|| tr().text("papers-no-store", None))?;
224        // Read, changed and written under the file's lock: over the letters as they are then.
225        Letters::try_change(&root, |letters| {
226            let mut letter = letters.get(id).cloned().ok_or_else(|| tr().text("papers-gone", None))?;
227            let said = change(letters, &mut letter)?;
228            letters.put(letter);
229            Ok(said)
230        })
231    })();
232    match result {
233        Ok(said) => said,
234        Err(e) => e,
235    }
236}
237
238/// A task for its date, in your usual list, tied to its scan and its project.
239pub(crate) fn make_task(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
240    with_letter(id, |letters, letter| {
241        // Filed first: the task's link holds where the scan stays.
242        letters.file_away(letter)?;
243        let r = &letter.reading;
244        let amount = r.amount.map(|a| tr().money(a)).unwrap_or_default();
245        let title = say(&format!("letter-task-{}", r.kind.id()), &[("sender", r.sender.clone()), ("amount", amount)]);
246        let notes = [r.why.clone(), if r.reference.is_empty() { String::new() } else { say("letter-reference", &[("reference", r.reference.clone())]) }].into_iter().filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
247        let scan = crate::backend::file_url(&letters.file_path(letter));
248        let edit = TaskEdit {
249            title: title.trim().to_string(),
250            notes,
251            due: r.deadline.map(|d| d.to_string()).unwrap_or_default(),
252            estimate: 20,
253            projects: if letter.project.is_empty() { Vec::new() } else { vec![letter.project.clone()] },
254            links: vec![Link { uri: scan, label: tr().text("letter-scan", None), rel: "describedby".into() }],
255            ..TaskEdit::default()
256        };
257        let list = sioul_core::tasks::default_list().map(|l| format!("{}/{}", l.account, l.id));
258        let uid = match list {
259            Some(list) => crate::work::create_task(qt, shared, &edit, &list)?,
260            None => crate::work::local_task(qt, shared, &tr().text("papers-list", None), &edit)?,
261        };
262        letter.task = uid;
263        Ok(String::new())
264    })
265}
266
267/// Its appointment in the agenda, an hour long, tied to its scan (filed first, so the tie holds).
268pub(crate) fn make_event(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
269    let filed = with_letter(id, |letters, letter| letters.file_away(letter).map(|()| String::new()));
270    if !filed.is_empty() {
271        return filed;
272    }
273    let root = load_config().notes_root_path();
274    let letter = root.as_deref().and_then(|r| Letters::load(r).ok()).and_then(|l| Some((l.get(id)?.clone(), l)));
275    let Some((letter, letters)) = letter else { return tr().text("papers-gone", None) };
276    let Some((date, time)) = letter.reading.appointment else { return String::new() };
277    let (start, end, all_day) = match time {
278        // An hour long, or to the end of the day for one at 23:00 or later.
279        Some((h, m)) if h >= 23 => (format!("{date}T{h:02}:{m:02}"), format!("{date}T23:59"), false),
280        Some((h, m)) => (format!("{date}T{h:02}:{m:02}"), format!("{date}T{:02}:{m:02}", h + 1), false),
281        None => (date.to_string(), date.to_string(), true),
282    };
283    let edit = serde_json::json!({
284        "title": say("letter-event", &[("sender", letter.reading.sender.clone())]),
285        "notes": letter.reading.why,
286        "start": start,
287        "end": end,
288        "all_day": all_day,
289        "links": [crate::backend::file_url(&letters.file_path(&letter))],
290    });
291    crate::pim::save_event(qt, shared, "", &edit.to_string(), "").err().unwrap_or_default()
292}
293
294/// Done with it: its project set, its scan filed in `letters/<year>/`.
295pub(crate) fn done(id: &str, project: &str) -> String {
296    with_letter(id, |letters, letter| {
297        if !project.is_empty() {
298            letter.project = project.to_string();
299        }
300        letters.file_away(letter)?;
301        letter.status = "done".into();
302        Ok(String::new())
303    })
304}
305
306/// The folder scans arrive in, made if it is not there yet; "" without one
307/// (no folder chosen and no notes folder), or when it cannot be made.
308pub(crate) fn folder() -> String {
309    let Some(inbox) = inbox() else { return String::new() };
310    if std::fs::create_dir_all(&inbox).is_err() {
311        return String::new();
312    }
313    inbox.display().to_string()
314}
315
316/// A name free in `folder` for a file named `name`: itself, else "scan (2).pdf", "scan (3).pdf"…
317fn free_name(folder: &Path, name: &str) -> PathBuf {
318    let first = folder.join(name);
319    if !first.exists() {
320        return first;
321    }
322    let path = Path::new(name);
323    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
324    let extension = path.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
325    (2..)
326        .map(|n| folder.join(format!("{stem} ({n}){extension}")))
327        .find(|candidate| !candidate.exists())
328        .unwrap_or(first)
329}
330
331/// Scans chosen on this computer (a JSON array of `file://` addresses, or of
332/// paths), copied into the folder they are read from: what happened, in a
333/// sentence. The copy is read at a coming minute, as a scanner's file is; the
334/// chosen file stays where it was.
335pub(crate) fn add(files: &str) -> String {
336    let Some(inbox) = inbox() else { return tr().text("letters-no-folder", None) };
337    let chosen: Vec<String> = serde_json::from_str(files).unwrap_or_default();
338    if let Err(e) = std::fs::create_dir_all(&inbox) {
339        return say("letters-folder-failed", &[("folder", inbox.display().to_string()), ("detail", e.to_string())]);
340    }
341    let mut copied = 0usize;
342    let mut problems: Vec<String> = Vec::new();
343    for url in chosen.iter().filter(|u| !u.trim().is_empty()) {
344        let source = crate::backend::local_path(url);
345        let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
346        let readable = source.extension().is_some_and(|e| SCANS.contains(&e.to_string_lossy().to_ascii_lowercase().as_str()));
347        if !readable {
348            problems.push(say("letters-add-not-scan", &[("name", name)]));
349            continue;
350        }
351        match std::fs::copy(&source, free_name(&inbox, &name)) {
352            Ok(_) => copied += 1,
353            Err(e) => problems.push(say("letters-add-failed", &[("name", name), ("detail", e.to_string())])),
354        }
355    }
356    let mut said: Vec<String> = Vec::new();
357    if copied > 0 {
358        said.push(tr().text("letters-added", Some(&tr().counted(copied))));
359    }
360    said.extend(problems);
361    said.join(" ")
362}
363
364#[cfg(test)]
365mod tests {
366    use super::free_name;
367
368    #[test]
369    fn a_scan_added_twice_keeps_both() {
370        let folder = std::env::temp_dir().join(format!("sioul-letters-free-{}", std::process::id()));
371        std::fs::create_dir_all(&folder).unwrap();
372        assert_eq!(free_name(&folder, "scan.pdf"), folder.join("scan.pdf"));
373        std::fs::write(folder.join("scan.pdf"), b"%PDF").unwrap();
374        assert_eq!(free_name(&folder, "scan.pdf"), folder.join("scan (2).pdf"));
375        std::fs::write(folder.join("scan (2).pdf"), b"%PDF").unwrap();
376        assert_eq!(free_name(&folder, "scan.pdf"), folder.join("scan (3).pdf"));
377        assert_eq!(free_name(&folder, "photo"), folder.join("photo"));
378        std::fs::remove_dir_all(&folder).unwrap();
379    }
380}