Skip to main content

sioul_core/
letters.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Paper letters (docs/porch.md, "Paper letters"): a scan, a phone photo or a
5//! PDF dropped in a folder (by you, or a helper who opens the post), read by
6//! OCR, then understood by rules (the `letters` word lists, docs/words.md):
7//! who sent it, what it is, the amount, and the
8//! date by which something is asked, as a date, legal delays included ("dans
9//! un délai de deux mois à compter de la notification"). It waits outside like
10//! mail, and comes as a card in the Porch's window, with a task for its date
11//! and its scan filed. The envelope stays outside; the date is computed once.
12//!
13//! The not-knowing costs more than the work; a date written "within 30 days"
14//! is remembered by no one (time-based remembering fails, Landsiedel et al.
15//! 2017); clearer material and the amount shown raise what people claim
16//! (Bhargava & Manoli 2015).
17//!
18//! `letters/letters.toml` in the notes folder, each letter's text beside it
19//! (`letters/<id>.txt`), the scans filed in `letters/<year>/`.
20
21use crate::money::Money;
22use crate::words::{LetterWords, Words};
23use jiff::ToSpan;
24use jiff::civil::Date;
25use serde::Serialize;
26use std::path::{Path, PathBuf};
27
28pub const FOLDER: &str = "letters";
29pub const MANIFEST: &str = "letters.toml";
30
31/// What a letter is.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum Kind {
35    /// "Mise en demeure": a last formal step before others.
36    FormalNotice,
37    /// A decision, with its ways of appeal.
38    Decision,
39    TaxNotice,
40    Reminder,
41    Bill,
42    Appointment,
43    Acknowledgment,
44    Attestation,
45    Contract,
46    #[default]
47    Other,
48}
49
50impl Kind {
51    pub fn id(self) -> &'static str {
52        match self {
53            Kind::FormalNotice => "formal-notice",
54            Kind::Decision => "decision",
55            Kind::TaxNotice => "tax-notice",
56            Kind::Reminder => "reminder",
57            Kind::Bill => "bill",
58            Kind::Appointment => "appointment",
59            Kind::Acknowledgment => "acknowledgment",
60            Kind::Attestation => "attestation",
61            Kind::Contract => "contract",
62            Kind::Other => "other",
63        }
64    }
65
66    pub fn of(id: &str) -> Kind {
67        [Kind::FormalNotice, Kind::Decision, Kind::TaxNotice, Kind::Reminder, Kind::Bill, Kind::Appointment, Kind::Acknowledgment, Kind::Attestation, Kind::Contract].into_iter().find(|k| k.id() == id).unwrap_or_default()
68    }
69}
70
71/// Folded words, punctuation as spaces, padded: " avis d impot 2026 ".
72fn plain(text: &str) -> String {
73    let words: String = crate::text::fold(text).into_iter().map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect();
74    format!(" {} ", words.split_whitespace().collect::<Vec<_>>().join(" "))
75}
76
77/// Whether a plain text holds one of the phrases, each as `plain` writes it, whole words.
78fn has(text: &str, phrases: &[String]) -> bool {
79    phrases.iter().any(|p| {
80        let p = plain(p);
81        let p = p.trim();
82        !p.is_empty() && text.contains(&format!(" {p} "))
83    })
84}
85
86/// What a letter says, by rules.
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct Reading {
89    pub sender: String,
90    pub kind: Kind,
91    pub amount: Option<Money>,
92    /// The letter's own date ("Paris, le 3 octobre 2026").
93    pub dated: Option<Date>,
94    /// The date by which something is asked, and the words that set it.
95    pub deadline: Option<Date>,
96    pub why: String,
97    /// An appointment: its day, and its time when written.
98    pub appointment: Option<(Date, Option<(i8, i8)>)>,
99    /// Sent registered, with acknowledgment of receipt.
100    pub registered: bool,
101    /// Your number with them: "Numéro allocataire : 1234567".
102    pub reference: String,
103}
104
105/// A list's words as `plain` writes them, without empty ones.
106fn normal(list: &[String]) -> Vec<String> {
107    list.iter().map(|w| plain(w).trim().to_string()).filter(|w| !w.is_empty()).collect()
108}
109
110/// Named lists by their number ("10" → the names of October), as `plain` writes them.
111fn numbered(list: &crate::words::Named) -> Vec<(i64, Vec<String>)> {
112    list.iter().filter_map(|(n, names)| Some((n.trim().parse().ok()?, normal(names)))).collect()
113}
114
115/// The dates written in a plain text, with where each starts (in words):
116/// "15 novembre 2026", "1er octobre 2026", "15/11/2026", "15.11.26", "November 15, 2026".
117fn dates(lw: &LetterWords, words: &[&str]) -> Vec<(usize, Date)> {
118    let mut out = Vec::new();
119    let months = numbered(&lw.months);
120    let ordinals = normal(&lw.ordinals);
121    let month = |w: &str| months.iter().find(|(_, names)| names.iter().any(|n| n == w)).and_then(|(m, _)| i8::try_from(*m).ok());
122    let year = |w: &str| w.parse::<i16>().ok().filter(|y| (1990..=2100).contains(y));
123    let day = |w: &str| ordinals.iter().fold(w, |w, suffix| w.trim_end_matches(suffix.as_str())).parse::<i8>().ok().filter(|d| (1..=31).contains(d));
124    let mut i = 0;
125    while i < words.len() {
126        // 15 novembre 2026
127        if let (Some(d), Some(m), Some(y)) = (day(words[i]), words.get(i + 1).and_then(|w| month(w)), words.get(i + 2).and_then(|w| year(w)))
128            && let Ok(date) = Date::new(y, m, d)
129        {
130            out.push((i, date));
131            i += 3;
132            continue;
133        }
134        // November 15 2026
135        if let (Some(m), Some(d), Some(y)) = (month(words[i]), words.get(i + 1).and_then(|w| day(w)), words.get(i + 2).and_then(|w| year(w)))
136            && let Ok(date) = Date::new(y, m, d)
137        {
138            out.push((i, date));
139            i += 3;
140            continue;
141        }
142        // 15 11 2026 (from 15/11/2026, once punctuation is a space)
143        if let (Some(d), Some(m), Some(y)) = (day(words[i]), words.get(i + 1).and_then(|w| w.parse::<i8>().ok()).filter(|m| (1..=12).contains(m)), words.get(i + 2))
144            && words[i].len() <= 2
145            && let Some(y) = year(y).or_else(|| y.parse::<i16>().ok().filter(|y| *y < 100 && y.to_string().len() <= 2).map(|y| 2000 + y))
146            && let Ok(date) = Date::new(y, m, d)
147        {
148            out.push((i, date));
149            i += 3;
150            continue;
151        }
152        i += 1;
153    }
154    out
155}
156
157/// A number written as a word (`LetterWords::numbers`) or in digits, up to a year's days.
158fn number_word(numbers: &[(i64, Vec<String>)], w: &str) -> Option<i64> {
159    numbers.iter().find(|(_, names)| names.iter().any(|n| n == w)).map(|(n, _)| *n).or_else(|| w.parse().ok().filter(|n: &i64| *n > 0 && *n <= 366))
160}
161
162/// Reads a letter's text. `received` is the day it came (its scan's day):
163/// a delay counted from the notification counts from there. The words
164/// looked for are the `letters` lists of the word packs in use, with your
165/// changes (`words::LetterWords`): bodies, kinds, months, delays…
166pub fn read(looked: &Words, text: &str, received: Date) -> Reading {
167    let lw = &looked.letters;
168    let mut reading = Reading::default();
169    let all = plain(text);
170    let words: Vec<&str> = all.split_whitespace().collect();
171    // Who: a known body in the first lines, else the first line of the letterhead.
172    let head: String = text.lines().map(str::trim).filter(|l| !l.is_empty()).take(25).collect::<Vec<_>>().join("\n");
173    let head_plain = plain(&head);
174    reading.sender = lw.senders.iter().find(|(_, phrases)| has(&head_plain, phrases)).or_else(|| lw.senders.iter().find(|(_, phrases)| has(&all, phrases))).map(|(name, _)| name.clone()).unwrap_or_else(|| text.lines().map(str::trim).find(|l| l.chars().filter(|c| c.is_alphabetic()).count() >= 3).unwrap_or("").chars().take(60).collect());
175    // What it is, the gravest first.
176    let k = &lw.kinds;
177    reading.kind = if has(&all, &k.formal_notice) {
178        Kind::FormalNotice
179    } else if has(&all, &k.tax_notice) {
180        Kind::TaxNotice
181    } else if has(&all, &k.decision) && has(&all, &k.decision_appeal) {
182        Kind::Decision
183    } else if has(&all, &k.reminder) {
184        Kind::Reminder
185    } else if has(&all, &k.appointment) {
186        Kind::Appointment
187    } else if has(&all, &k.bill) {
188        Kind::Bill
189    } else if has(&all, &k.acknowledgment) {
190        Kind::Acknowledgment
191    } else if has(&all, &k.attestation) {
192        Kind::Attestation
193    } else if has(&all, &k.contract) {
194        Kind::Contract
195    } else {
196        Kind::Other
197    };
198    reading.registered = has(&all, &lw.registered) && has(&all, &lw.registered_receipt);
199    // The amount: on a line that asks for it, else the first one after a word that names one.
200    reading.amount = text
201        .lines()
202        .find(|line| has(&plain(line), &lw.asks))
203        .and_then(|line| crate::money::find_amount(&looked.money, line))
204        .or_else(|| crate::money::find_amount(&looked.money, text))
205        .map(|a| Money(a.money.cents().abs()));
206    let found = dates(lw, &words);
207    // Its own date: the first date in the head, at its start ("Paris, le 3 octobre 2026").
208    let head_words = head_plain.split_whitespace().count();
209    reading.dated = found.iter().find(|(at, _)| *at < head_words.min(80)).map(|(_, d)| *d);
210    // The deadline: a date after words that ask by when, in the lists' order.
211    let before: Vec<Vec<String>> = lw.deadline.iter().map(|p| plain(p).split_whitespace().map(str::to_string).collect::<Vec<_>>()).filter(|run| !run.is_empty()).collect();
212    'dates: for (at, date) in &found {
213        for words_before in &before {
214            let n = words_before.len();
215            // The asking words within the six words before the date.
216            let start = at.saturating_sub(6 + n);
217            for k in start..*at {
218                if k + n <= words.len() && words[k..k + n].iter().zip(words_before).all(|(a, b)| *a == b) && date >= &received.checked_sub(30.days()).unwrap_or(received) {
219                    reading.deadline = Some(*date);
220                    reading.why = words[k..(*at + 3).min(words.len())].join(" ");
221                    break 'dates;
222                }
223            }
224        }
225    }
226    // A delay: "dans un délai de deux mois à compter de la notification", "sous huitaine".
227    if reading.deadline.is_none() {
228        let numbers = numbered(&lw.numbers);
229        let (delay_words, delay_of) = (normal(&lw.delay_words), normal(&lw.delay_of));
230        let (months, days, weeks) = (normal(&lw.unit_months), normal(&lw.unit_days), normal(&lw.unit_weeks));
231        let fixed: Vec<(i64, Vec<String>)> = lw.fixed_delays.iter().filter_map(|(n, phrases)| Some((n.trim().parse().ok()?, phrases))).flat_map(|(n, phrases)| phrases.iter().map(move |p| (n, plain(p).split_whitespace().map(str::to_string).collect::<Vec<_>>()))).filter(|(_, run)| !run.is_empty()).collect();
232        let from_letter = normal(&lw.from_letter);
233        let is = |list: &[String], w: &str| list.iter().any(|x| x == w);
234        for (i, w) in words.iter().enumerate() {
235            let span = if is(&delay_words, w) && i + 3 < words.len() {
236                let (n, unit) = if is(&delay_of, words[i + 1]) { (number_word(&numbers, words[i + 2]), words.get(i + 3)) } else { (number_word(&numbers, words[i + 1]), words.get(i + 2)) };
237                match (n, unit.copied()) {
238                    (Some(n), Some(unit)) if is(&months, unit) => Some(n.months()),
239                    (Some(n), Some(unit)) if is(&days, unit) => Some(n.days()),
240                    (Some(n), Some(unit)) if is(&weeks, unit) => Some((n * 7).days()),
241                    _ => None,
242                }
243            } else {
244                fixed.iter().find(|(_, run)| words[i..].iter().zip(run).filter(|(a, b)| **a == b.as_str()).count() == run.len()).map(|(n, _)| n.days())
245            };
246            let Some(span) = span else { continue };
247            // From the notification or the reception: the day it came; from the letter: its date.
248            let rest = words[i..(i + 14).min(words.len())].join(" ");
249            let from_the_letter = from_letter.iter().any(|p| rest.contains(p.as_str()));
250            let base = if from_the_letter { reading.dated.unwrap_or(received) } else { received };
251            if let Ok(date) = base.checked_add(span) {
252                reading.deadline = Some(date);
253                reading.why = words[i.saturating_sub(2)..(i + 10).min(words.len())].join(" ");
254                break;
255            }
256        }
257    }
258    // An appointment: its day, and "à 10 h 30" / "at 10:30" after it.
259    if reading.kind == Kind::Appointment
260        && let Some((at, date)) = found.iter().find(|(_, d)| *d >= received)
261    {
262        let at_words = normal(&lw.at);
263        let after: Vec<&str> = words[(*at + 3).min(words.len())..(*at + 9).min(words.len())].to_vec();
264        let time = after.iter().position(|w| at_words.iter().any(|a| a == w)).and_then(|k| {
265            let hour = after.get(k + 1)?.trim_end_matches('h').parse::<i8>().ok().filter(|h| (0..24).contains(h))?;
266            let minute = after.get(k + 2).filter(|w| **w == "h").and_then(|_| after.get(k + 3)).or_else(|| after.get(k + 2)).and_then(|m| m.parse::<i8>().ok()).filter(|m| (0..60).contains(m)).unwrap_or(0);
267            Some((hour, minute))
268        });
269        reading.appointment = Some((*date, time));
270    }
271    // The words that set the date, as the letter writes them (accents and all).
272    if !reading.why.is_empty() {
273        reading.why = as_written(text, &reading.why);
274    }
275    // Your number with them, as written on its line: "Référence : 2026-ASAP-01234".
276    let labels = normal(&lw.references);
277    'lines: for line in text.lines() {
278        let folded = plain(line);
279        if !labels.iter().any(|l| folded.contains(&format!(" {l} "))) {
280            continue;
281        }
282        let after = line.split_once(':').map_or(line, |(_, rest)| rest);
283        for token in after.split_whitespace() {
284            let token = token.trim_matches(|c: char| !c.is_alphanumeric());
285            if token.chars().filter(char::is_ascii_digit).count() >= 4 {
286                reading.reference = token.to_string();
287                break 'lines;
288            }
289        }
290    }
291    reading
292}
293
294/// The words of a folded phrase as `text` writes them: "delai de deux mois" → "délai de deux mois".
295fn as_written(text: &str, phrase: &str) -> String {
296    let flat: Vec<char> = text.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
297    // Folded one character for one, so positions hold.
298    let folded: Vec<char> = flat.iter().map(|&c| crate::text::fold_char(c)).map(|c| if c.is_alphanumeric() { c } else { ' ' }).collect();
299    let mut spans: Vec<(usize, usize)> = Vec::new();
300    let mut start = None;
301    for (i, c) in folded.iter().enumerate() {
302        match (c.is_alphanumeric(), start) {
303            (true, None) => start = Some(i),
304            (false, Some(s)) => {
305                spans.push((s, i));
306                start = None;
307            }
308            _ => {}
309        }
310    }
311    if let Some(s) = start {
312        spans.push((s, folded.len()));
313    }
314    let words: Vec<String> = spans.iter().map(|(a, b)| folded[*a..*b].iter().collect()).collect();
315    let wanted: Vec<&str> = phrase.split_whitespace().collect();
316    let n = wanted.len();
317    (0..words.len().saturating_sub(n.saturating_sub(1)))
318        .find(|&k| words[k..k + n].iter().map(String::as_str).eq(wanted.iter().copied()))
319        .map(|k| flat[spans[k].0..spans[k + n - 1].1].iter().collect())
320        .unwrap_or_else(|| phrase.to_string())
321}
322
323/// One letter kept.
324#[derive(Debug, Clone, Default, PartialEq, Eq)]
325pub struct Letter {
326    pub id: String,
327    /// Its scan: from the notes folder, or anywhere.
328    pub file: String,
329    /// The scan as it was when read: its name, size and time, to read it once.
330    pub source: String,
331    pub received: Option<Date>,
332    pub reading: Reading,
333    /// The project it belongs with: `case` in the file, the key every
334    /// version of Sioul reads (`project` reads too).
335    pub project: String,
336    /// "new" until you are done with it; "done" then.
337    pub status: String,
338    /// The task made for its date.
339    pub task: String,
340    /// Why it could not be read ("no OCR program"), if it could not.
341    pub problem: String,
342}
343
344/// The letters of a notes folder.
345#[derive(Debug, Clone, Default)]
346pub struct Letters {
347    pub root: PathBuf,
348    pub list: Vec<Letter>,
349}
350
351impl Letters {
352    pub fn folder(root: &Path) -> PathBuf {
353        root.join(FOLDER)
354    }
355
356    /// Read, changed by `change` and written back, all under the file's lock
357    /// (the window's own; the sharing carries the file as a note): a change
358    /// is set over the file as it is then. Returns what `change` returns.
359    pub fn change<R>(root: &Path, change: impl FnOnce(&mut Letters) -> R) -> Result<R, String> {
360        Letters::try_change(root, |letters| Ok(change(letters)))
361    }
362
363    /// The same, written back only when `change` succeeds.
364    pub fn try_change<R>(root: &Path, change: impl FnOnce(&mut Letters) -> Result<R, String>) -> Result<R, String> {
365        crate::filelock::with_lock(&Letters::folder(root).join(MANIFEST), || {
366            let mut letters = Letters::load(root)?;
367            let out = change(&mut letters)?;
368            letters.save()?;
369            Ok(out)
370        })
371    }
372
373    pub fn load(root: &Path) -> Result<Letters, String> {
374        let path = Letters::folder(root).join(MANIFEST);
375        let text = match std::fs::read_to_string(&path) {
376            Ok(text) => text,
377            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Letters { root: root.to_path_buf(), list: Vec::new() }),
378            Err(e) => return Err(format!("{}: {e}", path.display())),
379        };
380        let table: toml::Table = text.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
381        let text_of = |t: &toml::Table, k: &str| t.get(k).and_then(toml::Value::as_str).unwrap_or("").to_string();
382        let date_of = |t: &toml::Table, k: &str| t.get(k).and_then(|v| v.as_datetime().map(|d| d.to_string()).or_else(|| v.as_str().map(str::to_string))).and_then(|s| s.get(..10).and_then(|s| s.parse().ok()));
383        let list = table
384            .get("letter")
385            .and_then(toml::Value::as_array)
386            .into_iter()
387            .flatten()
388            .filter_map(|v| {
389                let t = v.as_table()?;
390                let time = t.get("time").and_then(toml::Value::as_str).and_then(|s| s.split_once(':')).and_then(|(h, m)| Some((h.parse().ok()?, m.parse().ok()?)));
391                Some(Letter {
392                    id: text_of(t, "id"),
393                    file: text_of(t, "file"),
394                    source: text_of(t, "source"),
395                    received: date_of(t, "received"),
396                    reading: Reading {
397                        sender: text_of(t, "sender"),
398                        kind: Kind::of(&text_of(t, "kind")),
399                        amount: t.get("amount").and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))).map(Money::from_units),
400                        dated: date_of(t, "dated"),
401                        deadline: date_of(t, "deadline"),
402                        why: text_of(t, "why"),
403                        appointment: date_of(t, "appointment").map(|d| (d, time)),
404                        registered: t.get("registered").and_then(toml::Value::as_bool).unwrap_or(false),
405                        reference: text_of(t, "reference"),
406                    },
407                    project: Some(text_of(t, "project")).filter(|p| !p.is_empty()).unwrap_or_else(|| text_of(t, "case")),
408                    status: text_of(t, "status"),
409                    task: text_of(t, "task"),
410                    problem: text_of(t, "problem"),
411                })
412            })
413            .collect();
414        Ok(Letters { root: root.to_path_buf(), list })
415    }
416
417    pub fn get(&self, id: &str) -> Option<&Letter> {
418        self.list.iter().find(|l| l.id == id)
419    }
420
421    pub fn put(&mut self, letter: Letter) {
422        match self.list.iter_mut().find(|l| l.id == letter.id) {
423            Some(place) => *place = letter,
424            None => self.list.push(letter),
425        }
426    }
427
428    /// A scan's file, where it is on this computer.
429    pub fn file_path(&self, letter: &Letter) -> PathBuf {
430        let path = Path::new(&letter.file);
431        if path.is_absolute() { path.to_path_buf() } else { self.root.join(path) }
432    }
433
434    /// Its text, as read.
435    pub fn text(&self, letter: &Letter) -> String {
436        std::fs::read_to_string(Letters::folder(&self.root).join(format!("{}.txt", letter.id))).unwrap_or_default()
437    }
438
439    pub fn save_text(&self, id: &str, text: &str) -> Result<(), String> {
440        let folder = Letters::folder(&self.root);
441        std::fs::create_dir_all(&folder).and_then(|()| std::fs::write(folder.join(format!("{id}.txt")), text)).map_err(|e| e.to_string())
442    }
443
444    /// Its scan moved out of the inbox into `letters/<year>/`, named by its day, sender and kind.
445    pub fn file_away(&self, letter: &mut Letter) -> Result<(), String> {
446        let from = self.file_path(letter);
447        let day = letter.reading.dated.or(letter.received).unwrap_or(Date::constant(2000, 1, 1));
448        let folder = Letters::folder(&self.root).join(day.year().to_string());
449        if from.starts_with(&folder) {
450            return Ok(());
451        }
452        std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
453        let extension = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
454        let sender: String = crate::projects::new_id(&letter.reading.sender, &[]);
455        let base = format!("{day} {sender} {}", letter.reading.kind.id());
456        let target = std::iter::once(folder.join(format!("{base}{extension}"))).chain((2..1000).map(|n| folder.join(format!("{base} {n}{extension}")))).find(|p| !p.exists()).ok_or("no free name")?;
457        std::fs::rename(&from, &target).or_else(|_| std::fs::copy(&from, &target).and_then(|_| std::fs::remove_file(&from))).map_err(|e| e.to_string())?;
458        letter.file = target.strip_prefix(&self.root).map_or_else(|_| target.display().to_string(), |p| p.to_string_lossy().replace('\\', "/"));
459        Ok(())
460    }
461
462    /// Written whole, under the file's lock.
463    pub fn save(&self) -> Result<(), String> {
464        crate::filelock::with_lock(&Letters::folder(&self.root).join(MANIFEST), || self.write())
465    }
466
467    fn write(&self) -> Result<(), String> {
468        let folder = Letters::folder(&self.root);
469        std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
470        let mut doc = toml_edit::DocumentMut::new();
471        doc.decor_mut().set_prefix("# Paper letters, read from their scans (docs/porch.md). Their texts are beside: <id>.txt.\n\n");
472        let date = |d: Date| toml_edit::value(toml_edit::Datetime { date: Some(toml_edit::Date { year: d.year() as u16, month: d.month() as u8, day: d.day() as u8 }), time: None, offset: None });
473        let mut list = toml_edit::ArrayOfTables::new();
474        for l in &self.list {
475            let mut t = toml_edit::Table::new();
476            t["id"] = toml_edit::value(&l.id);
477            t["file"] = toml_edit::value(&l.file);
478            // The project under `case`, its key from the first version: an older
479            // Sioul writing the file again would drop a key it does not know.
480            for (k, v) in [("source", &l.source), ("sender", &l.reading.sender), ("why", &l.reading.why), ("reference", &l.reading.reference), ("case", &l.project), ("status", &l.status), ("task", &l.task), ("problem", &l.problem)] {
481                if !v.is_empty() {
482                    t[k] = toml_edit::value(v);
483                }
484            }
485            t["kind"] = toml_edit::value(l.reading.kind.id());
486            if let Some(amount) = l.reading.amount {
487                t["amount"] = toml_edit::value(amount.cents() as f64 / 100.0);
488            }
489            for (k, d) in [("received", l.received), ("dated", l.reading.dated), ("deadline", l.reading.deadline), ("appointment", l.reading.appointment.map(|(d, _)| d))] {
490                if let Some(d) = d {
491                    t[k] = date(d);
492                }
493            }
494            if let Some((_, Some((h, m)))) = l.reading.appointment {
495                t["time"] = toml_edit::value(format!("{h:02}:{m:02}"));
496            }
497            if l.reading.registered {
498                t["registered"] = toml_edit::value(true);
499            }
500            list.push(t);
501        }
502        doc.insert("letter", toml_edit::Item::ArrayOfTables(list));
503        let path = folder.join(MANIFEST);
504        let temporary = folder.join(format!("{MANIFEST}.new"));
505        std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, &path)).map_err(|e| format!("{}: {e}", path.display()))
506    }
507}
508
509/// The project a letter belongs with: the one whose name's words it carries most.
510pub fn project_of(text: &str, sender: &str, projects: &[crate::projects::Project]) -> Option<String> {
511    let all = plain(&format!("{sender} {text}"));
512    projects
513        .iter()
514        .filter(|c| c.status.as_deref() != Some("closed"))
515        .map(|c| {
516            let words: Vec<String> = plain(&c.title).split_whitespace().filter(|w| w.len() >= 4).map(str::to_string).collect();
517            (words.iter().filter(|w| all.contains(&format!(" {w} "))).count(), words.len(), c.id.clone())
518        })
519        .filter(|(hits, words, _)| *hits > 0 && *hits * 2 >= *words)
520        .max_by_key(|(hits, _, _)| *hits)
521        .map(|(_, _, id)| id)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    /// A letter read with the packs built in (French and English, France).
529    fn read(text: &str, received: Date) -> Reading {
530        super::read(&Words::builtin(), text, received)
531    }
532
533    fn day(text: &str) -> Date {
534        text.parse().unwrap()
535    }
536
537    #[test]
538    fn a_decision_with_its_appeal_delay() {
539        let text = "CAISSE D'ALLOCATIONS FAMILIALES\nd'Exempleville\n\nExempleville, le 28 septembre 2026\n\nNuméro allocataire : 1234567 X\n\nObjet : notification de décision\n\nMadame, Monsieur,\nNous avons examiné votre demande d'aide au logement. Notre décision : votre droit est refusé.\n\nVoies et délais de recours : vous pouvez contester cette décision dans un délai de deux mois à compter de sa notification, auprès de la commission de recours amiable.\n";
540        let r = read(text, day("2026-10-02"));
541        assert_eq!(r.sender, "CAF");
542        assert_eq!(r.kind, Kind::Decision);
543        assert_eq!(r.dated, Some(day("2026-09-28")));
544        assert_eq!(r.deadline, Some(day("2026-12-02")), "two months from the day it came");
545        assert!(r.why.contains("délai de deux mois à compter"), "as written: {}", r.why);
546        assert_eq!(r.reference, "1234567");
547    }
548
549    #[test]
550    fn a_bill_with_its_date_and_amount() {
551        let text = "CENTRE HOSPITALIER D'EXEMPLEVILLE\nService des recettes\n\nAVIS DES SOMMES A PAYER\n\nDate d'émission : 15/09/2026\nRéférence : 2026-ASAP-01234\n\nMontant à payer : 1 234,56 €\nÀ régler avant le 15 novembre 2026, par virement ou en ligne sur payfip.gouv.fr.\n\nLettre recommandée avec accusé de réception\n";
552        let r = read(text, day("2026-10-02"));
553        assert_eq!(r.sender, "Hôpital");
554        assert_eq!(r.kind, Kind::Bill);
555        assert_eq!(r.amount, Some(Money(123456)));
556        assert_eq!(r.deadline, Some(day("2026-11-15")));
557        assert!(r.registered);
558        assert_eq!(r.reference, "2026-ASAP-01234");
559    }
560
561    #[test]
562    fn a_formal_notice_comes_first_and_eight_days() {
563        let text = "Cabinet Exemple\nCommissaire de justice\n\nMISE EN DEMEURE\n\nNous vous mettons en demeure de régler la somme de 2 345,00 € sous huitaine.\n";
564        let r = read(text, day("2026-10-02"));
565        assert_eq!(r.kind, Kind::FormalNotice);
566        assert_eq!(r.sender, "Commissaire de justice");
567        assert_eq!(r.amount, Some(Money(234500)));
568        assert_eq!(r.deadline, Some(day("2026-10-10")));
569    }
570
571    #[test]
572    fn an_appointment_with_its_hour() {
573        let text = "Assurance Maladie\nCPAM d'Exempleville\n\nConvocation\n\nVous êtes convoqué à un examen le jeudi 22 octobre 2026 à 9 h 15, au centre d'examens de santé.\n";
574        let r = read(text, day("2026-10-02"));
575        assert_eq!(r.kind, Kind::Appointment);
576        assert_eq!(r.sender, "Assurance Maladie");
577        assert_eq!(r.appointment, Some((day("2026-10-22"), Some((9, 15)))));
578    }
579
580    #[test]
581    fn english_and_unknown_senders() {
582        let text = "Example Water Ltd\n12 High Street\n\nInvoice\nAmount due: £84.20\nPlease pay no later than 30 October 2026.\n";
583        let r = read(text, day("2026-10-02"));
584        assert_eq!(r.sender, "Example Water Ltd");
585        assert_eq!(r.kind, Kind::Bill);
586        assert_eq!(r.deadline, Some(day("2026-10-30")));
587        assert_eq!(r.amount, Some(Money(8420)));
588    }
589
590    #[test]
591    fn kept_and_filed() {
592        let root = std::env::temp_dir().join(format!("sioul-letters-{}", std::process::id()));
593        let _ = std::fs::remove_dir_all(&root);
594        std::fs::create_dir_all(root.join("letters/inbox")).unwrap();
595        std::fs::write(root.join("letters/inbox/scan.pdf"), b"%PDF").unwrap();
596        let mut letters = Letters::load(&root).unwrap();
597        let mut letter = Letter { id: "a1".into(), file: "letters/inbox/scan.pdf".into(), received: Some(day("2026-10-02")), status: "new".into(), ..Letter::default() };
598        letter.reading = read("CAF\nExempleville, le 28 septembre 2026\nDécision. Voies et délais de recours : recours dans un délai de deux mois.", day("2026-10-02"));
599        letters.file_away(&mut letter).unwrap();
600        assert_eq!(letter.file, "letters/2026/2026-09-28 caf decision.pdf");
601        assert!(root.join(&letter.file).is_file() && !root.join("letters/inbox/scan.pdf").exists());
602        letters.put(letter.clone());
603        letters.save_text("a1", "the text").unwrap();
604        letters.save().unwrap();
605        let again = Letters::load(&root).unwrap();
606        assert_eq!(again.list, vec![letter]);
607        assert_eq!(again.text(&again.list[0]), "the text");
608        let _ = std::fs::remove_dir_all(&root);
609    }
610}