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.
9
10use crate::backend::{QtThread, Shared, json, load_config, say, tr};
11use serde::Serialize;
12use sioul_core::letters::{Letter, Letters};
13use sioul_core::tasks::{Link, TaskEdit};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17static BUSY: Mutex<()> = Mutex::new(());
18/// Why scans could not be read lately (no OCR program): said on the cards' place.
19static MISSING: Mutex<String> = Mutex::new(String::new());
20
21const SCANS: &[&str] = &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "webp"];
22
23/// The inbox: the folder chosen, else `letters/inbox` in the notes folder.
24pub(crate) fn inbox() -> Option<PathBuf> {
25    let config = load_config();
26    match config.letters.inbox.as_deref().filter(|f| !f.trim().is_empty()) {
27        Some(folder) => Some(sioul_core::config::expand_home(folder)),
28        None => config.notes_root_path().map(|root| Letters::folder(&root).join("inbox")),
29    }
30}
31
32fn source_of(path: &Path) -> String {
33    let meta = std::fs::metadata(path).ok();
34    let size = meta.as_ref().map_or(0, |m| m.len());
35    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());
36    format!("{}|{size}|{time}", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
37}
38
39/// The scans in the inbox not read yet, read; off the window's thread.
40pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
41    let (qt, shared) = (qt.clone(), Arc::clone(shared));
42    std::thread::spawn(move || {
43        let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
44        let config = load_config();
45        let looked = sioul_core::words::Words::of(&config);
46        let (Some(inbox), Some(root)) = (inbox(), config.notes_root_path()) else { return };
47        let Ok(entries) = std::fs::read_dir(&inbox) else { return };
48        let Ok(letters) = Letters::load(&root) else { return };
49        let known: Vec<String> = letters.list.iter().map(|l| l.source.clone()).collect();
50        // Read first (seconds each), then added to the letters as the file holds them then.
51        let mut read: Vec<Letter> = Vec::new();
52        let projects = sioul_core::projects::ProjectStore::load(&root).map(|s| s.projects).unwrap_or_default();
53        let mut added = 0;
54        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()))) {
55            let source = source_of(&path);
56            if known.contains(&source) {
57                continue;
58            }
59            // A file still being written (a scanner, a sync) is read next time.
60            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) {
61                continue;
62            }
63            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());
64            let id = format!("{received}-{:08x}", source.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193)));
65            let work = sioul_core::config::cache_dir().join("ocr").join(&id);
66            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() };
67            match sioul_sync::ocr::text_of(&path, &work, &looked.ocr.tesseract) {
68                Ok(text) => {
69                    letter.reading = sioul_core::letters::read(&looked, &text, received);
70                    letter.project = sioul_core::letters::project_of(&text, &letter.reading.sender, &projects).unwrap_or_default();
71                    let _ = letters.save_text(&id, &text);
72                    if let Ok(mut missing) = MISSING.lock() {
73                        missing.clear();
74                    }
75                }
76                Err(sioul_sync::ocr::Unread::Missing(hint)) => {
77                    // Kept, unread: it can still be filed by hand; read once a program is there.
78                    if let Ok(mut missing) = MISSING.lock() {
79                        *missing = hint;
80                    }
81                    continue;
82                }
83                Err(sioul_sync::ocr::Unread::Failed(e)) => letter.problem = e,
84            }
85            read.push(letter);
86            added += 1;
87        }
88        let taken = added > 0
89            && Letters::change(&root, |letters| {
90                for letter in read {
91                    if !letters.list.iter().any(|l| l.source == letter.source) {
92                        letters.put(letter);
93                    }
94                }
95            })
96            .is_ok();
97        if taken {
98            let _ = qt.queue(|mut sioul| sioul.as_mut().letters_changed());
99            let _ = shared;
100        }
101    });
102}
103
104#[derive(Serialize)]
105struct LetterView {
106    id: String,
107    sender: String,
108    kind: &'static str,
109    kind_label: String,
110    received: String,
111    /// What it says, a sentence each: what it asks, by when, registered, your number.
112    lines: Vec<String>,
113    /// Its scan, where it is on this computer.
114    file: String,
115    deadline: String,
116    appointment: bool,
117    task: String,
118    project: String,
119    problem: String,
120}
121
122#[derive(Serialize)]
123struct ProjectChoice {
124    id: String,
125    title: String,
126}
127
128#[derive(Serialize, Default)]
129struct View {
130    inbox: String,
131    letters: Vec<LetterView>,
132    projects: Vec<ProjectChoice>,
133    /// No OCR program: how to install one.
134    missing: String,
135}
136
137/// The letters not done with, as JSON.
138pub(crate) fn view() -> String {
139    let mut view = View { inbox: inbox().map(|p| p.display().to_string()).unwrap_or_default(), missing: MISSING.lock().map(|m| m.clone()).unwrap_or_default(), ..View::default() };
140    let Some(root) = load_config().notes_root_path() else { return json(&view) };
141    let Ok(letters) = Letters::load(&root) else { return json(&view) };
142    let today = jiff::Zoned::now().date();
143    let projects = sioul_core::projects::ProjectStore::load(&root).map(|s| s.projects).unwrap_or_default();
144    view.projects = projects.iter().filter(|c| c.status.as_deref() != Some("closed")).map(|c| ProjectChoice { id: c.id.clone(), title: c.title.clone() }).collect();
145    for letter in letters.list.iter().filter(|l| l.status != "done") {
146        let r = &letter.reading;
147        let day = |d: jiff::civil::Date| tr().day_in(d, today);
148        let mut lines = Vec::new();
149        if let Some(amount) = r.amount {
150            lines.push(say("letter-amount", &[("amount", tr().money(amount))]));
151        }
152        if let Some(deadline) = r.deadline {
153            lines.push(say("letter-deadline", &[("date", day(deadline)), ("why", r.why.clone())]));
154        }
155        if let Some((date, time)) = r.appointment {
156            let when = match time {
157                Some((h, m)) => format!("{} {h:02}:{m:02}", day(date)),
158                None => day(date),
159            };
160            lines.push(say("letter-appointment", &[("when", when)]));
161        }
162        if r.registered {
163            lines.push(tr().text("letter-registered", None));
164        }
165        if let Some(dated) = r.dated {
166            lines.push(say("letter-dated", &[("date", day(dated))]));
167        }
168        if !r.reference.is_empty() {
169            lines.push(say("letter-reference", &[("reference", r.reference.clone())]));
170        }
171        view.letters.push(LetterView {
172            id: letter.id.clone(),
173            sender: if r.sender.is_empty() { tr().text("letter-unknown-sender", None) } else { r.sender.clone() },
174            kind: r.kind.id(),
175            kind_label: tr().text(&format!("letter-kind-{}", r.kind.id()), None),
176            received: letter.received.map(day).unwrap_or_default(),
177            lines,
178            file: letters.file_path(letter).display().to_string(),
179            deadline: r.deadline.map(|d| d.to_string()).unwrap_or_default(),
180            appointment: r.appointment.is_some(),
181            task: letter.task.clone(),
182            project: letter.project.clone(),
183            problem: letter.problem.clone(),
184        });
185    }
186    json(&view)
187}
188
189fn with_letter(id: &str, change: impl FnOnce(&mut Letters, &mut Letter) -> Result<String, String>) -> String {
190    let result = (|| {
191        let root = load_config().notes_root_path().ok_or_else(|| tr().text("papers-no-store", None))?;
192        // Read, changed and written under the file's lock: over the letters as they are then.
193        Letters::try_change(&root, |letters| {
194            let mut letter = letters.get(id).cloned().ok_or_else(|| tr().text("papers-gone", None))?;
195            let said = change(letters, &mut letter)?;
196            letters.put(letter);
197            Ok(said)
198        })
199    })();
200    match result {
201        Ok(said) => said,
202        Err(e) => e,
203    }
204}
205
206/// A task for its date, in your usual list, tied to its scan and its project.
207pub(crate) fn make_task(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
208    with_letter(id, |letters, letter| {
209        // Filed first: the task's link holds where the scan stays.
210        letters.file_away(letter)?;
211        let r = &letter.reading;
212        let amount = r.amount.map(|a| tr().money(a)).unwrap_or_default();
213        let title = say(&format!("letter-task-{}", r.kind.id()), &[("sender", r.sender.clone()), ("amount", amount)]);
214        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");
215        let scan = crate::backend::file_url(&letters.file_path(letter));
216        let edit = TaskEdit {
217            title: title.trim().to_string(),
218            notes,
219            due: r.deadline.map(|d| d.to_string()).unwrap_or_default(),
220            estimate: 20,
221            projects: if letter.project.is_empty() { Vec::new() } else { vec![letter.project.clone()] },
222            links: vec![Link { uri: scan, label: tr().text("letter-scan", None), rel: "describedby".into() }],
223            ..TaskEdit::default()
224        };
225        let list = sioul_core::tasks::default_list().map(|l| format!("{}/{}", l.account, l.id));
226        let uid = match list {
227            Some(list) => crate::work::create_task(qt, shared, &edit, &list)?,
228            None => crate::work::local_task(qt, shared, &tr().text("papers-list", None), &edit)?,
229        };
230        letter.task = uid;
231        Ok(String::new())
232    })
233}
234
235/// Its appointment in the agenda, an hour long, tied to its scan (filed first, so the tie holds).
236pub(crate) fn make_event(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
237    let filed = with_letter(id, |letters, letter| letters.file_away(letter).map(|()| String::new()));
238    if !filed.is_empty() {
239        return filed;
240    }
241    let root = load_config().notes_root_path();
242    let letter = root.as_deref().and_then(|r| Letters::load(r).ok()).and_then(|l| Some((l.get(id)?.clone(), l)));
243    let Some((letter, letters)) = letter else { return tr().text("papers-gone", None) };
244    let Some((date, time)) = letter.reading.appointment else { return String::new() };
245    let (start, end, all_day) = match time {
246        // An hour long, or to the end of the day for one at 23:00 or later.
247        Some((h, m)) if h >= 23 => (format!("{date}T{h:02}:{m:02}"), format!("{date}T23:59"), false),
248        Some((h, m)) => (format!("{date}T{h:02}:{m:02}"), format!("{date}T{:02}:{m:02}", h + 1), false),
249        None => (date.to_string(), date.to_string(), true),
250    };
251    let edit = serde_json::json!({
252        "title": say("letter-event", &[("sender", letter.reading.sender.clone())]),
253        "notes": letter.reading.why,
254        "start": start,
255        "end": end,
256        "all_day": all_day,
257        "links": [crate::backend::file_url(&letters.file_path(&letter))],
258    });
259    crate::pim::save_event(qt, shared, "", &edit.to_string(), "").err().unwrap_or_default()
260}
261
262/// Done with it: its project set, its scan filed in `letters/<year>/`.
263pub(crate) fn done(id: &str, project: &str) -> String {
264    with_letter(id, |letters, letter| {
265        if !project.is_empty() {
266            letter.project = project.to_string();
267        }
268        letters.file_away(letter)?;
269        letter.status = "done".into();
270        Ok(String::new())
271    })
272}