Skip to main content

sioul_app/
papers.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The papers wallet, for the window (docs/papers.md): the page, its form,
5//! a paper kept from a mail's attachment or a file, its renewal planned as a task.
6
7use crate::backend::{QtThread, Shared, json, load_config, say, tr};
8use serde::{Deserialize, Serialize};
9use sioul_core::papers::{Kind, Paper, Standing, Wallet};
10use sioul_core::tasks::{Link, TaskEdit};
11use std::path::Path;
12use std::sync::Arc;
13
14fn wallet() -> Result<Wallet, String> {
15    let root = load_config().notes_root_path().ok_or_else(|| tr().text("papers-no-store", None))?;
16    Wallet::load(&root)
17}
18
19#[derive(Serialize)]
20struct PaperView {
21    id: String,
22    kind: &'static str,
23    kind_label: String,
24    title: String,
25    holder: String,
26    notes: String,
27    /// Its file where it is on this computer; "" without one.
28    file: String,
29    file_name: String,
30    /// The file is there (its sync may not be done yet).
31    file_there: bool,
32    issued: String,
33    until: String,
34    /// "valid", "renew", "ended", "fresh", "old", "undated".
35    standing: &'static str,
36    line: String,
37    renewal: String,
38    /// Renewing can be planned: it is renewed, it has an end, no task yet.
39    can_renew: bool,
40}
41
42#[derive(Serialize)]
43struct Family {
44    id: &'static str,
45    label: String,
46    papers: Vec<PaperView>,
47}
48
49#[derive(Serialize)]
50struct Choice {
51    id: &'static str,
52    label: String,
53}
54
55#[derive(Serialize, Default)]
56struct PapersView {
57    /// A notes folder is set: papers have a home.
58    store: bool,
59    problem: String,
60    kinds: Vec<Choice>,
61    families: Vec<Family>,
62}
63
64fn view_of(wallet: &Wallet, paper: &Paper, today: jiff::civil::Date) -> PaperView {
65    let file = wallet.file_path(paper);
66    let date = |d: jiff::civil::Date| tr().day_in(d, today);
67    let (standing, line) = match paper.standing(today) {
68        Standing::Valid(d) => ("valid", say("paper-valid", &[("date", date(d))])),
69        Standing::Renew(d) => ("renew", say("paper-renew", &[("date", date(d))])),
70        Standing::Ended(d) => ("ended", say("paper-ended", &[("date", date(d))])),
71        Standing::Fresh(d) => ("fresh", say("paper-fresh", &[("date", date(d))])),
72        Standing::Old(d) => ("old", say("paper-old", &[("date", date(d))])),
73        Standing::Undated => ("undated", String::new()),
74    };
75    PaperView {
76        id: paper.id.clone(),
77        kind: paper.kind.id(),
78        kind_label: tr().text(&format!("paper-kind-{}", paper.kind.id()), None),
79        title: paper.title.clone(),
80        holder: paper.holder.clone(),
81        notes: paper.notes.clone(),
82        file_name: file.as_ref().and_then(|f| f.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),
83        file_there: file.as_ref().is_some_and(|f| f.is_file()),
84        file: file.map(|f| f.display().to_string()).unwrap_or_default(),
85        issued: paper.issued.map(|d| d.to_string()).unwrap_or_default(),
86        until: paper.until.map(|d| d.to_string()).unwrap_or_default(),
87        standing,
88        line,
89        renewal: paper.renewal.clone(),
90        can_renew: paper.renewal.is_empty() && paper.renew_from().is_some(),
91    }
92}
93
94/// The page, as JSON: the papers by family, the kinds to choose from.
95pub(crate) fn view() -> String {
96    let kinds = Kind::ALL.iter().map(|k| Choice { id: k.id(), label: tr().text(&format!("paper-kind-{}", k.id()), None) }).collect();
97    let wallet = match wallet() {
98        Ok(wallet) => wallet,
99        Err(problem) => return json(&PapersView { store: load_config().notes_root_path().is_some(), problem, kinds, families: Vec::new() }),
100    };
101    let today = jiff::Zoned::now().date();
102    let mut families: Vec<Family> = Vec::new();
103    for family in ["identity", "health", "home", "money", "warranty", "other"] {
104        let mut papers: Vec<&Paper> = wallet.papers.iter().filter(|p| p.kind.family() == family).collect();
105        papers.sort_by(|a, b| (a.kind.id(), &a.holder, &a.title).cmp(&(b.kind.id(), &b.holder, &b.title)));
106        if !papers.is_empty() {
107            families.push(Family { id: family, label: tr().text(&format!("paper-family-{family}"), None), papers: papers.into_iter().map(|p| view_of(&wallet, p, today)).collect() });
108        }
109    }
110    json(&PapersView { store: true, problem: String::new(), kinds, families })
111}
112
113/// What the form gives back.
114#[derive(Deserialize)]
115struct PaperEdit {
116    #[serde(default)]
117    kind: String,
118    #[serde(default)]
119    title: String,
120    /// A file: kept where it is when it is in the wallet's folder, else copied there.
121    #[serde(default)]
122    file: String,
123    #[serde(default)]
124    issued: String,
125    #[serde(default)]
126    until: String,
127    #[serde(default)]
128    holder: String,
129    #[serde(default)]
130    notes: String,
131}
132
133/// A paper saved: new when `id` is empty; returns what went wrong, else "".
134pub(crate) fn save(id: &str, edit: &str) -> String {
135    let edit: PaperEdit = match serde_json::from_str(edit) {
136        Ok(edit) => edit,
137        Err(e) => return e.to_string(),
138    };
139    let wallet = match wallet() {
140        Ok(wallet) => wallet,
141        Err(e) => return e,
142    };
143    let title = edit.title.trim().to_string();
144    if title.is_empty() {
145        return tr().text("papers-no-title", None);
146    }
147    let date = |text: &str| -> Result<Option<jiff::civil::Date>, String> {
148        let text = text.trim();
149        if text.is_empty() { Ok(None) } else { text.parse().map(Some).map_err(|_| say("papers-bad-date", &[("date", text.to_string())])) }
150    };
151    let (issued, until) = match (date(&edit.issued), date(&edit.until)) {
152        (Ok(issued), Ok(until)) => (issued, until),
153        (Err(e), _) | (_, Err(e)) => return e,
154    };
155    let kind = Kind::of(&edit.kind);
156    let old = wallet.get(id).cloned().unwrap_or_default();
157    // A file from elsewhere is copied into the wallet's folder: it travels with it.
158    let file = if edit.file.trim().is_empty() {
159        String::new()
160    } else {
161        let path = crate::backend::local_path(edit.file.trim());
162        let inside = wallet.file_path(&old).is_some_and(|f| f == path) || path.starts_with(wallet.root.join(sioul_core::papers::FOLDER));
163        if inside {
164            path.strip_prefix(&wallet.root).map_or_else(|_| path.display().to_string(), |p| p.to_string_lossy().replace('\\', "/"))
165        } else if path.is_file() {
166            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
167            match wallet.keep_file(&path, &name) {
168                Ok(kept) => kept,
169                Err(e) => return e,
170            }
171        } else {
172            return say("papers-no-file", &[("path", path.display().to_string())]);
173        }
174    };
175    // An end proposed from the issue when the kind's usual length is known and none was given.
176    let until = until.or_else(|| issued.and_then(|d| sioul_core::papers::usual_end(kind, d)));
177    // Set over the wallet as the file holds it now (the file copied first), under its lock, which the sharing takes too.
178    Wallet::change(&wallet.root, |wallet| {
179        let old = wallet.get(id).cloned().unwrap_or_default();
180        let paper = Paper {
181            id: old.id.clone(),
182            kind,
183            title,
184            file,
185            issued,
186            until,
187            holder: edit.holder.trim().to_string(),
188            notes: edit.notes.trim().to_string(),
189            // A new end: a new renewal to plan.
190            renewal: if old.until == until { old.renewal.clone() } else { String::new() },
191            added: old.added.or_else(|| Some(jiff::Zoned::now().date())),
192        };
193        wallet.put(paper);
194    })
195    .err()
196    .unwrap_or_default()
197}
198
199/// A paper taken out; its file stays where it is.
200pub(crate) fn remove(id: &str) -> String {
201    let Some(root) = load_config().notes_root_path() else { return tr().text("papers-no-store", None) };
202    Wallet::change(&root, |wallet| wallet.remove(id)).err().unwrap_or_default()
203}
204
205/// Its renewal, as a task in your usual list (your phone has it): from when
206/// renewing starts to the day it ends, with the steps known for its kind.
207pub(crate) fn plan_renewal(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
208    let wallet = match wallet() {
209        Ok(wallet) => wallet,
210        Err(e) => return e,
211    };
212    let Some(paper) = wallet.get(id).cloned() else { return tr().text("papers-gone", None) };
213    let (Some(from), Some(until)) = (paper.renew_from(), paper.until) else { return String::new() };
214    let today = jiff::Zoned::now().date();
215    let steps = format!("paper-steps-{}", paper.kind.id());
216    let notes = tr().text(&steps, None);
217    let edit = TaskEdit {
218        title: say("paper-renew-task", &[("title", paper.title.clone())]),
219        notes: if notes == steps { String::new() } else { notes },
220        start: from.max(today).to_string(),
221        due: until.to_string(),
222        estimate: 30,
223        links: vec![Link { uri: sioul_core::links::paper_uri(&paper.id), label: paper.title.clone(), rel: "related".into() }],
224        ..TaskEdit::default()
225    };
226    let list = sioul_core::tasks::default_list().map(|l| format!("{}/{}", l.account, l.id));
227    let made = match list {
228        Some(list) => crate::work::create_task(qt, shared, &edit, &list),
229        None => crate::work::local_task(qt, shared, &tr().text("papers-list", None), &edit),
230    };
231    match made {
232        // The task made: its uid set on the paper as the wallet holds it now, under its lock.
233        Ok(uid) => Wallet::change(&wallet.root, |wallet| {
234            if let Some(mut paper) = wallet.get(id).cloned() {
235                paper.renewal = uid;
236                wallet.put(paper);
237            }
238        })
239        .err()
240        .unwrap_or_default(),
241        Err(e) => e,
242    }
243}
244
245/// A file checked by the antivirus (an attachment), kept in the wallet's
246/// folder; returns its path there, with a title and a kind guessed from its name.
247pub(crate) fn keep_checked(file: &Path, name: &str, subject: &str) -> Result<(String, String, &'static str), String> {
248    let wallet = wallet()?;
249    let kept = wallet.keep_file(file, name)?;
250    let title = Path::new(name).file_stem().map(|s| s.to_string_lossy().replace(['_', '-'], " ")).unwrap_or_default();
251    let looked = sioul_core::words::Words::of(&load_config());
252    let kind = Kind::guess(&looked, name).or_else(|| Kind::guess(&looked, subject)).unwrap_or_default();
253    Ok((wallet.root.join(&kept).display().to_string(), title, kind.id()))
254}