1use 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(());
20static MISSING: Mutex<String> = Mutex::new(String::new());
22static READER: Mutex<Option<(std::time::Instant, String)>> = Mutex::new(None);
25const READER_FRESH: std::time::Duration = std::time::Duration::from_secs(600);
27
28const SCANS: &[&str] = &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "webp"];
29
30pub(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
46pub(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 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 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 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 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 lines: Vec<String>,
138 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 missing: String,
160 reader: String,
162}
163
164pub(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 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
238pub(crate) fn make_task(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
240 with_letter(id, |letters, letter| {
241 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
267pub(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 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
294pub(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
306pub(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
316fn 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
331pub(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}