Skip to main content

sioul_app/
blocks.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Time blocks behind the window (docs/tasks.md, "Pinned to a time"): a task
5//! pinned to a time is an event in a calendar, made, moved and taken away
6//! here, the task tied to it; the plan reads them (`sioul_core::blocks`).
7//!
8//! - "Do at…" and a drag in the Tasks day view move the task's block, or make
9//!   one in the calendar for blocks (made at first use); "Undo" puts back what
10//!   was there.
11//! - "Let the plan place it" takes its blocks to come away after ten seconds
12//!   ("Undo" keeps them); one under way ends then.
13//! - Done or dropped here: its blocks not begun go, one under way ends then,
14//!   those over stay, the record of when the work was done. Deleted: its
15//!   blocks to come go with it, under the same "Undo".
16//! - Each time the plan is made, what changed elsewhere is set right
17//!   (`upkeep`), and a time given by a drag before blocks becomes a block, once.
18
19use crate::backend::{QtThread, Shared, load_config, say, tr};
20use crate::{mail, pim, work};
21use jiff::tz::TimeZone;
22use jiff::{Timestamp, Zoned};
23use sioul_core::agenda::Occurrence;
24use sioul_core::blocks::{self, Blocks, Closing, Fix, Place};
25use sioul_core::tasks::Task;
26use sioul_core::vdir::{self, Collection, Kind};
27use std::collections::BTreeSet;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex};
30
31/// The blocks found when the plan was last made: what a pin moves, what
32/// "Let the plan place it" takes away, what `upkeep` sets right.
33static FOUND: Mutex<Vec<Occurrence>> = Mutex::new(Vec::new());
34
35/// The calendars made or chosen for blocks, as last read: (folder, what its
36/// files were, the day read, its blocks). Read again when a file in it
37/// changed, came or went, and each day.
38type Read = Vec<(PathBuf, u64, jiff::civil::Date, Vec<Occurrence>)>;
39static READ: Mutex<Read> = Mutex::new(Vec::new());
40
41/// What a calendar's files are, cheaply: their names, sizes and times.
42fn fingerprint(calendar: &Collection) -> u64 {
43    use std::hash::{Hash, Hasher};
44    let mut hasher = std::collections::hash_map::DefaultHasher::new();
45    for path in calendar.items() {
46        path.hash(&mut hasher);
47        if let Ok(meta) = std::fs::metadata(&path) {
48            meta.len().hash(&mut hasher);
49            meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos()).hash(&mut hasher);
50        }
51    }
52    hasher.finish()
53}
54
55/// The blocks of the calendars made or chosen for them, from two days ago to
56/// as far as the plan goes: beyond the four weeks of events the plan reads.
57fn read_own(chosen: &str, now: &Zoned) -> Vec<Occurrence> {
58    let stamp = now.timestamp().as_second();
59    let mut kept = READ.lock().unwrap_or_else(|e| e.into_inner());
60    let calendars = blocks::calendars(chosen);
61    kept.retain(|(dir, ..)| calendars.iter().any(|c| &c.dir == dir));
62    let mut out = Vec::new();
63    for calendar in calendars {
64        let print = fingerprint(&calendar);
65        if let Some((.., found)) = kept.iter().find(|(dir, p, day, _)| *dir == calendar.dir && *p == print && *day == now.date()) {
66            out.extend(found.iter().cloned());
67            continue;
68        }
69        let found = blocks::read(std::slice::from_ref(&calendar), stamp - 2 * 86_400, stamp + blocks::AHEAD_DAYS * 86_400, now.time_zone());
70        kept.retain(|(dir, ..)| *dir != calendar.dir);
71        kept.push((calendar.dir.clone(), print, now.date(), found.clone()));
72        out.extend(found);
73    }
74    out
75}
76
77/// The tasks pinned to a time as of `now`: their blocks among `events` (the
78/// plan's weeks, every calendar) and in the calendars made or chosen for
79/// blocks, further ahead; those whose deletion waits ("Undo") aside. The
80/// blocks found are kept for what moves them.
81pub(crate) fn pins(shared: &Shared, events: &[Occurrence], tasks: &[Task], now: &Zoned) -> Blocks {
82    let chosen = load_config().tasks.blocks.unwrap_or_default();
83    let (removed, _) = mail::hidden_pim(shared);
84    let mut found: Vec<Occurrence> = events.iter().filter(|e| !e.task.is_empty()).cloned().collect();
85    found.extend(read_own(&chosen, now));
86    let mut seen = BTreeSet::new();
87    found.retain(|b| !removed.contains(Path::new(&b.key)) && seen.insert((b.key.clone(), b.start)));
88    let sorted = Blocks::of(&found, tasks, now.timestamp().as_second(), now.time_zone());
89    *FOUND.lock().unwrap_or_else(|e| e.into_inner()) = found;
90    sorted
91}
92
93/// The blocks of a task found when the plan was last made, each file once.
94fn found_of(uid: &str) -> Vec<Occurrence> {
95    let mut seen = BTreeSet::new();
96    FOUND.lock().unwrap_or_else(|e| e.into_inner()).iter().filter(|b| b.task == uid && seen.insert(b.key.clone())).cloned().collect()
97}
98
99/// A file written, and its account told to send it.
100fn write(shared: &Shared, path: &Path, text: &str) -> Result<(), String> {
101    vdir::write_item(path, text)?;
102    if let Some(account) = pim::account_of(path) {
103        pim::nudge(shared, &account);
104    }
105    Ok(())
106}
107
108/// A file deleted here; its account deletes it there at the next sync.
109fn remove(shared: &Shared, path: &Path) -> Result<(), String> {
110    std::fs::remove_file(path).map_err(|e| format!("{}: {e}", path.display()))?;
111    if let Some(account) = pim::account_of(path) {
112        pim::nudge(shared, &account);
113    }
114    Ok(())
115}
116
117/// "today, 14:00–14:45", "Thursday 8 October, 10:00–10:30".
118fn when(start: i64, end: i64) -> String {
119    let zone = TimeZone::system();
120    let at = |seconds: i64| Timestamp::from_second(seconds).map(|t| t.to_zoned(zone.clone())).ok();
121    let (Some(from), Some(to)) = (at(start), at(end)) else { return String::new() };
122    let (first, last) = (from.strftime("%H:%M").to_string(), to.strftime("%H:%M").to_string());
123    let today = Zoned::now().date();
124    if from.date() == today {
125        say("task-pinned-today", &[("from", first), ("to", last)])
126    } else {
127        say("task-pinned-day", &[("day", tr().day_in(from.date(), today)), ("from", first), ("to", last)])
128    }
129}
130
131/// The calendar a task's blocks go into, made when it is the calendar for
132/// blocks and not there yet (`blocks::place_for`).
133fn calendar_for(task: &Task) -> Result<Collection, String> {
134    let config = load_config();
135    let chosen = config.tasks.blocks.clone().unwrap_or_default();
136    let account = task.list_id.split('/').next().unwrap_or("").to_string();
137    let makes = sioul_core::capabilities::makes_collections(sioul_core::capabilities::provider_of(config.account(&account)));
138    match blocks::place_for(&chosen, &account, &vdir::collections(Kind::Calendars), makes) {
139        Place::Found(calendar) => Ok(calendar),
140        Place::Make(account) => blocks::make_calendar(&account, &tr().text("blocks-calendar", None)),
141    }
142}
143
144/// A new block for `task`, `start` to `end`: written, its account told; its file and its UID.
145fn make(shared: &Shared, task: &Task, start: i64, end: i64, now: &Zoned) -> Result<(PathBuf, String), String> {
146    let calendar = calendar_for(task)?;
147    let uid = vdir::new_name();
148    let text = blocks::new_block(task, &uid, start, end, load_config().tasks.block_alarms, now)?;
149    let path = calendar.dir.join(format!("{uid}.ics"));
150    write(shared, &path, &text)?;
151    Ok((path, uid))
152}
153
154/// A file as it was before a change, and as the change wrote it (`None`: there was none, or there is none now).
155struct Written {
156    path: PathBuf,
157    before: Option<String>,
158    after: Option<String>,
159}
160
161/// What was written put back, unless something changed it since (a sync,
162/// another device): says what went wrong, else "".
163fn put_back(shared: &Shared, written: &[Written]) -> String {
164    if written.iter().any(|w| std::fs::read_to_string(&w.path).ok() != w.after) {
165        return tr().text("undo-too-late", None);
166    }
167    for w in written {
168        let done = match &w.before {
169            Some(text) => write(shared, &w.path, text),
170            None => remove(shared, &w.path),
171        };
172        if let Err(e) = done {
173            return e;
174        }
175    }
176    String::new()
177}
178
179/// A task pinned to `at` ("2026-10-06T14:30", your time zone) for `minutes`
180/// (0: as long as its block is, else what the plan lays for it): its block
181/// moved there, or made in the calendar for blocks; the task tied to it, and
182/// to it alone. Told in the status line, "Undo" offered: both put back.
183/// Returns what went wrong, else "".
184pub(crate) fn pin(qt: &QtThread, shared: &Arc<Shared>, uid: &str, at: &str, minutes: u32) -> String {
185    let zone = TimeZone::system();
186    let now = Zoned::now();
187    let task = match work::find_task(shared, uid) {
188        Ok(task) => task,
189        Err(e) => return e,
190    };
191    if task.read_only {
192        return tr().text("task-read-only", None);
193    }
194    let Some(start) = at.parse::<jiff::civil::DateTime>().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map(|z| z.timestamp().as_second()) else { return format!("{at}?") };
195    // The block it is pinned to, else none: one is made. One another application made repeat is left as it is.
196    let own: Vec<Occurrence> = found_of(uid).into_iter().filter(|b| !b.recurring).collect();
197    let current = Blocks::of(&own, std::slice::from_ref(&task), now.timestamp().as_second(), &zone).pins.remove(uid).filter(|p| Path::new(&p.key).is_file());
198    if current.as_ref().is_some_and(|p| p.read_only) {
199        return tr().text("task-pinned-read-only", None);
200    }
201    let minutes = match (minutes, &current) {
202        (0, Some(pin)) => pin.minutes(),
203        (0, None) => blocks::default_minutes(&task, work::laid(shared, uid), load_config().tasks.estimate.unwrap_or(30)),
204        (m, _) => m,
205    }
206    .max(blocks::SHORTEST);
207    let end = start + i64::from(minutes) * 60;
208    let mut written = Vec::new();
209    let block_uid = match &current {
210        Some(pin) => {
211            let path = PathBuf::from(&pin.key);
212            let Ok(before) = std::fs::read_to_string(&path) else { return tr().text("agenda-gone", None) };
213            let moved = blocks::moved(&before, start, end, &zone).map(|text| blocks::titled(&text, &task.title, &zone).unwrap_or(text));
214            let text = match moved {
215                Ok(text) => text,
216                Err(e) => return e,
217            };
218            if let Err(e) = write(shared, &path, &text) {
219                return e;
220            }
221            written.push(Written { path, before: Some(before), after: Some(text) });
222            pin.uid.clone()
223        }
224        None => match make(shared, &task, start, end, &now) {
225            Ok((path, uid)) => {
226                written.push(Written { after: std::fs::read_to_string(&path).ok(), path, before: None });
227                uid
228            }
229            Err(e) => return e,
230        },
231    };
232    // The task tied to it, and to it alone; a time given by a drag before blocks taken out.
233    let task_path = PathBuf::from(&task.key);
234    if let Ok(before) = std::fs::read_to_string(&task_path)
235        && let Ok(text) = blocks::task_pinned(&before, Some(&block_uid), &now)
236        && text != before
237    {
238        if let Err(e) = write(shared, &task_path, &text) {
239            return e;
240        }
241        written.push(Written { path: task_path, before: Some(before), after: Some(text) });
242    }
243    let line = say("drag-done", &[("what", task.title.clone()), ("time", when(start, end))]);
244    mail::offer_back(qt, shared, line, move |qt, shared| {
245        let problem = put_back(shared, &written);
246        work::show_work(qt, shared);
247        pim::show_pim(qt, shared);
248        problem
249    });
250    work::show_work(qt, shared);
251    pim::show_pim(qt, shared);
252    String::new()
253}
254
255/// "Let the plan place it": the task's blocks not begun deleted after ten
256/// seconds ("Undo" keeps them), one under way ending now; its link to its
257/// block, and a time given by a drag before blocks, taken out at once, put
258/// back by "Undo". Returns what went wrong, else "".
259pub(crate) fn unpin(qt: &QtThread, shared: &Arc<Shared>, uid: &str) -> String {
260    let zone = TimeZone::system();
261    let now = Zoned::now();
262    let task = match work::find_task(shared, uid) {
263        Ok(task) => task,
264        Err(e) => return e,
265    };
266    if task.read_only {
267        return tr().text("task-read-only", None);
268    }
269    let stamp = now.timestamp().as_second();
270    let mut removals = Vec::new();
271    let mut written = Vec::new();
272    for block in found_of(uid).into_iter().filter(|b| !b.read_only && !b.recurring) {
273        let path = PathBuf::from(&block.key);
274        match blocks::closing(block.start, block.end, stamp) {
275            Closing::Keep => {}
276            Closing::Delete => removals.push((pim::account_of(&path).unwrap_or_default(), path)),
277            Closing::EndAt(end) => {
278                let Ok(before) = std::fs::read_to_string(&path) else { continue };
279                if let Ok(text) = blocks::ended(&before, end, &zone)
280                    && write(shared, &path, &text).is_ok()
281                {
282                    written.push(Written { path, before: Some(before), after: Some(text) });
283                }
284            }
285        }
286    }
287    let task_path = PathBuf::from(&task.key);
288    if let Ok(before) = std::fs::read_to_string(&task_path)
289        && let Ok(text) = blocks::task_pinned(&before, None, &now)
290        && text != before
291    {
292        if let Err(e) = write(shared, &task_path, &text) {
293            return e;
294        }
295        written.push(Written { path: task_path, before: Some(before), after: Some(text) });
296    }
297    if removals.is_empty() && written.is_empty() {
298        return String::new();
299    }
300    let line = say("day-let-plan-done", &[("title", task.title.clone())]);
301    let back = move |qt: &QtThread, shared: &Arc<Shared>| {
302        let problem = put_back(shared, &written);
303        work::show_work(qt, shared);
304        pim::show_pim(qt, shared);
305        problem
306    };
307    mail::schedule_removals(qt, shared, removals, Some(Box::new(back)), line);
308    // Laid again at once, even when only the task changed (a time given before blocks).
309    work::show_work(qt, shared);
310    pim::show_pim(qt, shared);
311    String::new()
312}
313
314/// A task done or dropped at `at` (Unix seconds), or a turn of a repeating one
315/// done: its blocks not begun deleted, their time freed; one under way ending
316/// then; those over kept, the record of when the work was done.
317pub(crate) fn closed(qt: &QtThread, shared: &Arc<Shared>, uid: &str, at: i64) {
318    let zone = TimeZone::system();
319    let mut changed = false;
320    for block in found_of(uid).into_iter().filter(|b| !b.read_only && !b.recurring) {
321        let path = PathBuf::from(&block.key);
322        let done = match blocks::closing(block.start, block.end, at) {
323            Closing::Keep => continue,
324            Closing::Delete => remove(shared, &path),
325            Closing::EndAt(end) => std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| blocks::ended(&text, end, &zone)).and_then(|text| write(shared, &path, &text)),
326        };
327        changed |= done.is_ok();
328    }
329    // The agenda and the day without them at once.
330    if changed {
331        work::show_work(qt, shared);
332        pim::show_pim(qt, shared);
333    }
334}
335
336/// The blocks to delete with a task deleted, (account, file): those not
337/// begun. One under way, or over, stays: an event of its own once the task is gone.
338pub(crate) fn with_task(uid: &str) -> Vec<(String, PathBuf)> {
339    let stamp = Timestamp::now().as_second();
340    found_of(uid).into_iter().filter(|b| !b.read_only && !b.recurring && b.start > stamp).map(|b| (pim::account_of(Path::new(&b.key)).unwrap_or_default(), PathBuf::from(&b.key))).collect()
341}
342
343/// A task's title or its dates changed here: its blocks to come follow, the
344/// title as its own, a repeating task's turn as its dates say now (the task
345/// read from its file: the window's copy is read again later).
346pub(crate) fn follow(qt: &QtThread, shared: &Arc<Shared>, uid: &str) {
347    let zone = TimeZone::system();
348    let Ok(known) = work::find_task(shared, uid) else { return };
349    let Some(task) = std::fs::read_to_string(&known.key).ok().and_then(|text| sioul_core::tasks::task_of_text(&text, &zone)) else { return };
350    let stamp = Timestamp::now().as_second();
351    let turn = blocks::turn_of(&task);
352    for block in found_of(uid).into_iter().filter(|b| !b.read_only && !b.recurring && b.end > stamp) {
353        let path = PathBuf::from(&block.key);
354        let Ok(before) = std::fs::read_to_string(&path) else { continue };
355        let mut text = blocks::titled(&before, &task.title, &zone).unwrap_or_else(|| before.clone());
356        if !block.turn.is_empty() && !turn.is_empty() && block.turn != turn {
357            text = blocks::turned(&text, &turn).unwrap_or(text);
358        }
359        if text != before && write(shared, &path, &text).is_ok() {
360            pim::show_pim(qt, shared);
361        }
362    }
363}
364
365/// What changed elsewhere set right, and the times given by a drag before
366/// blocks made blocks, once each (`blocks::upkeep`, `blocks::given_times`):
367/// only what differs is written. Returns whether anything was.
368pub(crate) fn upkeep(shared: &Shared, tasks: &[Task], now: &Zoned) -> bool {
369    let zone = now.time_zone();
370    let mut wrote = false;
371    // A time given by a drag before blocks, today or later: a block, once; the line goes from the task.
372    for (task, start) in blocks::given_times(tasks, now.date(), zone).into_iter().filter(|(t, _)| !t.read_only) {
373        let minutes = blocks::default_minutes(task, None, load_config().tasks.estimate.unwrap_or(30));
374        let Ok(before) = std::fs::read_to_string(&task.key) else { continue };
375        let Ok((block, uid)) = make(shared, task, start, start + i64::from(minutes) * 60, now) else { continue };
376        // The task tied to its block, the line gone; failing that, no block either: never one made twice.
377        match blocks::task_pinned(&before, Some(&uid), now).and_then(|text| write(shared, Path::new(&task.key), &text)) {
378            Ok(()) => wrote = true,
379            Err(_) => {
380                let _ = remove(shared, &block);
381            }
382        }
383    }
384    let (removed, _) = mail::hidden_pim(shared);
385    let found: Vec<Occurrence> = FOUND.lock().unwrap_or_else(|e| e.into_inner()).iter().filter(|b| !b.read_only && !removed.contains(Path::new(&b.key))).cloned().collect();
386    for (key, fix) in blocks::upkeep(&found, tasks, now.timestamp().as_second()) {
387        let path = PathBuf::from(&key);
388        let Ok(text) = std::fs::read_to_string(&path) else { continue };
389        // What would be written, when it differs: nothing is written twice.
390        let changed = match &fix {
391            Fix::Delete => None,
392            Fix::EndAt(end) => blocks::ended(&text, *end, zone).ok().filter(|t| *t != text),
393            Fix::Title(title) => blocks::titled(&text, title, zone),
394        };
395        let done = match (&fix, changed) {
396            (Fix::Delete, _) => remove(shared, &path).is_ok(),
397            (_, Some(new)) => write(shared, &path, &new).is_ok(),
398            (_, None) => false,
399        };
400        wrote |= done;
401    }
402    wrote
403}