Skip to main content

sioul_app/
projects.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Projects, time and invoices, for the window: a project's page, the Time
5//! page, time noted by hand, an invoice made from a project's unbilled hours
6//! (its record kept, its hours marked, its payment expected in the project's
7//! budget), and paid.
8
9use crate::backend::{QtThread, Shared, json, load_config, say, tr};
10use crate::work::{self, loaded};
11use jiff::Zoned;
12use jiff::civil::Date;
13use jiff::tz::TimeZone;
14use serde::Deserialize;
15use sioul_core::budget::{self, Line};
16use sioul_core::projects::{self, ProjectEdit, ProjectStore};
17use sioul_core::invoice::{self, Invoice};
18use sioul_core::money::Money;
19use sioul_core::timelog::{self, Session};
20use sioul_core::timereport::{self, Entry};
21use sioul_core::{links, projectview};
22use std::path::PathBuf;
23use std::sync::Arc;
24
25fn store() -> Result<ProjectStore, String> {
26    let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
27    ProjectStore::load(&root)
28}
29
30/// Every session with its project and title.
31fn entries(shared: &Shared) -> Vec<Entry> {
32    let loaded = loaded(shared);
33    timereport::entries(&timelog::sessions(), &loaded.tasks, &loaded.projects, &TimeZone::system())
34}
35
36/// The projects, for the list.
37pub(crate) fn rows(shared: &Shared) -> String {
38    let loaded = loaded(shared);
39    json(&projectview::rows(&loaded, &entries(shared), &loaded.projects))
40}
41
42/// One project's page, as JSON; "" when it is gone.
43pub(crate) fn page(shared: &Shared, id: &str) -> String {
44    let loaded = loaded(shared);
45    let Some(project) = loaded.projects.iter().find(|c| c.id == id) else { return String::new() };
46    let config = load_config();
47    let now = Zoned::now().timestamp().as_second();
48    // Its mail as the Porch takes it: never what it sets aside.
49    let gate = sioul_core::porch::Gate::load(&config, now);
50    let view = projectview::view(&loaded, project, &entries(shared), &invoice::all_in(&invoice::folder()), config.invoice.rate, now, tr(), &gate);
51    json(&view)
52}
53
54/// Saves a project from its form (a new one when `id` is empty); returns {"id"} or {"error"}.
55pub(crate) fn save(qt: &QtThread, shared: &Arc<Shared>, id: &str, edit: &str) -> String {
56    let result = serde_json::from_str::<ProjectEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
57        if edit.title.trim().is_empty() {
58            return Err(tr().text("project-no-title", None));
59        }
60        let store = store()?;
61        projects::save_project(&store.file(), id, &edit)
62    });
63    work::refresh(qt, shared);
64    match result {
65        Ok(id) => serde_json::json!({ "id": id }).to_string(),
66        Err(error) => serde_json::json!({ "error": error }).to_string(),
67    }
68}
69
70/// A project taken out of the list; its tasks, notes, mail and time
71/// stay. Returns what went wrong, else "".
72pub(crate) fn remove(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
73    let result = store().and_then(|store| projects::remove_project(&store.file(), id));
74    work::refresh(qt, shared);
75    result.err().unwrap_or_default()
76}
77
78/// A budget made (`id` empty) or changed; returns {"id"} or {"error"}.
79pub(crate) fn save_budget(qt: &QtThread, shared: &Arc<Shared>, id: &str, edit: &str) -> String {
80    let result = serde_json::from_str::<budget::BudgetEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
81        let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
82        budget::save_budget(&root.join(budget::LEDGER), id, &edit)
83    });
84    crate::backend::show(qt, shared);
85    match result {
86        Ok(id) => serde_json::json!({ "id": id }).to_string(),
87        Err(error) => serde_json::json!({ "error": error }).to_string(),
88    }
89}
90
91/// A budget taken out, by its id (its lines stay in the file); returns what
92/// went wrong, else "". A line is taken out by `remove_line`.
93pub(crate) fn remove_budget(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
94    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
95    let result = budget::remove_budget(&root.join(budget::LEDGER), id);
96    crate::backend::show(qt, shared);
97    result.err().unwrap_or_default()
98}
99
100/// What a line's change or removal came to, said: "" when done; when the line
101/// is not in the file as the page showed it (changed meanwhile, here by hand
102/// or on another device), that nothing was changed.
103fn line_said(result: Result<bool, String>) -> String {
104    match result {
105        Ok(true) => String::new(),
106        Ok(false) => tr().text("budget-line-gone", None),
107        Err(e) => e,
108    }
109}
110
111/// One line of the budgets' file taken out (`sioul:budget/<budget>/<place>`),
112/// only when it is still the line the page showed (`shown`: its row, JSON);
113/// returns what went wrong, else "".
114pub(crate) fn remove_line(qt: &QtThread, shared: &Arc<Shared>, uri: &str, shown: &str) -> String {
115    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
116    let path = root.join(budget::LEDGER);
117    let result = match budget::SeenLine::from_page(uri, shown) {
118        Some((place, seen)) => budget::remove_line(&path, place, &seen),
119        None => Ok(false),
120    };
121    crate::backend::show(qt, shared);
122    line_said(result)
123}
124
125/// A line of the budgets' file changed: label, amount, date; only when it is
126/// still the line the page showed (`shown`: its row, JSON). Returns what went
127/// wrong, else "".
128pub(crate) fn change_line(qt: &QtThread, shared: &Arc<Shared>, uri: &str, shown: &str, label: &str, amount: f64, date: &str) -> String {
129    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
130    let path = root.join(budget::LEDGER);
131    let today = Zoned::now().date();
132    let result = match budget::SeenLine::from_page(uri, shown) {
133        Some((place, seen)) => budget::change_line(&path, place, &seen, label, amount, date.parse().unwrap_or(today), today),
134        None => Ok(false),
135    };
136    crate::backend::show(qt, shared);
137    line_said(result)
138}
139
140/// The Time page for `period` ("week", "month", "year") around `anchor`, one project or all.
141pub(crate) fn time(shared: &Shared, period: &str, anchor: &str, project: &str) -> String {
142    let loaded = loaded(shared);
143    let today = Zoned::now().date();
144    let anchor = anchor.parse::<Date>().unwrap_or(today);
145    let sessions = timelog::sessions();
146    // Time noted by hand has no task, or was not timed: it can be taken out.
147    let by_hand: std::collections::BTreeSet<String> = sessions.iter().filter(|s| s.task.is_empty() || !s.project.is_empty()).map(Session::key).collect();
148    let all = timereport::entries(&sessions, &loaded.tasks, &loaded.projects, &TimeZone::system());
149    let config = load_config();
150    let mut view = timereport::view(&all, &loaded.projects, &|key| by_hand.contains(key), period, anchor, Some(project).filter(|p| !p.is_empty()), config.invoice.rate, today, tr());
151    // How each stretch's minutes were known, said quietly: timed, typed, corrected, or not known.
152    let kinds: std::collections::BTreeMap<String, Option<timelog::Kind>> = sessions.iter().map(|s| (s.key(), s.kind)).collect();
153    for entry in &mut view.entries {
154        let kind = kinds.get(&entry.key).copied().flatten();
155        entry.kind = timelog::Kind::id(kind).to_string();
156        entry.kind_said = tr().text(&format!("time-kind-{}", entry.kind), None);
157    }
158    json(&view)
159}
160
161/// A project's billable time from `from` to `to`, written as a spreadsheet at
162/// `target` (a file address); returns the file written.
163pub(crate) fn export_time_csv(shared: &Shared, project: &str, from: &str, to: &str, target: &str) -> Result<String, String> {
164    let loaded = loaded(shared);
165    let Some(found) = loaded.projects.iter().find(|c| c.id == project) else { return Err(tr().text("project-gone", None)) };
166    let (Ok(from), Ok(to)) = (from.parse::<Date>(), to.parse::<Date>()) else { return Err(tr().text("time-bad-day", None)) };
167    let all = timereport::entries(&timelog::sessions(), &loaded.tasks, &loaded.projects, &TimeZone::system());
168    let config = load_config();
169    let french = tr().text("qt-locale", None).starts_with("fr");
170    let words = ["time-csv-what", "time-csv-hours", "time-csv-rate", "time-csv-amount", "time-csv-total"].map(|w| tr().text(w, None));
171    let csv = timereport::billable_csv(&all, found, from, to, config.invoice.rate, french, [&words[0], &words[1], &words[2], &words[3], &words[4]]);
172    let path = crate::backend::local_path(target);
173    std::fs::write(&path, csv).map_err(|e| format!("{}: {e}", path.display()))?;
174    Ok(path.display().to_string())
175}
176
177/// A file address's path with its %-escapes undone ("Mes%20factures" → "Mes factures").
178pub(crate) fn percent_decoded(path: &str) -> String {
179    let bytes = path.as_bytes();
180    let mut out = Vec::with_capacity(bytes.len());
181    let mut i = 0;
182    while i < bytes.len() {
183        if bytes[i] == b'%'
184            && let Some(byte) = path.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
185        {
186            out.push(byte);
187            i += 3;
188            continue;
189        }
190        out.push(bytes[i]);
191        i += 1;
192    }
193    String::from_utf8_lossy(&out).to_string()
194}
195
196/// Time noted by hand, as its form gives it.
197#[derive(Deserialize)]
198struct TimeEdit {
199    /// "2026-10-05".
200    day: String,
201    /// "14:00", or "" for the middle of the day.
202    #[serde(default)]
203    at: String,
204    minutes: u32,
205    #[serde(default)]
206    project: String,
207    #[serde(default)]
208    task: String,
209    #[serde(default)]
210    note: String,
211    #[serde(default)]
212    unbilled: bool,
213}
214
215/// Notes time by hand; returns what went wrong, else "".
216pub(crate) fn note_time(qt: &QtThread, shared: &Arc<Shared>, edit: &str) -> String {
217    let result = serde_json::from_str::<TimeEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
218        if edit.minutes == 0 {
219            return Err(tr().text("time-no-minutes", None));
220        }
221        if edit.project.is_empty() && edit.task.is_empty() {
222            return Err(tr().text("time-no-project-chosen", None));
223        }
224        let day = edit.day.parse::<Date>().map_err(|_| tr().text("time-bad-day", None))?;
225        let (hour, minute) = edit.at.split_once(':').and_then(|(h, m)| Some((h.trim().parse::<i8>().ok()?, m.trim().parse::<i8>().ok()?))).unwrap_or((12, 0));
226        let start = day.at(hour.clamp(0, 23), minute.clamp(0, 59), 0, 0).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
227        timelog::record(&Session { task: edit.task, project: edit.project, start, minutes: edit.minutes, note: edit.note.trim().to_string(), unbilled: edit.unbilled, kind: Some(timelog::Kind::Typed), ..Session::default() })
228    });
229    work::refresh(qt, shared);
230    result.err().unwrap_or_default()
231}
232
233/// A stretch of time changed, as its form gives it: its day, from when to
234/// when, what it was, its task and project.
235#[derive(Deserialize)]
236struct TimeChange {
237    day: String,
238    from: String,
239    to: String,
240    #[serde(default)]
241    project: String,
242    #[serde(default)]
243    task: String,
244    #[serde(default)]
245    note: String,
246    #[serde(default)]
247    unbilled: bool,
248}
249
250/// Any stretch of time changed once noted, by the timer or by hand: its
251/// start, its end, what it was, its task, its project. Billed time stays as
252/// billed. Returns what went wrong, else "".
253pub(crate) fn change_time(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str) -> String {
254    let result = serde_json::from_str::<TimeChange>(edit).map_err(|e| e.to_string()).and_then(|edit| {
255        if timelog::sessions().iter().any(|s| s.key() == key && !s.invoice.is_empty()) {
256            return Err(tr().text("time-billed-stays", None));
257        }
258        let day = edit.day.parse::<Date>().map_err(|_| tr().text("time-bad-day", None))?;
259        let clock = |text: &str| text.split_once(':').and_then(|(h, m)| jiff::civil::Time::new(h.trim().parse().ok()?, m.trim().parse().ok()?, 0, 0).ok());
260        let (Some(from), Some(to)) = (clock(&edit.from), clock(&edit.to)) else { return Err(tr().text("time-bad-hours", None)) };
261        let start = day.to_datetime(from).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
262        // Ending at or before it began: past midnight.
263        let mut end = day.to_datetime(to).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
264        if end <= start {
265            end += 86_400;
266        }
267        let minutes = u32::try_from((end - start + 30) / 60).unwrap_or(0);
268        if minutes == 0 {
269            return Err(tr().text("time-no-minutes", None));
270        }
271        if edit.project.is_empty() && edit.task.is_empty() {
272            return Err(tr().text("time-no-project-chosen", None));
273        }
274        let changed = timelog::change_in(&timelog::folder(), key, |s| {
275            s.start = start;
276            // A timed stretch whose minutes change becomes "corrected" (docs/capacity.md).
277            s.set_minutes(minutes);
278            s.task = edit.task.clone();
279            s.project = edit.project.clone();
280            s.note = edit.note.trim().to_string();
281            s.unbilled = edit.unbilled;
282        })?;
283        if changed { Ok(()) } else { Err(tr().text("time-gone", None)) }
284    });
285    work::refresh(qt, shared);
286    result.err().unwrap_or_default()
287}
288
289/// The tasks time can be given to, as JSON [{uid, title, project}]: the open
290/// ones, and `keep` (the stretch's own task, done or not).
291pub(crate) fn task_choices(shared: &Shared, keep: &str) -> String {
292    let loaded = loaded(shared);
293    let mut tasks: Vec<&sioul_core::tasks::Task> = loaded.tasks.iter().filter(|t| t.status.is_open() || t.uid == keep).collect();
294    tasks.sort_by_key(|t| t.title.to_lowercase());
295    json(&tasks.iter().map(|t| serde_json::json!({ "uid": t.uid, "title": t.title, "project": t.projects.first().cloned().unwrap_or_default() })).collect::<Vec<_>>())
296}
297
298/// Takes out time noted by hand; billed time stays.
299pub(crate) fn remove_time(qt: &QtThread, shared: &Arc<Shared>, key: &str) -> String {
300    let billed = timelog::sessions().iter().any(|s| s.key() == key && !s.invoice.is_empty());
301    if billed {
302        return tr().text("time-billed-stays", None);
303    }
304    let result = timelog::remove_in(&timelog::folder(), &[key.to_string()]);
305    work::refresh(qt, shared);
306    match result {
307        Ok(_) => String::new(),
308        Err(e) => e,
309    }
310}
311
312/// Where an invoice's PDF goes: the folder set, else ~/Documents/Invoices (Factures in French).
313fn pdf_path(invoice: &Invoice) -> PathBuf {
314    let config = load_config();
315    let folder = if config.invoice.folder.trim().is_empty() {
316        sioul_core::config::expand_home(&format!("~/Documents/{}", tr().text("invoice-folder-name", None)))
317    } else {
318        sioul_core::config::expand_home(config.invoice.folder.trim())
319    };
320    let client: String = invoice.client.chars().filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-').collect();
321    // A prefix such as "F/2026-" makes no folder, and nothing a system refuses in a name.
322    let number: String = invoice.number.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '-' }).collect();
323    folder.join(format!("{} {}.pdf", number.trim_matches('.'), client.trim()).trim().to_string())
324}
325
326/// Why invoices cannot be numbered here now, as the window's answer
327/// ({"error", "elsewhere"}: "elsewhere" offers to make them here); None when they can.
328fn invoices_held_elsewhere(shared: &Shared) -> Option<String> {
329    let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
330    let (keeper, unwritten) = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, false);
331    let now = jiff::Timestamp::now().as_second();
332    let refused = |text: String, elsewhere: bool| Some(serde_json::json!({ "error": text, "elsewhere": elsewhere }).to_string());
333    if unwritten {
334        return refused(tr().text("invoice-unchecked", None), false);
335    }
336    if !keeper.mine {
337        return refused(say("invoice-elsewhere", &[("computer", keeper.name)]), true);
338    }
339    if !keeper.settled {
340        return refused(tr().text("invoice-settling", None), false);
341    }
342    // Another computer alive but not heard from lately: the sync may hold its claim back.
343    if keeper.others > 0 && now - keeper.others_heard > 3 * 60 {
344        return refused(tr().text("invoice-others-silent", None), false);
345    }
346    None
347}
348
349/// The lease's name for invoices' numbers.
350pub(crate) const INVOICES: &str = "invoices";
351
352/// Invoices made on this computer from now on, on purpose; returns what to say.
353pub(crate) fn take_invoices(shared: &Shared) -> String {
354    let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
355    let (keeper, unwritten) = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, true);
356    if unwritten {
357        tr().text("invoice-unchecked", None)
358    } else if keeper.settled {
359        tr().text("invoice-here", None)
360    } else {
361        tr().text("invoice-settling", None)
362    }
363}
364
365/// This computer's claim on invoices, renewed each minute while you make some (a sender set).
366pub(crate) fn keep_invoices(shared: &Shared) {
367    if !load_config().invoice.name.trim().is_empty() && crate::share::on() {
368        let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
369        let _ = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, false);
370    }
371}
372
373/// Who a project bills: the contact it names, with its postal address, else the name as written.
374fn client_of(shared: &Shared, client: &str) -> (String, String) {
375    let loaded = loaded(shared);
376    // A contact's address, or a name that is one of your contacts' (its name or organisation).
377    let named = || loaded.contacts.iter().find(|c| !client.trim().is_empty() && (c.name.eq_ignore_ascii_case(client.trim()) || c.org.eq_ignore_ascii_case(client.trim()))).cloned();
378    match client.strip_prefix("sioul:contact/").map(sioul_core::notes::decode).and_then(|uid| loaded.contacts.iter().find(|c| c.uid == uid).cloned()).or_else(named) {
379        Some(contact) => {
380            let name = if contact.org.is_empty() || contact.org == contact.name { contact.name.clone() } else { format!("{}\n{}", contact.org, contact.name) };
381            (name, contact.addresses.first().map(|a| a.value.clone()).unwrap_or_default())
382        }
383        None => (client.to_string(), String::new()),
384    }
385}
386
387/// Makes the invoice of a project's unbilled hours: its record, its hours
388/// marked, its payment expected in the project's budget. Returns {"number",
389/// "html", "pdf"} for the window to print, or {"error"}.
390pub(crate) fn make_invoice(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
391    // One computer numbers invoices (`sioul_sync::lease`): two at once could give one number twice.
392    if let Some(elsewhere) = invoices_held_elsewhere(shared) {
393        return elsewhere;
394    }
395    let result = (|| -> Result<serde_json::Value, String> {
396        let config = load_config();
397        let store = store()?;
398        let project = store.get(id).ok_or_else(|| tr().text("project-gone", None))?.clone();
399        let existing = invoice::all_in(&invoice::folder());
400        let today = Zoned::now().date();
401        let number = invoice::next_number(&existing, &config.invoice.prefix, today.year());
402        let (client, address) = client_of(shared, project.client.as_deref().unwrap_or(""));
403        let made = invoice::make(&entries(shared), &project, &client, &address, &config.invoice, &number, today).ok_or_else(|| tr().text("invoice-nothing", None))?;
404        invoice::save_in(&invoice::folder(), &made)?;
405        timelog::update_in(&timelog::folder(), &made.sessions, |s| s.invoice = number.clone())?;
406        // Expected in the project's budget until paid.
407        if let Some(budget_id) = project.budget.as_deref().filter(|b| !b.is_empty()) {
408            let ledger = store.root.join(budget::LEDGER);
409            let line = Line {
410                budget: budget_id.to_string(),
411                date: made.due.parse().unwrap_or(today),
412                amount: Money(made.total_cents),
413                label: say("invoice-budget-line", &[("number", number.clone()), ("client", client.lines().next().unwrap_or("").to_string())]),
414                planned: true,
415                reserve: None,
416                links: vec![format!("sioul:invoice/{number}"), links::project_uri(&project.id)],
417                preset: None,
418            };
419            budget::record_line(&ledger, &line, &say("invoice-budget-origin", &[("number", number.clone())]))?;
420        }
421        Ok(serde_json::json!({ "number": number, "html": invoice::html(&made, tr()), "pdf": pdf_path(&made).display().to_string() }))
422    })();
423    work::refresh(qt, shared);
424    match result {
425        Ok(answer) => answer.to_string(),
426        Err(error) => serde_json::json!({ "error": error }).to_string(),
427    }
428}
429
430/// An invoice made before, to print again: {"html", "pdf"}.
431pub(crate) fn invoice_again(number: &str) -> String {
432    match invoice::all_in(&invoice::folder()).into_iter().find(|i| i.number == number) {
433        Some(found) => serde_json::json!({ "html": invoice::html(&found, tr()), "pdf": pdf_path(&found).display().to_string() }).to_string(),
434        None => serde_json::json!({ "error": tr().text("invoice-gone", None) }).to_string(),
435    }
436}
437
438/// An invoice paid (or not): its record says so, and its budget line is no
439/// longer planned but dated today.
440pub(crate) fn set_paid(qt: &QtThread, shared: &Arc<Shared>, number: &str, paid: bool) -> String {
441    let result = (|| -> Result<(), String> {
442        let mut found = invoice::all_in(&invoice::folder()).into_iter().find(|i| i.number == number).ok_or_else(|| tr().text("invoice-gone", None))?;
443        found.paid = paid;
444        invoice::save_in(&invoice::folder(), &found)?;
445        if paid && let Ok(store) = store() {
446            budget::settle_line(&store.root.join(budget::LEDGER), &format!("sioul:invoice/{number}"), Zoned::now().date())?;
447        }
448        Ok(())
449    })();
450    work::refresh(qt, shared);
451    result.err().unwrap_or_default()
452}