1use 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(());
18static MISSING: Mutex<String> = Mutex::new(String::new());
20
21const SCANS: &[&str] = &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "webp"];
22
23pub(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
39pub(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 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 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 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 lines: Vec<String>,
113 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 missing: String,
135}
136
137pub(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 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
206pub(crate) fn make_task(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
208 with_letter(id, |letters, letter| {
209 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
235pub(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 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
262pub(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}