1use 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
38const NOTES_FOLDER: &str = "notes";
40
41#[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 #[serde(default, alias = "case")]
53 pub project: String,
54 #[serde(default)]
55 pub filter: Filter,
56 #[serde(skip)]
57 pub notes_query: String,
58 #[serde(default)]
60 pub notes_tree: bool,
61 #[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 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
87pub(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
99fn 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 fresh.events.retain(|e| !removed.contains(Path::new(&e.key)));
106 fresh
107}
108
109const EVENTS_AHEAD: i64 = 28 * 86_400;
111
112fn 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 let pins = crate::blocks::pins(shared, &read, tasks, &now);
122 let held = sioul_core::plan::event_spans(&read, 0);
124 let events = sioul_core::blocks::without_blocks(read, tasks);
125 let needs = sioul_core::health::Health::load(&sioul_core::health::Health::default_path()).needs;
127 let days = crate::health::days();
128 let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &held);
130 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 (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
144static 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
153pub(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
160struct 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 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 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 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 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 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 pending: bool,
234 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#[derive(Serialize)]
252struct TasksShown {
253 view: WorkState,
255 kinds: Vec<KindChoice>,
257 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: bool,
268 quiet: bool,
270 first_step: String,
272 first_step_task: String,
273 budget: String,
275 day: sioul_core::dayview::DayView,
277 free: Vec<serde_json::Value>,
279}
280
281fn 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 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 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 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 crate::capacity::dress(&mut day, &desk.loaded.tasks);
301 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
308static DAY_STEPS: std::sync::Mutex<Vec<(i64, i64, String)>> = std::sync::Mutex::new(Vec::new());
310
311pub(crate) fn day_steps() -> Vec<(i64, i64, String)> {
313 DAY_STEPS.lock().map(|steps| steps.clone()).unwrap_or_default()
314}
315
316pub(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
339pub(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 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
367pub(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
372fn 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 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
382pub(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 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
435pub(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 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 if crate::blocks::upkeep(&shared, &desk.loaded.tasks, &Zoned::now()) {
451 show_work(&qt, &shared);
452 pim::show_pim(&qt, &shared);
453 }
454 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 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 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 crate::timenote::follow(&qt, shared);
504 let _ = qt.queue(move |mut sioul| {
505 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(¬es_json));
511 sioul.as_mut().set_focus_session(QString::from(&focus_json));
512 });
513 });
514}
515
516#[derive(Serialize, Clone, Default)]
518pub(crate) struct FirstStep {
519 pub uid: String,
520 pub text: String,
521}
522
523#[derive(Serialize)]
525struct Deadline {
526 uid: String,
527 line: String,
528}
529
530#[derive(Serialize)]
534pub(crate) struct Closing {
535 put_away: String,
537 did: Vec<String>,
539 rest: String,
541 starts_with: String,
543 first: FirstStep,
544 deadline: Option<Deadline>,
545 leave: String,
547}
548
549pub(crate) struct Closed {
551 pub closing: Closing,
552 pub back: Zoned,
553 pub first: FirstStep,
554}
555
556fn 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
567fn 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
573pub(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 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 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 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 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
657pub(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
669pub(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
678fn 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
683fn 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
693fn 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
702pub(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 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 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 "level": "",
745 "estimate_first": null,
746 "ratio_line": "",
747 "felt_on": "",
748 })
749 .to_string()
750}
751
752fn 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
765fn 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
778pub(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
798fn 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(¤t)?;
806 if text != current {
807 write(qt, shared, Path::new(&task.key), &text)?;
808 }
809 Ok(())
810}
811
812pub(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
829pub(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(¤t, &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
870pub(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
883pub(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 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
898fn 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 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 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
925pub(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 limited: Vec<&'static str>,
941 budget: String,
943 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
962pub(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 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
984pub(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
990pub(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 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 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
1027pub(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 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 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 let done = uid.to_string();
1054 let _ = qt.queue(move |mut sioul| sioul.as_mut().task_done(QString::from(&done)));
1055 line
1056}
1057
1058pub(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
1071pub(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
1091pub(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
1101pub(crate) fn find_task(shared: &Shared, uid: &str) -> Result<Task, String> {
1103 find(shared, uid)
1104}
1105
1106pub(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
1115pub(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
1120pub(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
1129pub(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
1137pub(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 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
1155pub(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
1169pub(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
1181pub(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#[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 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 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
1212pub(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
1220pub(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
1230pub(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 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 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
1246pub(crate) fn focus_pause(qt: &QtThread, shared: &Arc<Shared>) {
1248 let _ = timelog::change_running(|running| running.toggle_pause(Timestamp::now().as_second()));
1250 show_work(qt, shared);
1251}
1252
1253pub(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
1259pub(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 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#[derive(Serialize)]
1287struct NoteRow {
1288 path: String,
1289 title: String,
1290 kind: notes::NoteKind,
1292 folder: String,
1294 tags: Vec<String>,
1295 modified: i64,
1296}
1297
1298#[derive(Serialize)]
1299struct NotesShown {
1300 missing: bool,
1302 notes: Vec<NoteRow>,
1304 recent: Vec<NoteRow>,
1306 tree: bool,
1308 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: ¬es::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<¬es::Note> = if query.trim().is_empty() { vault.notes.iter().collect() } else { vault.search(query) };
1316 let mut recent: Vec<¬es::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
1323pub(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, ¬es::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
1340pub(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
1352pub(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
1368pub(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
1380pub(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
1406pub(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
1416pub(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 if let Some(media) = vault.note(path).filter(|n| n.kind != notes::NoteKind::Text) {
1423 let related = taskview::related_views(tr(), &loaded.world().related(¬es::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 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(¬es::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 "base": file.parent().map(|p| format!("{}/", crate::backend::file_url(p))).unwrap_or_default(),
1461 })
1462 .to_string()
1463}
1464
1465pub(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 let written = notes::with_lock(|| {
1472 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(¤t) != 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
1493pub(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
1501fn 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 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
1520fn notes_folder() -> String {
1522 load_config().notes_folder.filter(|f| !f.trim().is_empty()).unwrap_or_else(|| NOTES_FOLDER.to_string())
1523}
1524
1525pub(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
1543pub(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
1559pub(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
1566fn 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, ¬es_folder(), title, ¬es::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
1587pub(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
1595pub(crate) fn refresh(qt: &QtThread, shared: &Arc<Shared>) {
1597 links_changed(qt, shared);
1598}
1599
1600fn 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
1610fn 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
1628pub(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 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
1652pub(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 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 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 linked: bool,
1705}
1706
1707pub(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
1731pub(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
1746pub(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, ¬es::path_of(&from).unwrap_or_default())),
1764 ("mail", _) => made("draft", draft_about(qt, shared, &loaded, &from)),
1765 _ => made("", String::new()),
1766 }
1767}
1768
1769fn 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
1794fn 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
1832fn 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
1841fn 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
1850pub(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
1855fn 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 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
1877pub(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
1895fn event_ref(shared: &Shared, key: &str) -> Option<agenda::EventRef> {
1897 loaded(shared).events.iter().find(|e| e.key == key).cloned()
1898}
1899
1900pub(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
1928pub(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
1933fn 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
1946pub(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
1955pub(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
1980pub(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 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 assert_eq!(text_stamp("theirs"), text_stamp("theirs"));
2026 assert_ne!(text_stamp("theirs"), text_stamp("ours"));
2027 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}