Skip to main content

sioul_app/
work.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Tasks, notes, links and focus behind the window (docs/tasks.md).
5//!
6//! Tasks are written to their list's folder at once, then the account's sync
7//! sends them, as contacts and events are. The plan is computed again after
8//! every change, on a thread; the window only lays out what the core worded.
9//! Everything links reach (tasks, events, contacts, notes, drafts, mail
10//! headers) is read once per change and kept, so the Related panel answers at
11//! once.
12
13use crate::backend::{QtThread, Shared, load_config, say, tell, tr};
14use crate::{mail, pim};
15use cxx_qt_lib::QString;
16use jiff::tz::TimeZone;
17use jiff::civil::Date;
18use jiff::{Timestamp, Zoned};
19use serde::Serialize;
20use sioul_core::capture;
21use sioul_core::card::Card;
22use sioul_core::projects::ProjectStore;
23use sioul_core::compose::Draft;
24use sioul_core::links::{self, Kind as LinkKind, Loaded, LocalLinks};
25use sioul_core::notes;
26use sioul_core::plan::{self, Plan, Settings};
27use sioul_core::taskview::{self, BoardView, Context, Filter, ListView, NowView, TimelineView};
28use sioul_core::tasks::{self, ContactRef, Link, Status, Task, TaskEdit};
29use sioul_core::timelog::{self, Running};
30use sioul_core::today::{Today, Weather};
31use sioul_core::vdir::{self, Collection, Kind};
32use sioul_core::{agenda, contacts, lines, maildir};
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::sync::atomic::Ordering;
37
38/// Where new notes go in the notes folder, unless the configuration says otherwise.
39const NOTES_FOLDER: &str = "notes";
40
41/// What the task pages ask for: the list's grouping, done tasks, a search, one
42/// project, one kind and category. All but the searches are kept between sessions.
43#[derive(Clone, Default, Serialize, serde::Deserialize)]
44pub(crate) struct WorkState {
45    #[serde(default)]
46    pub by: String,
47    #[serde(default)]
48    pub done: bool,
49    #[serde(skip)]
50    pub query: String,
51    /// One project; `case`, its name in a file kept before, reads too.
52    #[serde(default, alias = "case")]
53    pub project: String,
54    #[serde(default)]
55    pub filter: Filter,
56    #[serde(skip)]
57    pub notes_query: String,
58    /// Notes as a tree of folders; else one list.
59    #[serde(default)]
60    pub notes_tree: bool,
61    /// How other pages were left: a column folded, a section open ("sites-narrow"…).
62    #[serde(default)]
63    pub flags: std::collections::BTreeMap<String, bool>,
64}
65
66impl WorkState {
67    fn path() -> PathBuf {
68        sioul_core::config::state_dir().join("tasks-view.toml")
69    }
70
71    /// The choices as you left them.
72    pub(crate) fn load() -> WorkState {
73        std::fs::read_to_string(WorkState::path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
74    }
75
76    fn save(&self) {
77        let path = WorkState::path();
78        if let Some(parent) = path.parent() {
79            let _ = std::fs::create_dir_all(parent);
80        }
81        if let Ok(text) = toml::to_string(self) {
82            let _ = std::fs::write(&path, text);
83        }
84    }
85}
86
87/// Everything read, kept until the next change.
88pub(crate) fn loaded(shared: &Shared) -> Arc<Loaded> {
89    if let Some(found) = shared.loaded.lock().ok().and_then(|l| l.clone()) {
90        return found;
91    }
92    let fresh = Arc::new(read_loaded(shared));
93    if let Ok(mut cache) = shared.loaded.lock() {
94        *cache = Some(Arc::clone(&fresh));
95    }
96    fresh
97}
98
99/// Reads everything again, leaving out what waits ten seconds to be deleted.
100fn read_loaded(shared: &Shared) -> Loaded {
101    let mut fresh = Loaded::read(&load_config());
102    let (removed, _) = mail::hidden_pim(shared);
103    fresh.tasks.retain(|t| !removed.contains(Path::new(&t.key)));
104    // An event deleted, a task's time block left to the plan: gone from what things are tied to, too.
105    fresh.events.retain(|e| !removed.contains(Path::new(&e.key)));
106    fresh
107}
108
109/// How far ahead events take their time out of the plan's room: four weeks.
110const EVENTS_AHEAD: i64 = 28 * 86_400;
111
112/// The room for tasks: your hours, each kind for its own tasks, less the
113/// events of the coming weeks and the blocks tasks are pinned to; today's
114/// from now, scaled by the weather.
115fn settings(weather: Weather, situation: &sioul_core::quiet::Situation, projects: &[sioul_core::projects::Project], tasks: &[Task], shared: &Shared) -> Settings {
116    let config = load_config();
117    let now = Zoned::now();
118    let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(0, |z| z.timestamp().as_second());
119    let read = sioul_core::agenda::occurrences(midnight, midnight + EVENTS_AHEAD);
120    // The tasks pinned to a time: a block is its task's time, never an event of its own (docs/tasks.md, "Pinned to a time").
121    let pins = crate::blocks::pins(shared, &read, tasks, &now);
122    // Meals move past the blocks as past any event, as the Health page moves them.
123    let held = sioul_core::plan::event_spans(&read, 0);
124    let events = sioul_core::blocks::without_blocks(read, tasks);
125    // Meals, naps and the night first: the work goes around them, as each day has them.
126    let needs = sioul_core::health::Health::load(&sioul_core::health::Health::default_path()).needs;
127    let days = crate::health::days();
128    // Today's meals pushed past the events they would fall in (another day's: `with_events`).
129    let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &held);
130    // Today's end of work moved by free time (docs/pauses.md): its room for light steps only.
131    let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
132    let day = sioul_core::pause::Day::of(&config, crate::hours::blocks(&now), &events);
133    let extension = sioul_core::pause::moved_today(&overrides, &day.evening(), &now, config.free_time.moves);
134    let mut settings = Settings::of_hours(&config.week_hours(), sioul_core::areas::TaskAreas::of_config(&config, projects)).with_needs(&needs, pushed).with_days(days).with_extension(extension).with_pins(pins).with_events(&now, &events);
135    settings.default_estimate = config.tasks.estimate.unwrap_or(settings.default_estimate);
136    // Today by its weather, and no heavier than a hazy day after a pause (docs/pauses.md).
137    (settings.today_percent, settings.heavy_today) = sioul_core::pause::today_level(&overrides, now.date(), weather);
138    settings.closed = situation.closed.clone();
139    settings.office_days = situation.office_days;
140    settings.office_hours = config.office_hours();
141    settings
142}
143
144/// Asleep, the tasks all the same: the page asked ("Show anyway"), until it is left.
145static SHOWN_ANYWAY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
146
147pub(crate) fn show_anyway(qt: &QtThread, shared: &Arc<Shared>, on: bool) {
148    if SHOWN_ANYWAY.swap(on, Ordering::Relaxed) != on {
149        show_work(qt, shared);
150    }
151}
152
153/// The moment, for the task pages: quiet or not, offices open or not.
154pub(crate) fn situation(projects: &[sioul_core::projects::Project]) -> sioul_core::quiet::Situation {
155    let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
156    let now = Zoned::now();
157    sioul_core::quiet::Situation::now(&load_config(), &overrides, &crate::hours::blocks(&now), &now, tr(), projects)
158}
159
160/// The plan, and what it needs, from what was read.
161struct Desk {
162    loaded: Arc<Loaded>,
163    filter: Filter,
164    offices: taskview::Offices,
165    plan: Plan,
166    today: Today,
167    sessions: Vec<timelog::Session>,
168    spent: BTreeMap<String, u32>,
169    stopped: BTreeMap<String, String>,
170    settings: Settings,
171    /// Work rests now (quiet time); the filter alone says only that hours are set.
172    quiet: bool,
173}
174
175impl Desk {
176    fn new(loaded: Arc<Loaded>, shared: &Shared) -> Desk {
177        let date = Zoned::now().date();
178        let today = Today::load(&Today::default_path(), date);
179        let sessions = timelog::sessions();
180        let spent = timelog::spent(&sessions, 0, i64::MAX);
181        let stopped = sessions.iter().filter(|s| !s.note.is_empty()).map(|s| (s.task.clone(), s.note.clone())).collect();
182        let situation = situation(&loaded.projects);
183        // What your record says: ratings said after tasks, corrected lengths, what a day holds (docs/capacity.md).
184        let settings = crate::capacity::planned(settings(today.weather, &situation, &loaded.projects, &loaded.tasks, shared), &loaded.tasks, &sessions);
185        let plan = plan::plan(&loaded.tasks, date, &settings, &spent, &today.aside);
186        // Asleep no task shows, unless the page asked to see them all.
187        let anyway = situation.mode.sleeps() && SHOWN_ANYWAY.load(Ordering::Relaxed);
188        let filter = Filter { quiet: if anyway { None } else { situation.quiet_tasks() }, ..Filter::default() };
189        let quiet = situation.mode.quiet;
190        Desk { loaded, filter, offices: situation.offices, plan, today, sessions, spent, stopped, settings, quiet }
191    }
192
193    /// "Until Thursday 8 October: about 2 h of steps, 6 h of room.", for a date asked.
194    fn budget_line(&self, date: jiff::civil::Date) -> String {
195        let today = Zoned::now().date();
196        let budget = plan::cushion(&self.loaded.tasks, &self.plan, &self.settings, today, date);
197        if budget.need == 0 {
198            return String::new();
199        }
200        let span = |minutes: u32| match (minutes / 60, minutes % 60) {
201            (0, m) => format!("{m} min"),
202            (h, 0) => format!("{h} h"),
203            (h, m) => format!("{h} h {m:02}"),
204        };
205        let id = if budget.need > budget.room { "time-budget-short" } else { "time-budget" };
206        say(id, &[("date", tr().day(date)), ("need", span(budget.need)), ("room", span(budget.room))])
207    }
208
209    /// The next date asked within a week, among the tasks shown, and its time budget.
210    fn next_budget(&self) -> String {
211        let today = Zoned::now().date();
212        let week = today.checked_add(jiff::Span::new().days(7)).unwrap_or(today);
213        let shown = |t: &&Task| self.filter.quiet.as_ref().is_none_or(|q| q.keeps(t));
214        let next = self.loaded.tasks.iter().filter(|t| t.status.is_open()).filter(shown).filter(|t| self.plan.items.get(&t.uid).is_some_and(|p| !p.optional)).filter_map(|t| t.due_date()).filter(|d| *d >= today && *d <= week).min();
215        next.map(|date| self.budget_line(date)).unwrap_or_default()
216    }
217
218    fn context(&self) -> Context<'_> {
219        Context { filter: &self.filter, offices: self.offices.clone(), tasks: &self.loaded.tasks, plan: &self.plan, today: Zoned::now().date(), tr: tr(), projects: &self.loaded.projects, spent: &self.spent, stopped: &self.stopped }
220    }
221
222    fn task(&self, uid: &str) -> Option<&Task> {
223        self.loaded.tasks.iter().find(|t| t.uid == uid)
224    }
225}
226
227#[derive(Serialize)]
228struct ListChoice {
229    id: String,
230    name: String,
231    color: Option<String>,
232    /// Waits to be created on the server.
233    pending: bool,
234    /// It keeps tasks; a calendar that does not is shown, greyed, with why.
235    tasks: bool,
236}
237
238#[derive(Serialize)]
239struct ProjectChoice {
240    id: String,
241    title: String,
242}
243
244#[derive(Serialize)]
245struct KindChoice {
246    id: String,
247    label: String,
248}
249
250/// What the task page shows: its four views at once, so switching is instant.
251#[derive(Serialize)]
252struct TasksShown {
253    /// The choices as you left them.
254    view: WorkState,
255    /// The kinds of task, in your language.
256    kinds: Vec<KindChoice>,
257    /// The categories open tasks have, the most used first.
258    categories: Vec<String>,
259    weather: Weather,
260    now: NowView,
261    list: ListView,
262    board: BoardView,
263    timeline: TimelineView,
264    lists: Vec<ListChoice>,
265    projects: Vec<ProjectChoice>,
266    /// No list can take tasks yet.
267    no_list: bool,
268    /// Work time or quiet time.
269    quiet: bool,
270    /// "Monday. The plan starts with: …", once work is back, on its day.
271    first_step: String,
272    first_step_task: String,
273    /// The next date asked within a week: the time budget until then, else "".
274    budget: String,
275    /// Today laid out: events at their times, today's steps in the gaps.
276    day: sioul_core::dayview::DayView,
277    /// In free time, leisure offered, never a list to finish (docs/pauses.md).
278    free: Vec<serde_json::Value>,
279}
280
281/// Today, laid out: the events of today and the steps the plan gives today, those the page shows.
282fn today_laid_out(desk: &Desk, shared: &Shared) -> sioul_core::dayview::DayView {
283    let now = Zoned::now();
284    let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(0, |z| z.timestamp().as_second());
285    // Until the next midnight: a day of 23 or 25 hours when the clocks change.
286    let next_midnight = now.date().tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()).map_or(midnight + 86_400, |z| z.timestamp().as_second());
287    // Events deleted, or an occurrence left out, while "Undo" is offered: gone already.
288    let (removed, skipped) = crate::mail::hidden_pim(shared);
289    let events: Vec<sioul_core::agenda::Occurrence> = sioul_core::agenda::occurrences(midnight, next_midnight)
290        .into_iter()
291        .filter(|o| !removed.contains(std::path::Path::new(&o.key)) && !skipped.contains(&(std::path::PathBuf::from(&o.key), o.start)))
292        .collect();
293    let shown: Vec<Task> = desk.loaded.tasks.iter().filter(|t| desk.filter.quiet.as_ref().is_none_or(|q| q.keeps(t))).cloned().collect();
294    let mut day = sioul_core::dayview::day(&now, &events, &desk.plan, &shown, &desk.settings);
295    // Meals and naps without a name of their own: their usual one, in your language.
296    for block in day.blocks.iter_mut().filter(|b| (b.kind == "meal" || b.kind == "nap") && b.title.trim().is_empty()) {
297        block.title = crate::health::block_name(block.kind, &block.key, "");
298    }
299    // Time for you, the free time kept, and why today holds what it holds, in words.
300    crate::capacity::dress(&mut day, &desk.loaded.tasks);
301    // The steps as laid, for the Health page's day (faded, for context).
302    if let Ok(mut steps) = DAY_STEPS.lock() {
303        *steps = day.blocks.iter().filter(|b| b.kind == "task").map(|b| (b.start, b.end, b.title.clone())).collect();
304    }
305    day
306}
307
308/// Today's steps as the day's layout last laid them: (start, end, title).
309static DAY_STEPS: std::sync::Mutex<Vec<(i64, i64, String)>> = std::sync::Mutex::new(Vec::new());
310
311/// Today's steps as last laid out, for the Health page's day: faded, for context.
312pub(crate) fn day_steps() -> Vec<(i64, i64, String)> {
313    DAY_STEPS.lock().map(|steps| steps.clone()).unwrap_or_default()
314}
315
316/// The routines, as the page lists them: the admin window's first (made from
317/// what is there now), then yours, each with its steps as lines to change.
318pub(crate) fn routines(shared: &Shared) -> String {
319    use sioul_core::routines::{Routine, Step, steps_text};
320    #[derive(Serialize)]
321    struct Shown {
322        #[serde(flatten)]
323        routine: Routine,
324        text: String,
325        builtin: bool,
326    }
327    let desk = Desk::new(loaded(shared), shared);
328    let mut steps = vec![Step { title: tr().text("routine-admin-porch", None), minutes: 10, open: "porch".into() }];
329    if let Some(next) = desk.plan.next.as_deref().and_then(|uid| desk.task(uid)) {
330        steps.push(Step { title: say("routine-admin-next", &[("title", next.title.clone())]), minutes: desk.plan.items.get(&next.uid).map_or(25, |p| p.left.clamp(5, 45)), open: links::task_uri(&next.uid) });
331    }
332    steps.push(Step { title: tr().text("routine-admin-stop", None), minutes: 3, open: String::new() });
333    let admin = Routine { id: "admin".into(), title: tr().text("routine-admin", None), auto: false, steps };
334    let mut out = vec![Shown { text: steps_text(&admin.steps), routine: admin, builtin: true }];
335    out.extend(load_config().routines.into_iter().map(|r| Shown { text: steps_text(&r.steps), routine: r, builtin: false }));
336    crate::backend::json(&out)
337}
338
339/// A routine saved (new when `id` is empty) from its title and its steps, one a line; returns what went wrong, else "".
340pub(crate) fn save_routine(id: &str, title: &str, text: &str, auto: bool) -> String {
341    let title = title.trim();
342    if title.is_empty() {
343        return tr().text("routine-no-title", None);
344    }
345    let steps = sioul_core::routines::parse_steps(text);
346    if steps.is_empty() {
347        return tr().text("routine-no-steps", None);
348    }
349    // Read, changed and written under the configuration's lock, which the sharing takes too.
350    sioul_core::routines::change(&crate::backend::config_path(), |routines| {
351        let id = if id.is_empty() {
352            let taken: Vec<String> = routines.iter().map(|r| r.id.clone()).chain(std::iter::once("admin".to_string())).collect();
353            sioul_core::projects::new_id(title, &taken)
354        } else {
355            id.to_string()
356        };
357        let routine = sioul_core::routines::Routine { id: id.clone(), title: title.to_string(), auto, steps };
358        match routines.iter_mut().find(|r| r.id == id) {
359            Some(place) => *place = routine,
360            None => routines.push(routine),
361        }
362    })
363    .err()
364    .unwrap_or_default()
365}
366
367/// A routine taken out; returns what went wrong, else "".
368pub(crate) fn remove_routine(id: &str) -> String {
369    sioul_core::routines::change(&crate::backend::config_path(), |routines| routines.retain(|r| r.id != id)).err().unwrap_or_default()
370}
371
372/// The lists a task can go into.
373fn writable_lists() -> Vec<ListChoice> {
374    let config = load_config();
375    let choice = |c: Collection, tasks: bool| ListChoice { pending: vdir::State::load(&c.state_path()).pending, id: format!("{}/{}", c.account, c.id), name: c.label(&config, tr()), color: c.color, tasks };
376    let mut lists: Vec<ListChoice> = tasks::lists().into_iter().filter(|c| !c.read_only).map(|c| choice(c, true)).collect();
377    // The calendars that keep no task (Google's, event-only ones): greyed, never hidden.
378    lists.extend(vdir::collections(Kind::Calendars).into_iter().filter(|c| !c.read_only && !c.holds("VTODO")).map(|c| choice(c, false)));
379    lists
380}
381
382/// A task moved to another list, its steps with it: written there, then taken
383/// out here (the accounts send both). When the list would not keep something
384/// the task uses, nothing moves until `confirmed`: returns `{"losses": [words]}`,
385/// else `{"uid"}` or `{"error"}`.
386pub(crate) fn move_task(qt: &QtThread, shared: &Arc<Shared>, uid: &str, list: &str, confirmed: bool) -> String {
387    let loaded = loaded(shared);
388    let Some(target) = tasks::lists().into_iter().find(|c| !c.read_only && format!("{}/{}", c.account, c.id) == list) else { return answer(Err(tr().text("task-list-no-tasks", None))) };
389    // The task and every step under it.
390    let mut family: Vec<&Task> = Vec::new();
391    let mut queue = vec![uid.to_string()];
392    while let Some(next) = queue.pop() {
393        if let Some(task) = loaded.tasks.iter().find(|t| t.uid == next) {
394            if task.read_only {
395                return answer(Err(tr().text("task-read-only", None)));
396            }
397            queue.extend(loaded.tasks.iter().filter(|t| t.parent() == Some(task.uid.as_str())).map(|t| t.uid.clone()));
398            family.push(task);
399        }
400    }
401    if family.is_empty() {
402        return answer(Err(tr().text("task-gone", None)));
403    }
404    let config = load_config();
405    let provider = sioul_core::capabilities::provider_of_collection(config.account(&target.account), &target);
406    let mut losses: Vec<&str> = family.iter().flat_map(|t| sioul_core::capabilities::task_losses(t, provider)).collect();
407    losses.sort_unstable();
408    losses.dedup();
409    if !losses.is_empty() && !confirmed {
410        return serde_json::json!({ "losses": losses.iter().map(|l| tr().text(&format!("loss-{l}"), None)).collect::<Vec<_>>() }).to_string();
411    }
412    let mut accounts = BTreeSet::new();
413    for task in family {
414        let from = Path::new(&task.key);
415        if from.parent() == Some(target.dir.as_path()) {
416            continue;
417        }
418        let moved = std::fs::read_to_string(from).map_err(|e| e.to_string()).and_then(|text| vdir::write_item(&target.dir.join(format!("{}.ics", vdir::new_name())), &text)).and_then(|()| std::fs::remove_file(from).map_err(|e| e.to_string()));
419        if let Err(e) = moved {
420            return answer(Err(e));
421        }
422        accounts.extend(account_of(from));
423    }
424    accounts.insert(target.account.clone());
425    for account in accounts {
426        pim::nudge(shared, &account);
427    }
428    if let Ok(mut cache) = shared.loaded.lock() {
429        *cache = None;
430    }
431    show_work(qt, shared);
432    answer(Ok(uid.to_string()))
433}
434
435/// Reads everything again, computes the task and notes pages on a thread, and shows them.
436pub(crate) fn show_work(qt: &QtThread, shared: &Arc<Shared>) {
437    let qt = qt.clone();
438    crate::backend::coalesced(shared, |s| &s.work_job, move |shared| {
439        let generation = shared.work_generation.fetch_add(1, Ordering::Relaxed) + 1;
440        // Today's steps as laid before: the Health page's day shows them faded.
441        let steps_before = day_steps();
442        let fresh = Arc::new(read_loaded(shared));
443        if let Ok(mut cache) = shared.loaded.lock() {
444            *cache = Some(Arc::clone(&fresh));
445        }
446        let state = shared.work.lock().map(|s| s.clone()).unwrap_or_default();
447        let mut desk = Desk::new(fresh, &shared);
448        // What changed elsewhere in the blocks set right, a time given by a drag before them made one:
449        // laid again then, at once (`blocks::upkeep`; nothing written when nothing differs).
450        if crate::blocks::upkeep(&shared, &desk.loaded.tasks, &Zoned::now()) {
451            show_work(&qt, &shared);
452            pim::show_pim(&qt, &shared);
453        }
454        // The phone's home screen card: the next step as each coming time will have it (homecard.rs).
455        crate::homecard::plan_seen(&desk.loaded, &desk.plan, &desk.today, &desk.spent, &desk.stopped);
456        desk.filter = Filter { project: state.project.clone(), quiet: desk.filter.quiet.clone(), ..state.filter.clone() };
457        let cx = desk.context();
458        let project = Some(state.project.as_str()).filter(|c| !c.is_empty());
459        let rest = desk.settings.rest_days();
460        let lists = writable_lists();
461        let mut counted: BTreeMap<String, (usize, String)> = BTreeMap::new();
462        for task in desk.loaded.tasks.iter().filter(|t| t.status.is_open()) {
463            for category in &task.categories {
464                counted.entry(category.to_lowercase()).or_insert((0, category.clone())).0 += 1;
465            }
466        }
467        let mut categories: Vec<(usize, String)> = counted.into_values().collect();
468        categories.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase())));
469        // The day's first step, as the plan had it when the day before was closed.
470        let today = Zoned::now();
471        let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
472        let first = match overrides.first_step_on(today.date()).filter(|_| desk.filter.quiet.is_none()) {
473            Some((step, uid)) => (say("first-step-line", &[("day", capitalized(&tr().text(&format!("weekday-{}", today.date().weekday().to_monday_one_offset()), None))), ("step", step.to_string())]), uid.to_string()),
474            None => (String::new(), String::new()),
475        };
476        let shown = TasksShown {
477            view: state.clone(),
478            kinds: load_config().task_kinds(tr()).into_iter().map(|(id, label)| KindChoice { id, label }).collect(),
479            categories: categories.into_iter().map(|(_, c)| c).collect(),
480            weather: desk.today.weather,
481            now: taskview::now(&cx, desk.today.weather, &desk.today.aside),
482            list: taskview::list(&cx, if state.by.is_empty() { "project" } else { &state.by }, state.done, &state.query),
483            board: taskview::board(&cx, project),
484            timeline: taskview::timeline(&cx, project, &rest),
485            no_list: lists.is_empty(),
486            lists,
487            projects: desk.loaded.projects.iter().filter(|c| c.status.as_deref() != Some("closed")).map(|c| ProjectChoice { id: c.id.clone(), title: c.title.clone() }).collect(),
488            quiet: desk.quiet,
489            first_step: first.0,
490            first_step_task: first.1,
491            budget: desk.next_budget(),
492            day: today_laid_out(&desk, &shared),
493            free: crate::pauses::offers(&desk.loaded.tasks, desk.filter.quiet.as_ref(), crate::hours::mode_now().free()),
494        };
495        let tasks_json = crate::backend::json(&shown);
496        // The steps laid elsewhere now (a meal moved, a step given a time): the Health page's day follows at once.
497        if day_steps() != steps_before {
498            crate::health::show_health(&qt, shared);
499        }
500        let notes_json = notes_list(&desk.loaded, &state.notes_query, state.notes_tree);
501        let focus_json = focus_json(&desk);
502        // The time running, in the system's notifications as in the focus window.
503        crate::timenote::follow(&qt, shared);
504        let _ = qt.queue(move |mut sioul| {
505            // Only the newest (see `backend::show`).
506            if sioul.shared().work_generation.load(Ordering::Relaxed) != generation || sioul.shared().work_shown_generation.fetch_max(generation, Ordering::Relaxed) > generation {
507                return;
508            }
509            sioul.as_mut().set_tasks(QString::from(&tasks_json));
510            sioul.as_mut().set_notes(QString::from(&notes_json));
511            sioul.as_mut().set_focus_session(QString::from(&focus_json));
512        });
513    });
514}
515
516/// The plan's first step when work comes back: in words, and its task.
517#[derive(Serialize, Clone, Default)]
518pub(crate) struct FirstStep {
519    pub uid: String,
520    pub text: String,
521}
522
523/// A date asked that falls before work comes back: told once, at closing.
524#[derive(Serialize)]
525struct Deadline {
526    uid: String,
527    line: String,
528}
529
530/// The screen after closing the day, read in ten seconds: where work went and
531/// when it comes back, what got done if anything, the first step, what still
532/// gets through. Nothing counts what was not done (docs/tasks.md).
533#[derive(Serialize)]
534pub(crate) struct Closing {
535    /// "Work is put away until Monday 5 October at 09:00."
536    put_away: String,
537    /// "Done today: …", "Worked on: …": only when something was.
538    did: Vec<String>,
539    /// "Everything else has its place, from Monday, in the usual order."
540    rest: String,
541    /// "Monday starts with:"
542    starts_with: String,
543    first: FirstStep,
544    deadline: Option<Deadline>,
545    /// "Leave it for Monday".
546    leave: String,
547}
548
549/// The day closed: the screen to show, when work comes back, and its first step.
550pub(crate) struct Closed {
551    pub closing: Closing,
552    pub back: Zoned,
553    pub first: FirstStep,
554}
555
556/// "tomorrow", "Monday", "Monday 19 October": the nearer, the shorter.
557fn day_name(day: Date, today: Date) -> String {
558    if today.tomorrow().is_ok_and(|t| t == day) {
559        tr().text("day-tomorrow", None)
560    } else if day > today && day.since(today).is_ok_and(|s| s.get_days() < 7) {
561        tr().text(&format!("weekday-{}", day.weekday().to_monday_one_offset()), None)
562    } else {
563        tr().day(day)
564    }
565}
566
567/// The first letter capitalized, for a word starting a sentence ("lundi" → "Lundi").
568fn capitalized(text: &str) -> String {
569    let mut chars = text.chars();
570    chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
571}
572
573/// Closes the day in the plan: today's room is taken away and everything flows
574/// on to the next days with room, as each morning; nothing is written into the
575/// tasks. Computed before the overrides change.
576pub(crate) fn closing(shared: &Shared) -> Closed {
577    let loaded = loaded(shared);
578    let now = Zoned::now();
579    let date = now.date();
580    let today = Today::load(&Today::default_path(), date);
581    let sessions = timelog::sessions();
582    let spent = timelog::spent(&sessions, 0, i64::MAX);
583    let mut situation = situation(&loaded.projects);
584    let before = plan::plan(&loaded.tasks, date, &settings(today.weather, &situation, &loaded.projects, &loaded.tasks, shared), &spent, &today.aside);
585    situation.closed.insert(date);
586    let after = plan::plan(&loaded.tasks, date, &settings(today.weather, &situation, &loaded.projects, &loaded.tasks, shared), &spent, &today.aside);
587    let config = load_config();
588    let back = sioul_core::quiet::after_today(&config.working_hours(), &config.time_off, &now, i8::try_from(config.agenda.day_start.unwrap_or(7)).unwrap_or(7));
589    let back_day = back.date();
590    let task = |uid: &str| loaded.tasks.iter().find(|t| t.uid == uid);
591    // What got done, and what was worked on: three lines at most, nothing when nothing.
592    let morning = date.to_zoned(now.time_zone().clone()).map_or(0, |z| z.timestamp().as_second());
593    let done: Vec<&Task> = loaded.tasks.iter().filter(|t| t.completed.is_some_and(|c| c >= morning)).take(3).collect();
594    let mut worked: BTreeMap<String, u32> = BTreeMap::new();
595    for session in sessions.iter().filter(|s| s.start >= morning && !s.task.is_empty() && !s.done) {
596        if task(&session.task).is_some_and(|t| t.status.is_open()) {
597            *worked.entry(session.task.clone()).or_default() += session.minutes;
598        }
599    }
600    let mut did = Vec::new();
601    if !done.is_empty() {
602        did.push(say("closing-done", &[("list", done.iter().map(|t| t.title.clone()).collect::<Vec<_>>().join(", "))]));
603    }
604    let room = 3usize.saturating_sub(done.len());
605    if room > 0 && !worked.is_empty() {
606        let list: Vec<String> = worked.iter().filter_map(|(uid, minutes)| task(uid).map(|t| format!("{} ({})", t.title, sioul_core::timereport::duration(*minutes)))).take(room).collect();
607        did.push(say("closing-worked", &[("list", list.join(", "))]));
608    }
609    // Where the rest went, without a number.
610    let moved = before.order.iter().any(|uid| {
611        let (Some(was), Some(will)) = (before.items.get(uid), after.items.get(uid)) else { return false };
612        !was.optional && was.start.is_some_and(|a| a <= date) && will.start.is_none_or(|b| b > date)
613    });
614    let rest = if moved { say("closing-rest", &[("day", day_name(back_day, date))]) } else { String::new() };
615    // The first step: the note left when a session stopped today, else the plan's first.
616    let noted = sessions.iter().filter(|s| s.start >= morning && !s.note.is_empty() && task(&s.task).is_some_and(|t| t.status.is_open())).max_by_key(|s| s.start);
617    let first = match noted {
618        Some(session) => FirstStep { uid: session.task.clone(), text: session.note.clone() },
619        None => after
620            .order
621            .iter()
622            .filter_map(|uid| Some((uid, after.items.get(uid)?)))
623            .filter(|(_, p)| !p.optional && p.open_steps == 0 && p.start.is_some_and(|s| s >= back_day))
624            .min_by_key(|(_, p)| p.start)
625            .and_then(|(uid, p)| {
626                let t = task(uid)?;
627                let text = if p.left > 0 { format!("{} ({})", t.title, sioul_core::timereport::duration(p.left)) } else { t.title.clone() };
628                Some(FirstStep { uid: uid.clone(), text })
629            })
630            .unwrap_or_default(),
631    };
632    let starts_with = say("closing-starts-with", &[("day", capitalized(&day_name(back_day, date)))]);
633    // A date asked before work comes back: the nearest, told once.
634    let deadline = loaded
635        .tasks
636        .iter()
637        .filter(|t| t.status.is_open())
638        .filter_map(|t| Some((t, t.due_date()?)))
639        .filter(|(_, due)| *due >= date && *due < back_day)
640        .min_by_key(|(_, due)| *due)
641        .map(|(t, due)| {
642            let day = if due == date { tr().text("day-today", None) } else { day_name(due, date) };
643            Deadline { uid: t.uid.clone(), line: say("closing-deadline", &[("title", t.title.clone()), ("day", day)]) }
644        });
645    let closing = Closing {
646        put_away: say("closing-put-away", &[("back", sioul_core::quiet::until_text(tr(), &back, &now))]),
647        did,
648        rest,
649        starts_with,
650        first: first.clone(),
651        deadline,
652        leave: say("closing-leave", &[("day", day_name(back_day, date))]),
653    };
654    Closed { closing, back, first }
655}
656
657/// The task page's choices: how the list is grouped, done tasks, a search, one project.
658pub(crate) fn set_view(qt: &QtThread, shared: &Arc<Shared>, by: &str, done: bool, query: &str, project: &str) {
659    if let Ok(mut state) = shared.work.lock() {
660        state.by = by.to_string();
661        state.done = done;
662        state.query = query.to_string();
663        state.project = project.to_string();
664        state.save();
665    }
666    show_work(qt, shared);
667}
668
669/// Tasks of one kind and one category only ("" for all), kept for next time.
670pub(crate) fn set_filter(qt: &QtThread, shared: &Arc<Shared>, kind: &str, category: &str) {
671    if let Ok(mut state) = shared.work.lock() {
672        state.filter = Filter { kind: kind.to_string(), category: category.to_string(), ..Filter::default() };
673        state.save();
674    }
675    show_work(qt, shared);
676}
677
678/// The account a task file syncs through.
679fn account_of(path: &Path) -> Option<String> {
680    path.strip_prefix(Kind::Calendars.root()).ok().and_then(|rest| rest.components().next()).map(|c| c.as_os_str().to_string_lossy().to_string())
681}
682
683/// Writes a task file, has its account send it, and shows everything again.
684fn write(qt: &QtThread, shared: &Arc<Shared>, path: &Path, text: &str) -> Result<(), String> {
685    vdir::write_item(path, text)?;
686    if let Some(account) = account_of(path) {
687        pim::nudge(shared, &account);
688    }
689    show_work(qt, shared);
690    Ok(())
691}
692
693/// The list a new task goes into: the one chosen ("account/id"), else the
694/// usual one (Tasks ⚙, "New tasks go into"), else the first made for tasks.
695fn target_list(id: &str) -> Result<Collection, String> {
696    let lists: Vec<Collection> = tasks::lists().into_iter().filter(|c| !c.read_only).collect();
697    let usual = load_config().tasks.list.clone().unwrap_or_default();
698    let named = |wanted: &str| lists.iter().find(|c| !wanted.is_empty() && format!("{}/{}", c.account, c.id) == wanted).cloned();
699    named(id).or_else(|| named(&usual)).or_else(tasks::default_list).ok_or_else(|| tr().text("task-no-list-yet", None))
700}
701
702/// The form of a task not made yet, as `task` gives one made (no card: it is
703/// not read yet): blank, or what Add ▾ makes from `from` (`key`, `start`: which
704/// message, which occurrence). In `list`, else the usual list; what that list
705/// keeps. Nothing is written: `save` with an empty UID makes it, once it has
706/// a title. {"error"} when what it comes from is gone.
707pub(crate) fn new_form(shared: &Shared, from: &str, key: &str, start: f64, list: &str) -> String {
708    let made = if from.is_empty() { Ok(TaskEdit::default()) } else { linked_edit(shared, from, key, start) };
709    let edit = match made {
710        Ok(edit) => edit,
711        Err(error) => return answer(Err(error)),
712    };
713    let loaded = loaded(shared);
714    let config = load_config();
715    let target = target_list(list).ok();
716    let list_id = target.as_ref().map(|c| format!("{}/{}", c.account, c.id)).unwrap_or_default();
717    let limited = target
718        .as_ref()
719        .map(|c| sioul_core::capabilities::task_fields_lost(sioul_core::capabilities::provider_of_collection(config.account(&c.account), c), false))
720        .unwrap_or_default();
721    // What its tags would say it is for, as the panel shows it for a task made.
722    let shaped = Task { categories: edit.categories.clone(), projects: edit.projects.clone(), list_id: list_id.clone(), ..Task::default() };
723    let area_tags = sioul_core::areas::TaskAreas::of_config(&config, &loaded.projects).by_tags(&shaped).id();
724    let source = if from.is_empty() { String::new() } else { loaded.world().describe(&loaded.world().canonical(from)).title };
725    // What was felt after tasks of its title or its kind, faint in its form.
726    let proposed = sioul_core::capacity::FeltIndex::of(&loaded.tasks).proposal(&Task { title: edit.title.clone(), kind: edit.kind.clone(), ..Task::default() });
727    serde_json::json!({
728        "card": null,
729        "edit": edit,
730        "proposed": proposed,
731        "steps": [],
732        "steps_total": "",
733        "waits_for": [],
734        "frees": [],
735        "sessions": [],
736        "related": [],
737        "area_tags": area_tags,
738        "lists": writable_lists(),
739        "list": list_id,
740        "limited": limited,
741        "budget": "",
742        "source": source,
743        // As `task` gives them for a task made: nothing computed yet.
744        "level": "",
745        "estimate_first": null,
746        "ratio_line": "",
747        "felt_on": "",
748    })
749    .to_string()
750}
751
752/// Makes a task in a list; returns its UID.
753fn create(qt: &QtThread, shared: &Arc<Shared>, edit: &TaskEdit, list: &str) -> Result<String, String> {
754    if edit.title.trim().is_empty() {
755        return Err(tr().text("task-no-title", None));
756    }
757    let target = target_list(list)?;
758    let uid = vdir::new_name();
759    let text = tasks::new_task(edit, &uid, &TimeZone::system(), &Zoned::now())?;
760    write(qt, shared, &target.dir.join(format!("{uid}.ics")), &text)?;
761    write_gaps(qt, shared, &uid, edit);
762    Ok(uid)
763}
764
765/// The waits the form gave a gap (`TaskEdit::wait_gaps`), written in the
766/// tasks waited for, as `sioul tasks import` writes them (`tasks::set_gap`):
767/// once the task that waits is written. What fails is said in the status line;
768/// the task itself is kept.
769fn write_gaps(qt: &QtThread, shared: &Arc<Shared>, uid: &str, edit: &TaskEdit) {
770    let now = Zoned::now();
771    for (other, gap) in edit.gapped_waits() {
772        if let Err(e) = change(qt, shared, &other, |text| tasks::set_gap(text, uid, gap, &now)) {
773            tell(qt, shared, e);
774        }
775    }
776}
777
778/// A task in a list kept on this computer only, made the first time with
779/// `list_name`: what must never reach a server (health errands). Returns its UID.
780/// A task made in a list ("account/id"); returns its UID.
781pub(crate) fn create_task(qt: &QtThread, shared: &Arc<Shared>, edit: &TaskEdit, list: &str) -> Result<String, String> {
782    create(qt, shared, edit, list)
783}
784
785pub(crate) fn local_task(qt: &QtThread, shared: &Arc<Shared>, list_name: &str, edit: &TaskEdit) -> Result<String, String> {
786    let local = tasks::lists().into_iter().find(|c| c.account == vdir::LOCAL && !c.read_only);
787    let list = match local {
788        Some(list) => list,
789        None => vdir::create(Kind::Calendars, vdir::LOCAL, list_name, None, &["VTODO"])?,
790    };
791    create(qt, shared, edit, &format!("{}/{}", list.account, list.id))
792}
793
794fn find(shared: &Shared, uid: &str) -> Result<Task, String> {
795    loaded(shared).tasks.iter().find(|t| t.uid == uid).cloned().ok_or_else(|| tr().text("task-gone", None))
796}
797
798/// Changes one task's file with `change`, unless it can only be read.
799fn change(qt: &QtThread, shared: &Arc<Shared>, uid: &str, change: impl FnOnce(&str) -> Result<String, String>) -> Result<(), String> {
800    let task = find(shared, uid)?;
801    if task.read_only {
802        return Err(tr().text("task-read-only", None));
803    }
804    let current = std::fs::read_to_string(&task.key).map_err(|e| e.to_string())?;
805    let text = change(&current)?;
806    if text != current {
807        write(qt, shared, Path::new(&task.key), &text)?;
808    }
809    Ok(())
810}
811
812/// Every category tasks have, those of open tasks first, the most used first.
813pub(crate) fn categories_in_use(shared: &Shared) -> Vec<String> {
814    let mut counted: BTreeMap<String, (usize, usize, String)> = BTreeMap::new();
815    for task in &loaded(shared).tasks {
816        for category in &task.categories {
817            let entry = counted.entry(category.to_lowercase()).or_insert((0, 0, category.clone()));
818            if task.status.is_open() {
819                entry.0 += 1;
820            }
821            entry.1 += 1;
822        }
823    }
824    let mut all: Vec<(usize, usize, String)> = counted.into_values().collect();
825    all.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.to_lowercase().cmp(&b.2.to_lowercase())));
826    all.into_iter().map(|(_, _, c)| c).collect()
827}
828
829/// A category renamed on every task that has it, or taken off them all (`to`
830/// empty); a name already there merges with it. Written at once, then the
831/// accounts send the changes.
832pub(crate) fn rename_category(qt: &QtThread, shared: &Arc<Shared>, from: &str, to: &str) -> Result<(), String> {
833    let fold = |s: &str| s.trim().to_lowercase();
834    let (from, to) = (fold(from), to.trim().to_string());
835    let mut accounts = BTreeSet::new();
836    let mut problem = None;
837    for task in loaded(shared).tasks.iter().filter(|t| !t.read_only && t.categories.iter().any(|c| fold(c) == from)) {
838        let mut edit = TaskEdit::of(task);
839        let mut categories: Vec<String> = Vec::new();
840        for category in &edit.categories {
841            let next = if fold(category) == from { to.clone() } else { category.clone() };
842            if !next.is_empty() && !categories.iter().any(|c| fold(c) == fold(&next)) {
843                categories.push(next);
844            }
845        }
846        edit.categories = categories;
847        let path = Path::new(&task.key);
848        let written = std::fs::read_to_string(path)
849            .map_err(|e| e.to_string())
850            .and_then(|current| tasks::apply(&current, &edit, &TimeZone::system(), &Zoned::now()).map(|text| (current, text)))
851            .and_then(|(current, text)| if text == current { Ok(()) } else { vdir::write_item(path, &text) });
852        match written {
853            Ok(()) => {
854                if let Some(account) = account_of(path) {
855                    accounts.insert(account);
856                }
857            }
858            Err(e) => {
859                problem.get_or_insert(e);
860            }
861        }
862    }
863    for account in accounts {
864        pim::nudge(shared, &account);
865    }
866    show_work(qt, shared);
867    problem.map_or(Ok(()), Err)
868}
869
870/// A note moved to the vault's trash, "Undo" offered; returns what went wrong, else "".
871pub(crate) fn trash_note(qt: &QtThread, shared: &Arc<Shared>, path: &str) -> String {
872    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
873    match notes::trash(&root, path) {
874        Ok(trashed) => {
875            mail::offer_untrash(qt, shared, trashed, path.to_string(), say("note-trashed", &[("title", path.rsplit('/').next().unwrap_or(path).to_string())]));
876            show_work(qt, shared);
877            String::new()
878        }
879        Err(e) => e,
880    }
881}
882
883/// A note renamed, the notes, tasks and ties that name it following;
884/// returns {"path"} or {"error"}.
885pub(crate) fn rename_note(qt: &QtThread, shared: &Arc<Shared>, path: &str, name: &str) -> String {
886    let loaded = loaded(shared);
887    let Some(vault) = loaded.vault.as_ref() else { return answer(Err(tr().text("error-no-store", None))) };
888    // The notes naming it are read again and changed with the notes' lock held (an agent writing one meanwhile).
889    let (new_path, _) = match notes::with_lock(|| notes::rename(vault, path, name)) {
890        Ok(done) => done,
891        Err(e) => return answer(Err(e)),
892    };
893    retarget_note(qt, shared, &loaded, path, &new_path);
894    show_work(qt, shared);
895    serde_json::json!({ "path": new_path }).to_string()
896}
897
898/// What names a note by its address follows it to its new path: the tasks'
899/// links (changed, then sent), and the ties Sioul keeps itself.
900fn retarget_note(qt: &QtThread, shared: &Arc<Shared>, loaded: &Loaded, path: &str, new_path: &str) {
901    let (old_uri, new_uri) = (notes::uri_of(path), notes::uri_of(new_path));
902    // Tasks hold their links: each one naming the note is changed, then sent.
903    for task in loaded.tasks.iter().filter(|t| !t.read_only && t.links.iter().any(|l| l.uri == old_uri)) {
904        let mut edit = TaskEdit::of(task);
905        for link in edit.links.iter_mut().filter(|l| l.uri == old_uri) {
906            link.uri = new_uri.clone();
907        }
908        let _ = change(qt, shared, &task.uid, |text| tasks::apply(text, &edit, &TimeZone::system(), &Zoned::now()));
909    }
910    // Ties Sioul keeps itself, read again under their lock.
911    let _ = LocalLinks::change(&LocalLinks::default_path(), |ties| {
912        let mut moved = false;
913        for edge in ties.links.iter_mut() {
914            for end in [&mut edge.from, &mut edge.to] {
915                if *end == old_uri {
916                    *end = new_uri.clone();
917                    moved = true;
918                }
919            }
920        }
921        moved
922    });
923}
924
925/// One task in full, with its lists and what it is tied to, as JSON.
926pub(crate) fn task(shared: &Shared, uid: &str) -> String {
927    let loaded = loaded(shared);
928    let desk = Desk::new(Arc::clone(&loaded), shared);
929    let Some(task) = desk.task(uid) else { return String::new() };
930    let related = loaded.world().related(&links::task_uri(uid));
931    let mut detail = taskview::detail(&desk.context(), task, &desk.sessions, &related);
932    detail.ratio_line = crate::capacity::ratio_line(task);
933    #[derive(Serialize)]
934    struct Shown<'a> {
935        #[serde(flatten)]
936        detail: taskview::DetailView,
937        lists: Vec<ListChoice>,
938        list: &'a str,
939        /// What its list does not keep: those fields greyed (Google Tasks).
940        limited: Vec<&'static str>,
941        /// Its date asked within three weeks: the time budget until then.
942        budget: String,
943        /// What was felt after it, or tasks of its kind, before: faint in its form.
944        proposed: sioul_core::capacity::Proposal,
945    }
946    let config = load_config();
947    let limited = tasks::lists()
948        .into_iter()
949        .find(|c| format!("{}/{}", c.account, c.id) == task.list_id)
950        .map(|c| sioul_core::capabilities::task_fields_lost(sioul_core::capabilities::provider_of_collection(config.account(&c.account), &c), task.parent().is_some()))
951        .unwrap_or_default();
952    let today = Zoned::now().date();
953    let soon = today.checked_add(jiff::Span::new().days(21)).unwrap_or(today);
954    let budget = match task.due_date() {
955        Some(date) if task.status.is_open() && date >= today && date <= soon && desk.plan.items.get(uid).is_some_and(|p| !p.optional) => desk.budget_line(date),
956        _ => String::new(),
957    };
958    let proposed = sioul_core::capacity::FeltIndex::of(&loaded.tasks).proposal(task);
959    crate::backend::json(&Shown { detail, lists: writable_lists(), list: &task.list_id, limited, budget, proposed })
960}
961
962/// Saves the task form; an empty UID makes a new task in `list`. Returns {"uid"} or {"error"}.
963pub(crate) fn save(qt: &QtThread, shared: &Arc<Shared>, uid: &str, edit: &str, list: &str) -> String {
964    let result = serde_json::from_str::<TaskEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
965        if uid.is_empty() {
966            return create(qt, shared, &edit, list);
967        }
968        change(qt, shared, uid, |text| tasks::apply(text, &edit, &TimeZone::system(), &Zoned::now()))?;
969        write_gaps(qt, shared, uid, &edit);
970        // Its blocks to come follow its title and, when it comes back, its turn (`blocks`).
971        crate::blocks::follow(qt, shared, uid);
972        Ok(uid.to_string())
973    });
974    answer(result)
975}
976
977fn answer(result: Result<String, String>) -> String {
978    match result {
979        Ok(uid) => serde_json::json!({ "uid": uid }).to_string(),
980        Err(error) => serde_json::json!({ "error": error }).to_string(),
981    }
982}
983
984/// What a typed line says, before it becomes a task: its chips, as JSON.
985pub(crate) fn captured(shared: &Shared, line: &str) -> String {
986    let projects: Vec<String> = loaded(shared).projects.iter().map(|c| c.id.clone()).collect();
987    crate::backend::json(&capture::capture(line, Zoned::now().date(), &projects, &load_config().task_kinds(tr())))
988}
989
990/// A task from a typed line, a step of `parent` when given.
991pub(crate) fn add(qt: &QtThread, shared: &Arc<Shared>, line: &str, parent: &str, list: &str) -> String {
992    let projects: Vec<String> = loaded(shared).projects.iter().map(|c| c.id.clone()).collect();
993    let config = load_config();
994    let mut edit = capture::capture(line, Zoned::now().date(), &projects, &config.task_kinds(tr())).edit;
995    edit.parent = parent.to_string();
996    // In quiet time, a thought noted waits for work to come back, out of sight.
997    let now = Zoned::now();
998    let mode = crate::hours::mode_at(&now);
999    let mut noted = String::new();
1000    if edit.start.is_empty()
1001        && parent.is_empty()
1002        && mode.quiet
1003        && let Some(back) = mode.back
1004    {
1005        edit.start = back.date().to_string();
1006        noted = say("task-noted-for", &[("day", day_name(back.date(), now.date()))]);
1007    }
1008    // A step goes into its bigger task's list, and its project.
1009    let list = match find(shared, parent) {
1010        Ok(bigger) => {
1011            for project in bigger.projects {
1012                if !edit.projects.contains(&project) {
1013                    edit.projects.push(project);
1014                }
1015            }
1016            bigger.list_id
1017        }
1018        Err(_) => list.to_string(),
1019    };
1020    let made = create(qt, shared, &edit, &list);
1021    if made.is_ok() && !noted.is_empty() {
1022        tell(qt, shared, noted);
1023    }
1024    answer(made)
1025}
1026
1027/// Done, started, open again or dropped; returns what it changed, in a sentence.
1028pub(crate) fn set_status(qt: &QtThread, shared: &Arc<Shared>, uid: &str, status: &str) -> String {
1029    let status = match status {
1030        "completed" => Status::Completed,
1031        "in-process" => Status::InProcess,
1032        "cancelled" => Status::Cancelled,
1033        _ => Status::NeedsAction,
1034    };
1035    let before = Desk::new(loaded(shared), shared);
1036    let result = change(qt, shared, uid, |text| tasks::set_status(text, status, &TimeZone::system(), &Zoned::now()));
1037    if let Err(e) = result {
1038        return e;
1039    }
1040    // Done or dropped (a repeating one's turn done): its blocks not begun go, one under way ends now (`blocks`).
1041    if matches!(status, Status::Completed | Status::Cancelled) {
1042        crate::blocks::closed(qt, shared, uid, Timestamp::now().as_second());
1043    }
1044    if status != Status::Completed {
1045        return String::new();
1046    }
1047    // What finishing it freed, said once (docs/tasks.md: close the loop fast).
1048    let after = Desk::new(Arc::new(read_loaded(shared)), shared);
1049    let effect = taskview::done_effect(&after.context(), &before.plan, uid);
1050    let line = if effect.is_empty() { tr().text("task-done-said", None) } else { format!("{} {effect}", tr().text("task-done-said", None)) };
1051    tell(qt, shared, line.clone());
1052    // "How was it?" offered on the status line, after the line that says it is done.
1053    let done = uid.to_string();
1054    let _ = qt.queue(move |mut sioul| sioul.as_mut().task_done(QString::from(&done)));
1055    line
1056}
1057
1058/// How a task was, felt ("How was it?"): today's rating, JSON {cognitive,
1059/// emotional, anxiety, body, gain} (null: unsaid, never the forecast copied),
1060/// written alone from the file as it is, whatever the window last read;
1061/// every value null takes today's back. Returns what went wrong, else "".
1062pub(crate) fn set_felt(qt: &QtThread, shared: &Arc<Shared>, uid: &str, felt: &str) -> String {
1063    let felt: sioul_core::demands::Demands = match serde_json::from_str(felt) {
1064        Ok(felt) => felt,
1065        Err(e) => return e.to_string(),
1066    };
1067    let now = Zoned::now();
1068    change(qt, shared, uid, |text| tasks::set_felt(text, &felt, now.date(), &now)).err().unwrap_or_default()
1069}
1070
1071/// Waits for `other`, or no longer. Without a gap, DEPENDS-ON in the task
1072/// that waits. With one (`gap_minutes`: "the answer comes within two
1073/// weeks"), FINISHTOSTART with its GAP in `other`, the one that comes first,
1074/// as `sioul tasks import` writes it (`tasks::set_gap`, RFC 9253 §4), and no
1075/// DEPENDS-ON beside it. No longer: both taken away, and a FINISHTOSTART or
1076/// NEXT that another application wrote in `other` for the pair.
1077pub(crate) fn set_waits(qt: &QtThread, shared: &Arc<Shared>, uid: &str, other: &str, wait: bool, gap_minutes: i64) -> String {
1078    let now = Zoned::now();
1079    let mine = |l: &str| lines::name(l) == "RELATED-TO" && tasks::relation_of(l).kind == "DEPENDS-ON" && tasks::relation_of(l).uid == other;
1080    let theirs = |l: &str| lines::name(l) == "RELATED-TO" && matches!(tasks::relation_of(l).kind.as_str(), "FINISHTOSTART" | "NEXT") && tasks::relation_of(l).uid == uid;
1081    let result = if !wait {
1082        change(qt, shared, uid, |text| tasks::remove_lines(text, mine, &now)).and_then(|()| change(qt, shared, other, |text| tasks::remove_lines(text, theirs, &now)))
1083    } else if gap_minutes > 0 {
1084        change(qt, shared, other, |text| tasks::set_gap(text, uid, gap_minutes, &now)).and_then(|()| change(qt, shared, uid, |text| tasks::remove_lines(text, mine, &now)))
1085    } else {
1086        change(qt, shared, uid, |text| tasks::add_lines(text, &[tasks::relation_line("DEPENDS-ON", other, 0)], &now))
1087    };
1088    result.err().unwrap_or_default()
1089}
1090
1091/// A task pinned to a time ("2026-10-06T14:30", your time zone) for
1092/// `minutes` (0: as long as its block is, else what the plan lays for it), or
1093/// left to the plan again (""): its time block, an event, made, moved or taken
1094/// away (`blocks`); its own dates stay as set (docs/tasks.md, "The plan
1095/// proposes; your dates stay yours", "Pinned to a time"); the day is laid
1096/// again at once, and "Undo" offered for ten seconds. Returns what went wrong, else "".
1097pub(crate) fn set_at(qt: &QtThread, shared: &Arc<Shared>, uid: &str, at: &str, minutes: u32) -> String {
1098    if at.is_empty() { crate::blocks::unpin(qt, shared, uid) } else { crate::blocks::pin(qt, shared, uid, at, minutes) }
1099}
1100
1101/// A task by its UID, as last read.
1102pub(crate) fn find_task(shared: &Shared, uid: &str) -> Result<Task, String> {
1103    find(shared, uid)
1104}
1105
1106/// A task renamed from its time block (its event's form): its title only, from its file as it is.
1107pub(crate) fn retitle(qt: &QtThread, shared: &Arc<Shared>, uid: &str, title: &str) -> Result<(), String> {
1108    change(qt, shared, uid, |text| {
1109        let zone = TimeZone::system();
1110        let task = tasks::task_of_text(text, &zone).ok_or_else(|| tr().text("task-gone", None))?;
1111        tasks::apply(text, &TaskEdit { title: title.to_string(), ..TaskEdit::of(&task) }, &zone, &Zoned::now())
1112    })
1113}
1114
1115/// The minutes the plan lays for a task, its margins with it (`plan::Planned::laid`); None when it plans none.
1116pub(crate) fn laid(shared: &Shared, uid: &str) -> Option<u32> {
1117    Desk::new(loaded(shared), shared).plan.items.get(uid).map(|p| p.laid).filter(|m| *m > 0)
1118}
1119
1120/// Puts a task off until tomorrow.
1121pub(crate) fn not_now(qt: &QtThread, shared: &Arc<Shared>, uid: &str) {
1122    let mut today = Today::load(&Today::default_path(), Zoned::now().date());
1123    today.aside.insert(uid.to_string());
1124    let _ = today.save(&Today::default_path());
1125    tell(qt, shared, tr().text("task-set-aside", None));
1126    show_work(qt, shared);
1127}
1128
1129/// How today is: "clear", "haze" or "fog".
1130pub(crate) fn set_weather(qt: &QtThread, shared: &Arc<Shared>, weather: &str) {
1131    let mut today = Today::load(&Today::default_path(), Zoned::now().date());
1132    today.weather = Weather::parse(weather);
1133    let _ = today.save(&Today::default_path());
1134    show_work(qt, shared);
1135}
1136
1137/// Deletes a task after ten seconds to undo; its steps stay, as tasks of their own.
1138pub(crate) fn delete(qt: &QtThread, shared: &Arc<Shared>, uid: &str) {
1139    let Ok(task) = find(shared, uid) else { return };
1140    let path = PathBuf::from(&task.key);
1141    let account = account_of(&path).unwrap_or_default();
1142    // Its time blocks to come go with it, under the same "Undo" (`blocks`).
1143    let blocks = crate::blocks::with_task(uid);
1144    if blocks.is_empty() {
1145        mail::schedule_removal(qt, shared, &account, path, tr().text("undo-task-deleted", None));
1146    } else {
1147        mail::schedule_removals(qt, shared, std::iter::once((account, path)).chain(blocks).collect(), None, tr().text("undo-task-deleted", None));
1148    }
1149    if let Ok(mut cache) = shared.loaded.lock() {
1150        *cache = None;
1151    }
1152    show_work(qt, shared);
1153}
1154
1155/// Open tasks whose title holds `query`, for "waits for" and "a step of": [{uid, title, list}].
1156pub(crate) fn search(shared: &Shared, query: &str, except: &str) -> String {
1157    let fold = |t: &str| sioul_core::text::fold(t).into_iter().collect::<String>();
1158    let words: Vec<String> = query.split_whitespace().map(fold).collect();
1159    let found: Vec<serde_json::Value> = loaded(shared)
1160        .tasks
1161        .iter()
1162        .filter(|t| t.status.is_open() && t.uid != except && words.iter().all(|w| fold(&t.title).contains(w.as_str())))
1163        .take(12)
1164        .map(|t| serde_json::json!({ "uid": t.uid, "title": t.title, "list": t.list }))
1165        .collect();
1166    serde_json::Value::Array(found).to_string()
1167}
1168
1169/// Makes a task list; the next sync creates it on the account's server ("local": kept here only).
1170pub(crate) fn new_list(qt: &QtThread, shared: &Arc<Shared>, account: &str, name: &str) -> String {
1171    match vdir::create(Kind::Calendars, account, name.trim(), None, &["VTODO"]) {
1172        Ok(list) => {
1173            pim::nudge(shared, account);
1174            show_work(qt, shared);
1175            format!("{}/{}", list.account, list.id)
1176        }
1177        Err(e) => e,
1178    }
1179}
1180
1181/// The accounts a task list can be made in: contacts-and-calendars accounts
1182/// (a Google one makes it in Google Tasks), and "local".
1183pub(crate) fn list_accounts() -> String {
1184    let mut ids: Vec<String> = load_config().accounts.iter().filter(|a| a.is_dav()).map(|a| a.id.clone()).collect();
1185    ids.push("local".into());
1186    crate::backend::json(&ids)
1187}
1188
1189// Focus.
1190
1191#[derive(Serialize)]
1192struct FocusShown {
1193    task: String,
1194    title: String,
1195    start: i64,
1196    planned: u32,
1197    paused_at: Option<i64>,
1198    paused: i64,
1199    /// "Where you stopped" last time, to start from.
1200    stopped: String,
1201}
1202
1203fn focus_json(desk: &Desk) -> String {
1204    let Some(running) = timelog::running() else { return String::new() };
1205    let title = desk.task(&running.task).map(|t| t.title.clone()).unwrap_or_default();
1206    // Where you stopped: the line left at a pause of this session first, else the last session's.
1207    let left = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()).filter(|s| s.task == running.task && s.at >= running.start).map(|s| s.text);
1208    let stopped = left.or_else(|| desk.stopped.get(&running.task).cloned()).unwrap_or_default();
1209    crate::backend::json(&FocusShown { stopped, task: running.task, title, start: running.start, planned: running.planned, paused_at: running.paused_at, paused: running.paused })
1210}
1211
1212/// Where you stopped, as JSON: {text, when, task, title}; "null" when no line is left.
1213pub(crate) fn stopped(shared: &Shared) -> String {
1214    let Some(stopped) = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()) else { return "null".into() };
1215    let title = loaded(shared).tasks.iter().find(|t| t.uid == stopped.task).map(|t| t.title.clone()).unwrap_or_default();
1216    let when = Timestamp::from_second(stopped.at).map(|t| tr().when(&t.to_zoned(TimeZone::system()))).unwrap_or_default();
1217    serde_json::json!({ "text": stopped.text, "when": when, "task": stopped.task, "title": title }).to_string()
1218}
1219
1220/// A line left on where you stopped, about the task the timer runs for, if
1221/// any; an empty line: done. Returns what went wrong, else "".
1222pub(crate) fn set_stopped(qt: &QtThread, shared: &Arc<Shared>, text: &str) -> String {
1223    let task = timelog::running().map(|r| r.task).unwrap_or_default();
1224    let problem = sioul_core::stopped::Stopped::keep(&sioul_core::stopped::Stopped::default_path(), text, &task, Timestamp::now().as_second()).err().unwrap_or_default();
1225    show_work(qt, shared);
1226    crate::backend::show(qt, shared);
1227    problem
1228}
1229
1230/// Starts a focus session on a task; one already running ends first, kept.
1231pub(crate) fn focus_start(qt: &QtThread, shared: &Arc<Shared>, uid: &str, minutes: i32) {
1232    let now = Timestamp::now().as_second();
1233    let _ = timelog::finish(now, false);
1234    // 0: open-ended, counting up.
1235    let running = Running { task: uid.to_string(), start: now, planned: u32::try_from(minutes.max(0)).unwrap_or(25), ..Running::default() };
1236    if let Err(e) = timelog::keep_running(Some(&running)) {
1237        tell(qt, shared, e);
1238    }
1239    // Started, it is in the "Started" column.
1240    if find(shared, uid).is_ok_and(|t| t.status == Status::NeedsAction) {
1241        let _ = change(qt, shared, uid, |text| tasks::set_status(text, Status::InProcess, &TimeZone::system(), &Zoned::now()));
1242    }
1243    show_work(qt, shared);
1244}
1245
1246/// Pauses the session, or goes on.
1247pub(crate) fn focus_pause(qt: &QtThread, shared: &Arc<Shared>) {
1248    // Over the session as the file holds it now, under its lock, which the sharing takes too.
1249    let _ = timelog::change_running(|running| running.toggle_pause(Timestamp::now().as_second()));
1250    show_work(qt, shared);
1251}
1252
1253/// More minutes for the session.
1254pub(crate) fn focus_extend(qt: &QtThread, shared: &Arc<Shared>, minutes: i32) {
1255    let _ = timelog::change_running(|running| running.planned = running.planned.saturating_add(u32::try_from(minutes.max(0)).unwrap_or(0)));
1256    show_work(qt, shared);
1257}
1258
1259/// Ends the session: kept with where you stopped; `done` marks the task done.
1260/// Returns what it changed, in a sentence.
1261pub(crate) fn focus_stop(qt: &QtThread, shared: &Arc<Shared>, done: bool, note: &str) -> String {
1262    let now = Timestamp::now().as_second();
1263    let Some(running) = timelog::running() else { return String::new() };
1264    // No word given: the line left at a pause of this session, if any.
1265    let left = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()).filter(|s| s.task == running.task && s.at >= running.start).map(|s| s.text).unwrap_or_default();
1266    let note = if note.trim().is_empty() { left.as_str() } else { note };
1267    let session = match timelog::finish_with_note(now, done, note.trim()) {
1268        Ok(Some(session)) => session,
1269        Ok(None) => return String::new(),
1270        Err(e) => return e,
1271    };
1272    let mut args = tr().counted(session.minutes as usize);
1273    args.set("minutes", session.minutes.to_string());
1274    let kept = tr().text("focus-stopped", Some(&args));
1275    if done {
1276        let effect = set_status(qt, shared, &running.task, "completed");
1277        return format!("{kept} {effect}");
1278    }
1279    tell(qt, shared, kept.clone());
1280    show_work(qt, shared);
1281    kept
1282}
1283
1284// Notes.
1285
1286#[derive(Serialize)]
1287struct NoteRow {
1288    path: String,
1289    title: String,
1290    /// "text", "image", "pdf", "audio".
1291    kind: notes::NoteKind,
1292    /// Its folder in the vault, "" at the root.
1293    folder: String,
1294    tags: Vec<String>,
1295    modified: i64,
1296}
1297
1298#[derive(Serialize)]
1299struct NotesShown {
1300    /// No notes folder: none is set.
1301    missing: bool,
1302    /// Every note found, by title: the window shows them as one list or as a tree.
1303    notes: Vec<NoteRow>,
1304    /// The ten notes changed last, for the top of the page.
1305    recent: Vec<NoteRow>,
1306    /// A tree of folders, as you left it; else one list.
1307    tree: bool,
1308    /// Every folder, empty ones too, for the tree.
1309    folders: Vec<String>,
1310}
1311
1312fn notes_list(loaded: &Loaded, query: &str, tree: bool) -> String {
1313    let Some(vault) = loaded.vault.as_ref() else { return crate::backend::json(&NotesShown { missing: true, notes: Vec::new(), recent: Vec::new(), tree, folders: Vec::new() }) };
1314    let row = |n: &notes::Note| NoteRow { path: n.path.clone(), title: n.title.clone(), kind: n.kind, folder: n.folder().to_string(), tags: n.tags.clone(), modified: n.modified };
1315    let mut found: Vec<&notes::Note> = if query.trim().is_empty() { vault.notes.iter().collect() } else { vault.search(query) };
1316    let mut recent: Vec<&notes::Note> = found.clone();
1317    recent.sort_by_key(|n| std::cmp::Reverse(n.modified));
1318    found.sort_by_cached_key(|n| (n.title.to_lowercase(), n.path.to_lowercase()));
1319    let folders = if query.trim().is_empty() { vault.folders.clone() } else { Vec::new() };
1320    crate::backend::json(&NotesShown { missing: false, notes: found.into_iter().map(row).collect(), recent: recent.into_iter().take(10).map(row).collect(), tree, folders })
1321}
1322
1323/// A new note in a folder of the notes ("" for the notes' own folder); returns its path.
1324pub(crate) fn create_note_in(qt: &QtThread, shared: &Arc<Shared>, folder: &str, title: &str) -> String {
1325    let Some(root) = load_config().notes_root_path() else { return String::new() };
1326    let title = if title.trim().is_empty() { tr().text("note-untitled", None) } else { title.to_string() };
1327    let folder = if folder.trim().is_empty() { notes_folder() } else { folder.to_string() };
1328    match notes::create(&root, &folder, &title, &notes::new_text(&title, &[], "")) {
1329        Ok(path) => {
1330            show_work(qt, shared);
1331            path
1332        }
1333        Err(e) => {
1334            tell(qt, shared, e);
1335            String::new()
1336        }
1337    }
1338}
1339
1340/// A new folder in `parent`; returns what went wrong, else "".
1341pub(crate) fn make_folder(qt: &QtThread, shared: &Arc<Shared>, parent: &str, name: &str) -> String {
1342    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
1343    match notes::make_folder(&root, parent, name) {
1344        Ok(_) => {
1345            links_changed(qt, shared);
1346            String::new()
1347        }
1348        Err(e) => e,
1349    }
1350}
1351
1352/// A folder renamed, its notes with it, and everything naming them following;
1353/// returns {"path"} or {"error"}.
1354pub(crate) fn rename_folder(qt: &QtThread, shared: &Arc<Shared>, path: &str, name: &str) -> String {
1355    let loaded = loaded(shared);
1356    let Some(vault) = loaded.vault.as_ref() else { return answer(Err(tr().text("error-no-store", None))) };
1357    let (new_path, moved) = match notes::with_lock(|| notes::rename_folder(vault, path, name)) {
1358        Ok(done) => done,
1359        Err(e) => return answer(Err(e)),
1360    };
1361    for (from, to) in &moved {
1362        retarget_note(qt, shared, &loaded, from, to);
1363    }
1364    links_changed(qt, shared);
1365    serde_json::json!({ "path": new_path }).to_string()
1366}
1367
1368/// An empty folder taken out; returns what went wrong, else "".
1369pub(crate) fn remove_folder(qt: &QtThread, shared: &Arc<Shared>, path: &str) -> String {
1370    let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
1371    match notes::remove_folder(&root, path) {
1372        Ok(()) => {
1373            links_changed(qt, shared);
1374            String::new()
1375        }
1376        Err(_) => tr().text("note-folder-not-empty", None),
1377    }
1378}
1379
1380/// Notes as a tree of folders, or as one list; kept between sessions.
1381/// How a page was left: a column folded, a section open.
1382pub(crate) fn view_flag(shared: &Shared, name: &str) -> bool {
1383    shared.work.lock().ok().and_then(|state| state.flags.get(name).copied()).unwrap_or(false)
1384}
1385
1386pub(crate) fn set_view_flag(shared: &Shared, name: &str, value: bool) {
1387    if let Ok(mut state) = shared.work.lock() {
1388        state.flags.insert(name.to_string(), value);
1389        state.save();
1390    }
1391}
1392
1393pub(crate) fn set_notes_tree(qt: &QtThread, shared: &Arc<Shared>, tree: bool) {
1394    let query = match shared.work.lock() {
1395        Ok(mut state) => {
1396            state.notes_tree = tree;
1397            state.save();
1398            state.notes_query.clone()
1399        }
1400        Err(_) => String::new(),
1401    };
1402    let json = notes_list(&loaded(shared), &query, tree);
1403    let _ = qt.queue(move |mut sioul| sioul.as_mut().set_notes(QString::from(&json)));
1404}
1405
1406/// Shows the notes matching `query`.
1407pub(crate) fn search_notes(qt: &QtThread, shared: &Arc<Shared>, query: &str) {
1408    if let Ok(mut state) = shared.work.lock() {
1409        state.notes_query = query.to_string();
1410    }
1411    let tree = shared.work.lock().map(|s| s.notes_tree).unwrap_or(false);
1412    let json = notes_list(&loaded(shared), query, tree);
1413    let _ = qt.queue(move |mut sioul| sioul.as_mut().set_notes(QString::from(&json)));
1414}
1415
1416/// One note: its text, its HTML, its tags and checkboxes, and what is tied to it, as JSON.
1417pub(crate) fn note(shared: &Shared, path: &str) -> String {
1418    let loaded = loaded(shared);
1419    let Some(vault) = loaded.vault.as_ref() else { return String::new() };
1420    let Some(file) = vault.file(path) else { return String::new() };
1421    // A picture, a PDF, a sound: shown or played here, tied like any note.
1422    if let Some(media) = vault.note(path).filter(|n| n.kind != notes::NoteKind::Text) {
1423        let related = taskview::related_views(tr(), &loaded.world().related(&notes::uri_of(path)));
1424        return serde_json::json!({
1425            "path": path,
1426            "title": media.title,
1427            "kind": media.kind,
1428            "file": file.display().to_string(),
1429            "url": crate::backend::file_url(&file),
1430            "text": "",
1431            "html": "",
1432            "tags": [],
1433            "checkboxes": [],
1434            "related": related,
1435        })
1436        .to_string();
1437    }
1438    let Ok(text) = std::fs::read_to_string(&file) else { return String::new() };
1439    let note = notes::read(path, &text);
1440    let (_, body_start) = notes::front_matter(&text);
1441    // The title is shown above the text: a first heading saying the same is left out.
1442    let mut body_lines: Vec<&str> = text.lines().skip(body_start).skip_while(|l| l.trim().is_empty()).collect();
1443    if body_lines.first().is_some_and(|l| l.trim_start().strip_prefix("# ").is_some_and(|h| h.trim() == note.title)) {
1444        body_lines.remove(0);
1445    }
1446    let body = vault.with_links(note.folder(), &body_lines.join("\n"));
1447    let related = taskview::related_views(tr(), &loaded.world().related(&notes::uri_of(path)));
1448    serde_json::json!({
1449        "path": path,
1450        "title": note.title,
1451        "text": text,
1452        "html": sioul_core::compose::markdown_html(&body),
1453        "tags": note.tags,
1454        "checkboxes": note.checkboxes,
1455        "related": related,
1456        "file": file.display().to_string(),
1457        "stamp": text_stamp(&text),
1458        "kind": notes::NoteKind::Text,
1459        // Pictures written the usual Markdown way, relative to the note, are found from its folder.
1460        "base": file.parent().map(|p| format!("{}/", crate::backend::file_url(p))).unwrap_or_default(),
1461    })
1462    .to_string()
1463}
1464
1465/// Saves a note's text; returns what went wrong, else "".
1466pub(crate) fn save_note(qt: &QtThread, shared: &Arc<Shared>, path: &str, text: &str, stamp: &str) -> String {
1467    let answer = |problem: String, path: &str, kept: String| serde_json::json!({ "problem": problem, "stamp": text_stamp(text), "path": path, "kept": kept }).to_string();
1468    let Some(root) = load_config().notes_root_path() else { return answer(tr().text("error-no-store", None), path, String::new()) };
1469    // Looked at and written with the notes' lock held: an agent tying
1470    // something into this note meanwhile is seen here, never written over.
1471    let written = notes::with_lock(|| {
1472        // Changed since it was opened (another device through the sharing, a sync
1473        // app, another editor, an agent): that version keeps the name, this one goes beside it.
1474        let changed = notes::normalize(path)
1475            .and_then(|relative| std::fs::read_to_string(root.join(relative)).ok())
1476            .is_some_and(|current| !stamp.is_empty() && text_stamp(&current) != stamp && current != text);
1477        let target = if changed { conflict_path(&root, path) } else { path.to_string() };
1478        notes::write(&root, &target, text).map(|_| (target, changed))
1479    });
1480    match written {
1481        Ok((beside, true)) => {
1482            show_work(qt, shared);
1483            answer(String::new(), &beside, say("note-changed-elsewhere", &[("path", beside.clone())]))
1484        }
1485        Ok(_) => {
1486            show_work(qt, shared);
1487            answer(String::new(), path, String::new())
1488        }
1489        Err(e) => answer(e, path, String::new()),
1490    }
1491}
1492
1493/// A fingerprint of a note's text as it was read, given back when it is saved.
1494pub(crate) fn text_stamp(text: &str) -> String {
1495    use std::hash::{Hash, Hasher};
1496    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1497    text.hash(&mut hasher);
1498    format!("{:016x}", hasher.finish())
1499}
1500
1501/// A free name beside a note for a version of it that lost the race:
1502/// "Plan (conflict 2026-10-05 21.50).md", named as the sharing names its
1503/// copies, the same whatever the language (`share::conflict_name`).
1504fn conflict_path(root: &Path, path: &str) -> String {
1505    let (folder, name) = path.rsplit_once('/').map_or(("", path), |(folder, name)| (folder, name));
1506    let stem = notes::text_stem(name);
1507    // Its own extension kept: a Nextcloud Notes ".txt" stays one.
1508    let extension = &name[stem.len()..];
1509    let named = sioul_sync::share::conflict_name(stem, Zoned::now().timestamp().as_millisecond());
1510    let join = |file: String| if folder.is_empty() { file } else { format!("{folder}/{file}") };
1511    let mut candidate = join(format!("{named}{extension}"));
1512    let mut n = 2;
1513    while root.join(&candidate).exists() {
1514        candidate = join(format!("{named} {n}{extension}"));
1515        n += 1;
1516    }
1517    candidate
1518}
1519
1520/// The folder new notes go into, in the notes folder: yours, else "notes".
1521fn notes_folder() -> String {
1522    load_config().notes_folder.filter(|f| !f.trim().is_empty()).unwrap_or_else(|| NOTES_FOLDER.to_string())
1523}
1524
1525/// Where a new audio memo is recorded: `<notes>/memos/2026-10-03 18.40.ogg`, as
1526/// a file URL, its folder made; "" without a notes folder.
1527pub(crate) fn memo_url() -> String {
1528    let Some(root) = load_config().notes_root_path() else { return String::new() };
1529    let folder = root.join(notes_folder()).join("memos");
1530    if std::fs::create_dir_all(&folder).is_err() {
1531        return String::new();
1532    }
1533    let stamp = Zoned::now().strftime("%Y-%m-%d %H.%M").to_string();
1534    let mut file = folder.join(format!("{stamp}.ogg"));
1535    let mut n = 2;
1536    while file.exists() {
1537        file = folder.join(format!("{stamp} {n}.ogg"));
1538        n += 1;
1539    }
1540    crate::backend::file_url(&file)
1541}
1542
1543/// Your recordings to rest by: the sounds in a `sounds` folder of the notes
1544/// (its names in the languages in use: `words.tasks.sounds_folders`), as
1545/// JSON [{"title", "url"}].
1546pub(crate) fn calm_sounds(shared: &Shared) -> String {
1547    let loaded = loaded(shared);
1548    let Some(vault) = loaded.vault.as_ref() else { return "[]".into() };
1549    let names = sioul_core::words::current().tasks.sounds_folders.clone();
1550    let rows: Vec<serde_json::Value> = vault
1551        .notes
1552        .iter()
1553        .filter(|n| n.kind == notes::NoteKind::Audio && n.folder().split('/').any(|f| sioul_core::words::is_named(f, &names)))
1554        .map(|n| serde_json::json!({ "title": n.title, "url": crate::backend::file_url(&vault.root.join(&n.path)) }))
1555        .collect();
1556    crate::backend::json(&rows)
1557}
1558
1559/// The vault path of a file URL in the notes folder: what opens a memo once recorded.
1560pub(crate) fn note_path_of(url: &str) -> String {
1561    let Some(root) = load_config().notes_root_path() else { return String::new() };
1562    let file = crate::backend::local_path(url);
1563    file.strip_prefix(&root).map(|p| p.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/")).unwrap_or_default()
1564}
1565
1566/// A new note in the notes folder, its front matter linking `links`; returns its path, or "".
1567fn new_note(title: &str, front: &[(&str, Vec<String>)], body: &str) -> Result<String, String> {
1568    let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
1569    notes::create(&root, &notes_folder(), title, &notes::new_text(title, front, body))
1570}
1571
1572pub(crate) fn create_note(qt: &QtThread, shared: &Arc<Shared>, title: &str, links_json: &str) -> String {
1573    let wanted: Vec<String> = serde_json::from_str(links_json).unwrap_or_default();
1574    let title = if title.trim().is_empty() { tr().text("note-untitled", None) } else { title.to_string() };
1575    match new_note(&title, &[("links", wanted)], "") {
1576        Ok(path) => {
1577            show_work(qt, shared);
1578            path
1579        }
1580        Err(e) => {
1581            tell(qt, shared, e);
1582            String::new()
1583        }
1584    }
1585}
1586
1587// Links.
1588
1589/// What a thing is tied to, both ways, as JSON.
1590pub(crate) fn related(shared: &Shared, uri: &str) -> String {
1591    let loaded = loaded(shared);
1592    crate::backend::json(&taskview::related_views(tr(), &loaded.world().related(uri)))
1593}
1594
1595/// Something changed on disk (a project, time noted, an invoice): read again.
1596pub(crate) fn refresh(qt: &QtThread, shared: &Arc<Shared>) {
1597    links_changed(qt, shared);
1598}
1599
1600/// What the links read is read again at the next question, and the open
1601/// pages are told to ask: a tie made or undone shows at once.
1602fn links_changed(qt: &QtThread, shared: &Arc<Shared>) {
1603    if let Ok(mut cache) = shared.loaded.lock() {
1604        *cache = None;
1605    }
1606    show_work(qt, shared);
1607    let _ = qt.queue(|mut sioul| sioul.as_mut().links_changed());
1608}
1609
1610/// Writes what makes or undoes a tie, and has the account of a task or an event send it.
1611fn rewrite(shared: &Arc<Shared>, change: &links::Rewrite) -> Result<(), String> {
1612    match change.kind {
1613        LinkKind::Note => {
1614            let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
1615            let path = change.path.strip_prefix(&root).map_err(|_| say("note-outside-notes", &[("path", change.path.display().to_string())]))?;
1616            notes::write(&root, &path.to_string_lossy().replace('\\', "/"), &change.text).map(|_| ())
1617        }
1618        _ => {
1619            vdir::write_item(&change.path, &change.text)?;
1620            if let Some(account) = account_of(&change.path) {
1621                pim::nudge(shared, &account);
1622            }
1623            Ok(())
1624        }
1625    }
1626}
1627
1628/// Ties two things, in the one that can hold the link: a task (its list syncs
1629/// it to your other devices), an event whose calendar can be written, a note's
1630/// front matter; else the local file, as a message is never changed. Returns
1631/// what the line says.
1632pub(crate) fn link(qt: &QtThread, shared: &Arc<Shared>, from: &str, to: &str) -> String {
1633    let loaded = loaded(shared);
1634    let world = loaded.world();
1635    let (from, to) = (world.canonical(from), world.canonical(to));
1636    if from.is_empty() || to.is_empty() || from == to {
1637        return String::new();
1638    }
1639    // Worked out and written with the notes' lock held: the note (or task, or
1640    // event) is read again then, so a change an agent made meanwhile stays.
1641    let result = notes::with_lock(|| match loaded.tie(&from, &to, &Zoned::now()) {
1642        Some(change) => rewrite(shared, &change),
1643        None => LocalLinks::add_all(&LocalLinks::default_path(), &[links::Edge { from: from.clone(), to: to.clone(), how: "link".into() }]).map(|_| ()),
1644    });
1645    links_changed(qt, shared);
1646    match result {
1647        Ok(()) => say("link-made", &[("title", world.describe(&to).title)]),
1648        Err(e) => e,
1649    }
1650}
1651
1652/// Undoes a tie wherever it is written: a task, an event, a note's front
1653/// matter, the local file. A step, a wait, a guest or a link in a note's text
1654/// are changed where they are: that is said.
1655pub(crate) fn unlink(qt: &QtThread, shared: &Arc<Shared>, a: &str, b: &str) -> String {
1656    let loaded = loaded(shared);
1657    let world = loaded.world();
1658    let (a, b) = (world.canonical(a), world.canonical(b));
1659    let mut undone = false;
1660    let mut problem = None;
1661    // Read again and written with the notes' lock held, as `link` does.
1662    notes::with_lock(|| {
1663        for change in loaded.untie(&a, &b, &Zoned::now()) {
1664            match rewrite(shared, &change) {
1665                Ok(()) => undone = true,
1666                Err(e) => problem = Some(e),
1667            }
1668        }
1669    });
1670    match LocalLinks::change(&LocalLinks::default_path(), |local| local.untie(&world, &a, &b)) {
1671        Ok(true) => undone = true,
1672        Ok(false) => {}
1673        Err(e) => problem = Some(e),
1674    }
1675    links_changed(qt, shared);
1676    if let Some(problem) = problem {
1677        return problem;
1678    }
1679    if undone {
1680        return tr().text("link-undone", None);
1681    }
1682    // What is left is written elsewhere: said, not changed behind your back.
1683    let how = world.related(&a).into_iter().find(|r| r.uri == b).map(|r| r.how).unwrap_or_default();
1684    tr().text(
1685        match how.as_str() {
1686            "waits-for" | "unblocks" | "part-of" | "step" => "link-is-plan",
1687            "mentions" | "mentioned-by" => "link-in-text",
1688            "contact" | "involves" => "link-is-guest",
1689            _ => "link-not-found",
1690        },
1691        None,
1692    )
1693}
1694
1695#[derive(Serialize)]
1696struct Found {
1697    uri: String,
1698    kind: LinkKind,
1699    title: String,
1700    detail: String,
1701    when: String,
1702    key: String,
1703    /// Already tied to the thing open.
1704    linked: bool,
1705}
1706
1707/// Things to tie `from` to: matching `query`, of `kind` ("" for all), as JSON.
1708pub(crate) fn search_things(shared: &Shared, query: &str, kind: &str, from: &str) -> String {
1709    let loaded = loaded(shared);
1710    let world = loaded.world();
1711    let kind = match kind {
1712        "task" => Some(LinkKind::Task),
1713        "event" => Some(LinkKind::Event),
1714        "mail" => Some(LinkKind::Mail),
1715        "note" => Some(LinkKind::Note),
1716        "contact" => Some(LinkKind::Contact),
1717        "budget" => Some(LinkKind::Budget),
1718        "project" | "case" => Some(LinkKind::Project),
1719        "site" => Some(LinkKind::Site),
1720        _ => None,
1721    };
1722    let linked: BTreeSet<String> = world.related(from).into_iter().map(|r| r.uri).collect();
1723    let found = world.search(query, kind, from, 40);
1724    let rows: Vec<Found> = taskview::related_views(tr(), &found)
1725        .into_iter()
1726        .map(|r| Found { linked: linked.contains(&r.uri), uri: r.uri, kind: r.kind, title: r.title, detail: r.detail, when: r.when, key: r.key })
1727        .collect();
1728    crate::backend::json(&rows)
1729}
1730
1731/// The address of a thing a page knows by its file or id: a message's file
1732/// ("mail"), a note's path, a task's, an event's or a contact's UID.
1733pub(crate) fn uri_of(kind: &str, id: &str) -> String {
1734    match kind {
1735        "mail" => message_card(id).and_then(|(_, card)| card.message_id).map(|m| links::mail_uri(&m)).unwrap_or_default(),
1736        "note" => notes::uri_of(id),
1737        "task" => links::task_uri(id),
1738        "event" => links::event_uri(id),
1739        "contact" => links::contact_uri(id),
1740        "project" | "case" => links::project_uri(id),
1741        "site" => links::site_uri(id),
1742        _ => id.to_string(),
1743    }
1744}
1745
1746/// Something new tied to `from`: "task" or "note" (made here), "mail" (a
1747/// draft). `key` and `start` say which message or which occurrence, when it
1748/// is one. Returns {"uid"}, {"path"}, {"draft"} or {"error"}.
1749pub(crate) fn make_linked(qt: &QtThread, shared: &Arc<Shared>, kind: &str, from: &str, key: &str, start: f64) -> String {
1750    let loaded = loaded(shared);
1751    let world = loaded.world();
1752    let from = world.canonical(from);
1753    let source = world.describe(&from);
1754    let made = |field: &str, value: String| {
1755        if value.is_empty() { serde_json::json!({ "error": tr().text("link-not-made", None) }).to_string() } else { serde_json::json!({ field: value }).to_string() }
1756    };
1757    match (kind, links::kind_of(&from)) {
1758        ("task", _) => answer(linked_edit(shared, &from, key, start).and_then(|edit| create(qt, shared, &edit, ""))),
1759        ("note", LinkKind::Mail) => made("path", note_from_mail(qt, shared, key)),
1760        ("note", LinkKind::Event) => made("path", note_from_event(qt, shared, key, start)),
1761        ("note", _) => made("path", create_note(qt, shared, &source.title, &serde_json::json!([from]).to_string())),
1762        ("mail", LinkKind::Task) => made("draft", draft_for_task(qt, shared, &links::id_of(&from))),
1763        ("mail", LinkKind::Note) => made("draft", mail_note(qt, shared, &notes::path_of(&from).unwrap_or_default())),
1764        ("mail", _) => made("draft", draft_about(qt, shared, &loaded, &from)),
1765        _ => made("", String::new()),
1766    }
1767}
1768
1769/// What a task made from `from` starts with: a message's subject, the message
1770/// linked, its sender and project; for an event, "Prepare: …", its day as the
1771/// date asked, the event linked, its projects; else the thing's title and a tie
1772/// to it (a contact involved, a project, a note describing it, anything related).
1773fn linked_edit(shared: &Shared, from: &str, key: &str, start: f64) -> Result<TaskEdit, String> {
1774    let loaded = loaded(shared);
1775    let world = loaded.world();
1776    let from = world.canonical(from);
1777    match links::kind_of(&from) {
1778        LinkKind::Mail => mail_task_edit(shared, key),
1779        LinkKind::Event => event_task_edit(shared, key, start),
1780        other => {
1781            let source = world.describe(&from);
1782            let mut edit = TaskEdit { title: source.title.clone(), ..TaskEdit::default() };
1783            match other {
1784                LinkKind::Contact => edit.contacts.push(ContactRef { name: source.title.clone(), uri: from.clone() }),
1785                LinkKind::Project => edit.projects.push(links::id_of(&from)),
1786                LinkKind::Note => edit.links.push(Link { uri: from.clone(), label: source.title.clone(), rel: "describedby".into() }),
1787                _ => edit.links.push(Link { uri: from.clone(), label: source.title.clone(), rel: "related".into() }),
1788            }
1789            Ok(edit)
1790        }
1791    }
1792}
1793
1794/// A draft about anything: its title as subject, the people it involves as
1795/// recipients (an event's guests, a contact, the contacts tied to it), the
1796/// thing linked. Returns its id.
1797fn draft_about(qt: &QtThread, shared: &Arc<Shared>, loaded: &Loaded, from: &str) -> String {
1798    let world = loaded.world();
1799    let config = load_config();
1800    let own: BTreeSet<String> = config.accounts.iter().filter_map(|a| a.address.clone()).map(|a| a.to_lowercase()).collect();
1801    let address_of = |uid: &str| {
1802        let card = loaded.contacts.iter().find(|c| c.uid == uid)?;
1803        card.emails.first().map(|e| if card.name.is_empty() { e.value.clone() } else { format!("{} <{}>", card.name, e.value) })
1804    };
1805    let mut to: Vec<String> = Vec::new();
1806    match links::kind_of(from) {
1807        LinkKind::Contact => to.extend(address_of(&links::id_of(from))),
1808        LinkKind::Event => {
1809            if let Some(event) = loaded.events.iter().find(|e| e.uid == links::id_of(from)) {
1810                to.extend(event.people.iter().filter(|p| !own.contains(&p.to_lowercase())).cloned());
1811            }
1812        }
1813        _ => {}
1814    }
1815    for tied in world.related(from).into_iter().filter(|r| r.kind == LinkKind::Contact) {
1816        to.extend(address_of(&links::id_of(&tied.uri)));
1817    }
1818    let mut seen = BTreeSet::new();
1819    to.retain(|t| seen.insert(t.to_lowercase()));
1820    let account = config.accounts.iter().find(|a| a.syncs()).map(|a| a.id.clone()).unwrap_or_default();
1821    let mut draft = Draft::new(&account);
1822    draft.subject = if links::kind_of(from) == LinkKind::Contact { String::new() } else { world.describe(from).title };
1823    draft.to = to;
1824    draft.links.push(from.to_string());
1825    if draft.save().is_err() {
1826        return String::new();
1827    }
1828    links_changed(qt, shared);
1829    draft.id
1830}
1831
1832// Things made from other things.
1833
1834/// The message's card, and its file.
1835fn message_card(key: &str) -> Option<(PathBuf, Card)> {
1836    let path = maildir::locate(Path::new(key))?;
1837    let card = Card::from_bytes(&std::fs::read(&path).ok()?)?;
1838    Some((path, card))
1839}
1840
1841/// The sender of a message as a task's CONTACT: their card when they have one.
1842fn sender_contact(loaded: &Loaded, card: &Card) -> Option<ContactRef> {
1843    let address = card.from_address.clone()?;
1844    match contacts::by_address(&loaded.contacts, &address) {
1845        Some(contact) => Some(ContactRef { name: contact.name.clone(), uri: links::contact_uri(&contact.uid) }),
1846        None => Some(ContactRef { name: card.from_name.clone().map_or(address.clone(), |n| format!("{n} <{address}>")), uri: String::new() }),
1847    }
1848}
1849
1850/// A task from a message: its subject, the message linked, its sender, its project.
1851pub(crate) fn task_from_mail(qt: &QtThread, shared: &Arc<Shared>, key: &str) -> String {
1852    answer(mail_task_edit(shared, key).and_then(|edit| create(qt, shared, &edit, "")))
1853}
1854
1855/// A message's task, before it is made.
1856fn mail_task_edit(shared: &Shared, key: &str) -> Result<TaskEdit, String> {
1857    let Some((path, card)) = message_card(key) else { return Err(tr().text("mail-message-gone", None)) };
1858    let loaded = loaded(shared);
1859    let mut edit = TaskEdit { title: card.subject.trim().to_string(), ..TaskEdit::default() };
1860    if let Some(id) = &card.message_id {
1861        edit.links.push(Link { uri: links::mail_uri(id), label: String::new(), rel: "via".into() });
1862    }
1863    edit.contacts.extend(sender_contact(&loaded, &card));
1864    let config = load_config();
1865    if let Some(store) = config.notes_root_path().and_then(|r| ProjectStore::load(&r).ok()) {
1866        // Its project as the Porch would file it: none for mail it sets
1867        // aside, none by its sender's address when nothing authenticates it.
1868        let stored = Card { path: Some(path), ..card };
1869        let gate = sioul_core::porch::Gate::load(&config, Zoned::now().timestamp().as_second());
1870        if let Some(seen) = gate.admission(&stored).and_then(|a| a.for_routes(&stored)) {
1871            edit.projects.extend(store.route(&seen).first().map(|r| r.project.id.clone()));
1872        }
1873    }
1874    Ok(edit)
1875}
1876
1877/// A note from a message: its subject as title, the message linked, its text quoted.
1878pub(crate) fn note_from_mail(qt: &QtThread, shared: &Arc<Shared>, key: &str) -> String {
1879    let Some((_, card)) = message_card(key) else { return String::new() };
1880    let mail = card.message_id.as_deref().map(links::mail_uri).into_iter().collect();
1881    let quoted: String = card.excerpt.lines().take(40).map(|l| format!("> {l}\n")).collect();
1882    let body = format!("{}\n\n{quoted}", say("note-from-mail", &[("sender", card.sender().to_string())]));
1883    match new_note(&card.subject, &[("mail", mail)], &body) {
1884        Ok(path) => {
1885            show_work(qt, shared);
1886            path
1887        }
1888        Err(e) => {
1889            tell(qt, shared, e);
1890            String::new()
1891        }
1892    }
1893}
1894
1895/// An event's file and its ties.
1896fn event_ref(shared: &Shared, key: &str) -> Option<agenda::EventRef> {
1897    loaded(shared).events.iter().find(|e| e.key == key).cloned()
1898}
1899
1900/// A note for an event: dated, the guests listed, linked both ways (the
1901/// event's LINK when its calendar can be written, the note's front matter always).
1902pub(crate) fn note_from_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: f64) -> String {
1903    let Some(event) = event_ref(shared, key) else { return String::new() };
1904    let at = Timestamp::from_second(start as i64).map(|t| t.to_zoned(TimeZone::system())).unwrap_or_else(|_| Zoned::now());
1905    let title = format!("{} {}", at.date(), event.summary);
1906    let people: String = event.people.iter().map(|p| format!("- {p}\n")).collect();
1907    let body = if people.is_empty() { String::new() } else { format!("{}\n\n{people}", tr().text("note-guests", None)) };
1908    let path = match new_note(&title, &[("event", vec![links::event_uri(&event.uid)])], &body) {
1909        Ok(path) => path,
1910        Err(e) => {
1911            tell(qt, shared, e);
1912            return String::new();
1913        }
1914    };
1915    let link = tasks::link_line(&Link { uri: notes::uri_of(&path), label: String::new(), rel: "describedby".into() });
1916    if let Ok(text) = std::fs::read_to_string(&event.key)
1917        && let Some(with_link) = agenda::add_lines(&text, &[link])
1918    {
1919        let _ = vdir::write_item(Path::new(&event.key), &with_link);
1920        if let Some(account) = account_of(Path::new(&event.key)) {
1921            pim::nudge(shared, &account);
1922        }
1923    }
1924    show_work(qt, shared);
1925    path
1926}
1927
1928/// A task to prepare an event: its date the event's day, the event linked.
1929pub(crate) fn task_from_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: f64) -> String {
1930    answer(event_task_edit(shared, key, start).and_then(|edit| create(qt, shared, &edit, "")))
1931}
1932
1933/// The task to prepare an event (the occurrence at `start`), before it is made.
1934fn event_task_edit(shared: &Shared, key: &str, start: f64) -> Result<TaskEdit, String> {
1935    let Some(event) = event_ref(shared, key) else { return Err(tr().text("agenda-gone", None)) };
1936    let day = Timestamp::from_second(start as i64).map(|t| t.to_zoned(TimeZone::system()).date().to_string()).unwrap_or_default();
1937    Ok(TaskEdit {
1938        title: say("task-prepare", &[("title", event.summary.clone())]),
1939        due: day,
1940        links: vec![Link { uri: format!("uid:{}", event.uid), label: event.summary.clone(), rel: "related".into() }],
1941        projects: event.projects.clone(),
1942        ..TaskEdit::default()
1943    })
1944}
1945
1946/// A task from a checkbox line of a note: its text, the note linked.
1947pub(crate) fn task_from_line(qt: &QtThread, shared: &Arc<Shared>, path: &str, line: i32) -> String {
1948    let loaded = loaded(shared);
1949    let Some(note) = loaded.vault.as_ref().and_then(|v| v.note(path)) else { return answer(Err(tr().text("note-gone", None))) };
1950    let Some(checkbox) = note.checkboxes.iter().find(|c| c.line == line as usize) else { return answer(Err(tr().text("note-gone", None))) };
1951    let edit = TaskEdit { title: checkbox.text.clone(), links: vec![Link { uri: notes::uri_of(path), label: note.title.clone(), rel: "describedby".into() }], ..TaskEdit::default() };
1952    answer(create(qt, shared, &edit, ""))
1953}
1954
1955/// A draft to the people a task involves, linked both ways; returns its id.
1956pub(crate) fn draft_for_task(qt: &QtThread, shared: &Arc<Shared>, uid: &str) -> String {
1957    let Ok(task) = find(shared, uid) else { return String::new() };
1958    let loaded = loaded(shared);
1959    let config = load_config();
1960    let account = config.accounts.iter().find(|a| a.syncs()).map(|a| a.id.clone()).unwrap_or_default();
1961    let mut draft = Draft::new(&account);
1962    draft.subject = task.title.clone();
1963    draft.to = task
1964        .contacts
1965        .iter()
1966        .filter_map(|c| {
1967            let card = loaded.contacts.iter().find(|x| links::contact_uri(&x.uid) == c.uri)?;
1968            card.emails.first().map(|e| if card.name.is_empty() { e.value.clone() } else { format!("{} <{}>", card.name, e.value) })
1969        })
1970        .collect();
1971    draft.links.push(links::task_uri(uid));
1972    if draft.save().is_err() {
1973        return String::new();
1974    }
1975    let link = tasks::link_line(&Link { uri: links::draft_uri(&draft.id), label: String::new(), rel: "related".into() });
1976    let _ = change(qt, shared, uid, |text| tasks::add_lines(text, &[link], &Zoned::now()));
1977    draft.id
1978}
1979
1980/// A draft sending a note to the guests of the event it is about; returns its id.
1981pub(crate) fn mail_note(qt: &QtThread, shared: &Arc<Shared>, path: &str) -> String {
1982    let loaded = loaded(shared);
1983    let Some(note) = loaded.vault.as_ref().and_then(|v| v.note(path)).cloned() else { return String::new() };
1984    let Some(file) = loaded.vault.as_ref().and_then(|v| v.file(path)) else { return String::new() };
1985    let text = std::fs::read_to_string(&file).unwrap_or_default();
1986    let (_, body_start) = notes::front_matter(&text);
1987    // Without the front matter and the title: the text itself.
1988    let body: Vec<&str> = text.lines().skip(body_start).collect();
1989    let body = body.iter().skip_while(|l| l.trim().is_empty() || l.trim_start().starts_with("# ")).copied().collect::<Vec<_>>().join("\n");
1990    let config = load_config();
1991    let own: BTreeSet<String> = config.accounts.iter().filter_map(|a| a.address.clone()).map(|a| a.to_lowercase()).collect();
1992    let guests: Vec<String> = note
1993        .links
1994        .iter()
1995        .filter(|l| links::kind_of(&l.target) == LinkKind::Event)
1996        .filter_map(|l| loaded.events.iter().find(|e| links::event_uri(&e.uid) == l.target))
1997        .flat_map(|e| e.people.clone())
1998        .filter(|p| !own.contains(&p.to_lowercase()))
1999        .collect::<BTreeSet<_>>()
2000        .into_iter()
2001        .collect();
2002    let account = config.accounts.iter().find(|a| a.syncs()).map(|a| a.id.clone()).unwrap_or_default();
2003    let mut draft = Draft::new(&account);
2004    draft.subject = note.title.clone();
2005    draft.to = guests;
2006    draft.body = body;
2007    draft.links.push(notes::uri_of(path));
2008    if draft.save().is_err() {
2009        return String::new();
2010    }
2011    show_work(qt, shared);
2012    draft.id
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017    use super::*;
2018
2019    #[test]
2020    fn a_note_saved_over_a_newer_one_goes_beside_it() {
2021        let root = std::env::temp_dir().join(format!("sioul-note-beside-{}", std::process::id()));
2022        std::fs::create_dir_all(root.join("admin")).unwrap();
2023        std::fs::write(root.join("admin/Plan.md"), "theirs").unwrap();
2024        // The stamp tells a text from another, and the same text from itself.
2025        assert_eq!(text_stamp("theirs"), text_stamp("theirs"));
2026        assert_ne!(text_stamp("theirs"), text_stamp("ours"));
2027        // Beside it, in its folder, never over another copy.
2028        let first = conflict_path(&root, "admin/Plan.md");
2029        assert!(first.starts_with("admin/Plan (") && first.ends_with(".md"), "{first}");
2030        std::fs::write(root.join(&first), "ours").unwrap();
2031        let second = conflict_path(&root, "admin/Plan.md");
2032        assert!(second != first && second.ends_with(" 2.md"), "{second}");
2033        assert!(!conflict_path(&root, "Top.md").contains('/'));
2034        assert!(conflict_path(&root, "Shopping.txt").ends_with(".txt"), "a Nextcloud Notes note keeps its extension");
2035        let _ = std::fs::remove_dir_all(&root);
2036    }
2037}