1use 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
31static FOUND: Mutex<Vec<Occurrence>> = Mutex::new(Vec::new());
34
35type Read = Vec<(PathBuf, u64, jiff::civil::Date, Vec<Occurrence>)>;
39static READ: Mutex<Read> = Mutex::new(Vec::new());
40
41fn 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
55fn 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
77pub(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
93fn 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
99fn 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
108fn 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
117fn 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
131fn 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
144fn 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
154struct Written {
156 path: PathBuf,
157 before: Option<String>,
158 after: Option<String>,
159}
160
161fn 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
179pub(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 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, ¤t) {
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 ¤t {
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 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
255pub(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 work::show_work(qt, shared);
310 pim::show_pim(qt, shared);
311 String::new()
312}
313
314pub(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 if changed {
331 work::show_work(qt, shared);
332 pim::show_pim(qt, shared);
333 }
334}
335
336pub(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
343pub(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
365pub(crate) fn upkeep(shared: &Shared, tasks: &[Task], now: &Zoned) -> bool {
369 let zone = now.time_zone();
370 let mut wrote = false;
371 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 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 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}