1use crate::{Session, one_line, plain_lines};
9use clap::Subcommand;
10use jiff::{Timestamp, Zoned, tz::TimeZone};
11use serde::Deserialize;
12use sioul_core::capture;
13use sioul_core::compose::Draft;
14use sioul_core::config::Account;
15use sioul_core::contacts::{self, ContactEdit, Labeled};
16use sioul_core::links::{self, Loaded};
17use sioul_core::notes;
18use sioul_core::plan::{self, Plan, Settings};
19use sioul_core::taskview::{self, CardView, Context, Filter};
20use sioul_core::tasks::{self, ContactRef, Link, Status, Task, TaskEdit};
21use sioul_core::timelog;
22use sioul_core::today::{Today, Weather};
23use sioul_core::vdir::{self, Collection, Kind};
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26
27#[derive(Subcommand)]
28pub(crate) enum TasksCommand {
29 Now,
31 List {
33 #[arg(long, default_value = "project")]
35 by: String,
36 #[arg(long)]
38 done: bool,
39 query: Option<String>,
40 },
41 Board,
43 Timeline {
45 #[arg(long, alias = "case")]
47 project: Option<String>,
48 },
49 Show { task: String },
51 Add {
53 line: Vec<String>,
54 #[arg(long)]
56 list: Option<String>,
57 #[arg(long)]
59 parent: Option<String>,
60 #[arg(long)]
62 after: Vec<String>,
63 #[arg(long)]
65 no_sync: bool,
66 },
67 Done { task: String },
69 Start { task: String },
71 NotNow { task: String },
73 Weather { weather: String },
75 Lists,
77 NewList {
79 account: String,
80 name: String,
81 #[arg(long)]
82 color: Option<String>,
83 #[arg(long)]
85 events: bool,
86 },
87 Import {
90 file: PathBuf,
91 #[arg(long)]
93 no_sync: bool,
94 #[arg(long)]
96 dry_run: bool,
97 },
98}
99
100#[derive(Subcommand)]
101pub(crate) enum FocusCommand {
102 Start {
104 task: String,
105 #[arg(long, default_value_t = 25)]
106 minutes: u32,
107 },
108 Status,
110 Stop {
112 #[arg(long)]
113 done: bool,
114 #[arg(long)]
115 note: Option<String>,
116 },
117}
118
119struct Desk {
121 loaded: Loaded,
122 filter: Filter,
123 offices: taskview::Offices,
124 plan: Plan,
125 today: Today,
126 spent: BTreeMap<String, u32>,
127 stopped: BTreeMap<String, String>,
128 sessions: Vec<timelog::Session>,
129 settings: Settings,
131}
132
133pub(crate) fn settings(s: &Session, weather: Weather, situation: &sioul_core::quiet::Situation, projects: &[sioul_core::projects::Project], tasks: &[Task]) -> Settings {
137 let now = Zoned::now();
138 let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(0, |z| z.timestamp().as_second());
139 let read = sioul_core::agenda::occurrences(midnight, midnight + 28 * 86_400);
140 let stamp = now.timestamp().as_second();
142 let mut found: Vec<sioul_core::agenda::Occurrence> = read.iter().filter(|e| !e.task.is_empty()).cloned().collect();
143 let chosen = s.config.tasks.blocks.clone().unwrap_or_default();
144 found.extend(sioul_core::blocks::read(&sioul_core::blocks::calendars(&chosen), stamp - 2 * 86_400, stamp + sioul_core::blocks::AHEAD_DAYS * 86_400, now.time_zone()));
145 let pins = sioul_core::blocks::Blocks::of(&found, tasks, stamp, now.time_zone());
146 let events = sioul_core::blocks::without_blocks(read.clone(), tasks);
147 let needs = sioul_core::health::Health::load(&sioul_core::health::Health::default_path()).needs;
150 let days = sioul_core::needs::Days::load(&sioul_core::needs::Days::default_path());
151 let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &sioul_core::plan::event_spans(&read, 0));
153 let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
155 let day = sioul_core::pause::Day::of(&s.config, sioul_core::quiet::Blocks::read(&now, &read), &events);
156 let extension = sioul_core::pause::moved_today(&overrides, &day.evening(), &now, s.config.free_time.moves);
157 let mut settings = Settings::of_hours(&s.config.week_hours(), sioul_core::areas::TaskAreas::of_config(&s.config, projects)).with_needs(&needs, pushed).with_days(days).with_extension(extension).with_pins(pins).with_events(&now, &events);
158 settings.default_estimate = s.config.tasks.estimate.unwrap_or(settings.default_estimate);
159 (settings.today_percent, settings.heavy_today) = sioul_core::pause::today_level(&overrides, now.date(), weather);
162 settings.closed = situation.closed.clone();
163 settings.office_days = situation.office_days;
164 settings.office_hours = s.config.office_hours();
166 settings
167}
168
169impl Desk {
170 fn read(s: &Session) -> Desk {
171 let loaded = Loaded::read(&s.config);
172 let date = Zoned::now().date();
173 let today = Today::load(&Today::default_path(), date);
174 let sessions = timelog::sessions();
175 let spent = timelog::spent(&sessions, 0, i64::MAX);
176 let stopped = sessions.iter().filter(|x| !x.note.is_empty()).map(|x| (x.task.clone(), x.note.clone())).collect();
177 let overrides = sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path());
178 let now = Zoned::now();
179 let situation = sioul_core::quiet::Situation::now(&s.config, &overrides, &sioul_core::quiet::Blocks::read_now(&now), &now, &s.tr, &loaded.projects);
180 let mut settings = settings(s, today.weather, &situation, &loaded.projects, &loaded.tasks);
181 let own = |from: i64, to: i64| sioul_core::blocks::without_blocks(sioul_core::agenda::occurrences(from, to), &loaded.tasks);
183 settings.capacity = sioul_core::capacity::gather(&loaded.tasks, &sessions, &settings, &s.config.planning, &own, &now).planning;
184 let plan = plan::plan(&loaded.tasks, date, &settings, &spent, &today.aside);
185 let filter = Filter { quiet: situation.quiet_tasks(), ..Filter::default() };
186 Desk { loaded, filter, offices: situation.offices, plan, today, spent, stopped, sessions, settings }
187 }
188
189 fn context<'a>(&'a self, s: &'a Session) -> Context<'a> {
190 Context { filter: &self.filter, offices: self.offices.clone(), tasks: &self.loaded.tasks, plan: &self.plan, today: Zoned::now().date(), tr: &s.tr, projects: &self.loaded.projects, spent: &self.spent, stopped: &self.stopped }
191 }
192
193 fn find(&self, s: &Session, wanted: &str) -> Result<&Task, String> {
195 let tasks = &self.loaded.tasks;
196 if let Some(task) = tasks.iter().find(|t| t.uid == wanted) {
197 return Ok(task);
198 }
199 let lower = wanted.to_lowercase();
200 let starting: Vec<&Task> = tasks.iter().filter(|t| t.title.to_lowercase().starts_with(&lower)).collect();
201 let found: Vec<&Task> = if starting.is_empty() { tasks.iter().filter(|t| t.title.to_lowercase().contains(&lower)).collect() } else { starting };
202 match found.as_slice() {
203 [one] => Ok(one),
204 [] => Err(s.say("task-not-found", &[("what", wanted.to_string())])),
205 many => Err(s.say("task-ambiguous", &[("what", wanted.to_string()), ("titles", many.iter().take(5).map(|t| format!("{} ({})", t.title, t.uid)).collect::<Vec<_>>().join("; "))])),
206 }
207 }
208}
209
210pub(crate) fn run(s: &Session, command: TasksCommand) -> Result<(), String> {
211 match command {
212 TasksCommand::Now => now(s),
213 TasksCommand::List { by, done, query } => list(s, &by, done, query.as_deref().unwrap_or("")),
214 TasksCommand::Board => board(s),
215 TasksCommand::Timeline { project } => timeline(s, project.as_deref()),
216 TasksCommand::Show { task } => show(s, &task),
217 TasksCommand::Add { line, list, parent, after, no_sync } => add(s, &line.join(" "), list.as_deref(), parent.as_deref(), &after, no_sync),
218 TasksCommand::Done { task } => set_status(s, &task, Status::Completed),
219 TasksCommand::Start { task } => set_status(s, &task, Status::InProcess),
220 TasksCommand::NotNow { task } => not_now(s, &task),
221 TasksCommand::Weather { weather } => set_weather(s, &weather),
222 TasksCommand::Lists => lists(s),
223 TasksCommand::NewList { account, name, color, events } => new_list(s, &account, &name, color.as_deref(), events),
224 TasksCommand::Import { file, no_sync, dry_run } => import(s, &file, no_sync, dry_run),
225 }
226}
227
228fn print_card(card: &CardView, indent: usize) {
229 let pad = " ".repeat(indent);
230 let mark = match card.status {
231 Status::Completed => "✓",
232 Status::InProcess => "▸",
233 Status::Cancelled => "×",
234 Status::NeedsAction => "○",
235 };
236 println!("{pad}{mark} {} [{}]", one_line(&card.title), one_line(&card.uid));
238 for line in [&card.due, &card.estimate, &card.waits, &card.unblocks, &card.steps, &card.stopped, &card.tight, &card.done_on] {
239 if !line.is_empty() {
240 println!("{pad} {}", one_line(line));
241 }
242 }
243}
244
245fn now(s: &Session) -> Result<(), String> {
246 let desk = Desk::read(s);
247 let cx = desk.context(s);
248 let view = taskview::now(&cx, desk.today.weather, &desk.today.aside);
249 if !view.weather_note.is_empty() {
250 println!("{}\n", one_line(&view.weather_note));
251 }
252 match &view.now {
253 Some(card) => {
254 println!("{}", s.tr.text("task-now-title", None));
255 print_card(card, 0);
256 for why in &view.why {
257 println!(" {}", one_line(why));
258 }
259 if !view.picked.is_empty() {
260 println!(" {}", one_line(&view.picked));
261 }
262 if let Some(then) = &view.then {
263 println!("\n{}", s.tr.text("task-then", None));
264 print_card(then, 0);
265 }
266 }
267 None => println!("{}", one_line(&view.empty)),
268 }
269 for line in view.loops.iter().chain(std::iter::once(&view.wip)).filter(|l| !l.is_empty()) {
270 println!("\n{}", one_line(line));
271 }
272 Ok(())
273}
274
275fn list(s: &Session, by: &str, done: bool, query: &str) -> Result<(), String> {
276 let desk = Desk::read(s);
277 let view = taskview::list(&desk.context(s), by, done, query);
278 for group in &view.groups {
279 println!("\n{}", one_line(&group.title));
280 for card in &group.rows {
281 print_card(card, card.depth + 1);
282 }
283 }
284 Ok(())
285}
286
287fn board(s: &Session) -> Result<(), String> {
288 let desk = Desk::read(s);
289 let view = taskview::board(&desk.context(s), None);
290 for column in &view.columns {
291 println!("\n{}", one_line(&column.title));
292 for card in &column.cards {
293 print_card(card, 1);
294 }
295 }
296 if !view.wip.is_empty() {
297 println!("\n{}", one_line(&view.wip));
298 }
299 Ok(())
300}
301
302fn timeline(s: &Session, project: Option<&str>) -> Result<(), String> {
303 let desk = Desk::read(s);
304 let rest = desk.settings.rest_days();
306 let view = taskview::timeline(&desk.context(s), project, &rest);
307 let width = view.days.len();
308 for row in &view.rows {
309 let mut bar: Vec<char> = (0..width).map(|i| if view.days[i].rest { '·' } else { ' ' }).collect();
310 for i in row.start.max(0)..(row.start + row.length).min(width as i64) {
312 bar[i as usize] = if row.has_steps { '═' } else { '█' };
313 }
314 if let Some(slot) = row.due.and_then(|due| usize::try_from(due).ok()).and_then(|due| bar.get_mut(due)) {
315 *slot = '◆';
316 }
317 let title: String = format!("{}{}", " ".repeat(row.depth), one_line(&row.title)).chars().take(40).collect();
318 println!("{title:<40} {}", bar.into_iter().collect::<String>());
319 }
320 println!("\n{}", one_line(&view.note));
321 Ok(())
322}
323
324fn show(s: &Session, wanted: &str) -> Result<(), String> {
325 let desk = Desk::read(s);
326 let task = desk.find(s, wanted)?;
327 let related = desk.loaded.world().related(&links::task_uri(&task.uid));
328 let view = taskview::detail(&desk.context(s), task, &desk.sessions, &related);
329 print_card(&view.card, 0);
330 if !task.notes.is_empty() {
331 println!("\n{}", plain_lines(&task.notes));
332 }
333 for step in &view.steps {
334 print_card(step, 1);
335 }
336 if !view.steps_total.is_empty() {
337 println!(" {}", one_line(&view.steps_total));
338 }
339 for r in &view.related {
340 let when = if r.when.is_empty() { String::new() } else { format!(" · {}", one_line(&r.when)) };
341 let gone = if r.found { "" } else { " ?" };
342 println!(" {}: {}{when}{gone} <{}>", one_line(&r.how), one_line(&r.title), one_line(&r.uri));
343 }
344 for session in &view.sessions {
345 println!(" {}", one_line(session));
346 }
347 Ok(())
348}
349
350fn target_list(s: &Session, named: Option<&str>) -> Result<Collection, String> {
352 match named {
353 Some(id) => tasks::lists().into_iter().find(|c| format!("{}/{}", c.account, c.id) == id && !c.read_only).ok_or_else(|| s.say("task-no-such-list", &[("list", id.to_string())])),
354 None => tasks::default_list().ok_or_else(|| s.tr.text("task-no-list", None)),
355 }
356}
357
358fn sync_list(s: &Session, list: &Collection) -> Result<(), String> {
359 match s.config.account(&list.account).filter(|a| a.is_dav()) {
360 Some(account) => crate::dav::sync_one(s, account),
361 None => Ok(()),
362 }
363}
364
365fn add(s: &Session, line: &str, list: Option<&str>, parent: Option<&str>, after: &[String], no_sync: bool) -> Result<(), String> {
366 let desk = Desk::read(s);
367 let projects: Vec<String> = desk.loaded.projects.iter().map(|c| c.id.clone()).collect();
368 let captured = capture::capture(line, Zoned::now().date(), &projects, &s.config.task_kinds(&s.tr));
369 let mut edit = captured.edit;
370 if edit.title.is_empty() {
371 return Err(s.tr.text("task-no-title", None));
372 }
373 if let Some(parent) = parent {
374 edit.parent = desk.find(s, parent)?.uid.clone();
375 }
376 for wanted in after {
377 edit.waits_for.push(desk.find(s, wanted)?.uid.clone());
378 }
379 let target = target_list(s, list)?;
380 let text = tasks::new_task(&edit, &vdir::new_name(), &TimeZone::system(), &Zoned::now())?;
381 let path = tasks::new_path(&target);
382 vdir::write_item(&path, &text)?;
383 for chip in &captured.chips {
384 let value = if chip.kind == "kind" { s.tr.text(&format!("task-kind-{}", chip.value), None) } else { chip.value.clone() };
386 println!(" {}: {value}", s.tr.text(&format!("chip-{}", chip.kind), None));
387 }
388 println!("{}", path.display());
389 if no_sync { Ok(()) } else { sync_list(s, &target) }
390}
391
392fn write_task(s: &Session, task: &Task, text: &str) -> Result<(), String> {
393 vdir::write_item(Path::new(&task.key), text)?;
394 let list = tasks::lists().into_iter().find(|c| format!("{}/{}", c.account, c.id) == task.list_id);
395 match list {
396 Some(list) => sync_list(s, &list),
397 None => Ok(()),
398 }
399}
400
401fn set_status(s: &Session, wanted: &str, status: Status) -> Result<(), String> {
402 let desk = Desk::read(s);
403 let task = desk.find(s, wanted)?;
404 if task.read_only {
405 return Err(s.tr.text("task-read-only", None));
406 }
407 let current = std::fs::read_to_string(&task.key).map_err(|e| format!("{}: {e}", task.key))?;
408 let text = tasks::set_status(¤t, status, &TimeZone::system(), &Zoned::now())?;
409 vdir::write_item(Path::new(&task.key), &text)?;
410 if status == Status::Completed {
411 let after = Desk::read(s);
413 let effect = taskview::done_effect(&after.context(s), &desk.plan, &task.uid);
414 println!("{}", if effect.is_empty() { s.tr.text("task-done-said", None) } else { format!("{} {}", s.tr.text("task-done-said", None), one_line(&effect)) });
415 }
416 write_task(s, task, &text)
417}
418
419fn not_now(s: &Session, wanted: &str) -> Result<(), String> {
420 let desk = Desk::read(s);
421 let uid = desk.find(s, wanted)?.uid.clone();
422 let mut today = desk.today;
423 today.aside.insert(uid);
424 today.save(&Today::default_path())
425}
426
427fn set_weather(s: &Session, weather: &str) -> Result<(), String> {
428 let mut today = Today::load(&Today::default_path(), Zoned::now().date());
429 today.weather = Weather::parse(weather);
430 today.save(&Today::default_path())?;
431 let said = Zoned::now();
433 let _ = sioul_core::reviews::note_weather(&sioul_core::reviews::Reviews::default_path(), said.date(), today.weather, said.timestamp().as_second(), said.time_zone());
434 now(s)
435}
436
437fn lists(s: &Session) -> Result<(), String> {
438 for list in tasks::lists() {
439 let pending = if vdir::State::load(&list.state_path()).pending { " …" } else { "" };
440 let only = if list.read_only { format!(" {}", s.tr.text("list-read-only-mark", None)) } else { String::new() };
441 println!("{}/{} {}{only}{pending} {}", one_line(&list.account), one_line(&list.id), one_line(&list.name), list.items().len());
442 }
443 Ok(())
444}
445
446fn new_list(s: &Session, account: &str, name: &str, color: Option<&str>, events: bool) -> Result<(), String> {
447 let known: Option<&Account> = s.config.account(account);
448 if known.is_none_or(|a| !a.is_dav()) && account != "local" {
449 return Err(s.say("account-unknown", &[("id", account.to_string())]));
450 }
451 let components: &[&str] = if events { &["VEVENT", "VTODO"] } else { &["VTODO"] };
452 let list = vdir::create(Kind::Calendars, account, name, color, components)?;
453 println!("{}/{}", list.account, list.id);
454 Ok(())
455}
456
457#[derive(Debug, Default, Deserialize)]
461struct Plan_ {
462 #[serde(default)]
464 list: Option<String>,
465 #[serde(default)]
467 book: Option<String>,
468 #[serde(default)]
470 prefix: String,
471 #[serde(default, rename = "task")]
472 tasks: Vec<ImportTask>,
473 #[serde(default, rename = "contact")]
474 contacts: Vec<ImportContact>,
475 #[serde(default, rename = "draft")]
476 drafts: Vec<ImportDraft>,
477 #[serde(default)]
479 calendar: Option<String>,
480 #[serde(default, rename = "event")]
484 events: Vec<ImportEvent>,
485}
486
487#[derive(Debug, Default, Deserialize)]
489#[serde(deny_unknown_fields)]
490struct ImportEvent {
491 key: String,
492 title: String,
493 start: String,
495 #[serde(default)]
498 end: Option<String>,
499 #[serde(default)]
500 location: Option<String>,
501 #[serde(default)]
502 notes: Option<String>,
503 #[serde(default)]
505 repeat: Option<String>,
506 #[serde(default)]
508 calendar: Option<String>,
509}
510
511const ONLY_EVENTS: &[&str] = &["end", "all_day", "calendar", "attendees", "organizer"];
514
515#[derive(Debug, Default, Deserialize)]
516struct ImportTask {
517 key: String,
518 title: String,
519 #[serde(default)]
520 notes: String,
521 #[serde(default)]
522 start: String,
523 #[serde(default)]
524 due: String,
525 #[serde(default)]
526 estimate: u32,
527 #[serde(default)]
528 priority: u8,
529 #[serde(default)]
530 tags: Vec<String>,
531 #[serde(default, alias = "cases")]
533 projects: Vec<String>,
534 #[serde(default)]
536 parent: String,
537 #[serde(default)]
539 after: Vec<String>,
540 #[serde(default)]
542 after_gap: BTreeMap<String, u32>,
543 #[serde(default)]
545 note: Vec<String>,
546 #[serde(default)]
547 links: Vec<Link>,
548 #[serde(default)]
550 contacts: Vec<String>,
551 #[serde(default)]
553 drafts: Vec<String>,
554 #[serde(default)]
556 status: Option<Status>,
557 #[serde(default)]
559 done: String,
560 #[serde(flatten)]
562 other: BTreeMap<String, toml::Value>,
563}
564
565#[derive(Debug, Default, Deserialize)]
566struct ImportContact {
567 key: String,
568 name: String,
569 #[serde(default)]
570 org: String,
571 #[serde(default)]
572 emails: Vec<String>,
573 #[serde(default)]
574 phones: Vec<String>,
575 #[serde(default)]
576 address: String,
577 #[serde(default)]
578 url: String,
579 #[serde(default)]
580 notes: String,
581}
582
583#[derive(Debug, Default, Deserialize)]
584struct ImportDraft {
585 key: String,
586 account: String,
587 #[serde(default)]
588 to: Vec<String>,
589 #[serde(default)]
590 cc: Vec<String>,
591 subject: String,
592 body: String,
593}
594
595fn uid_of(prefix: &str, key: &str) -> String {
597 match key.strip_prefix("uid:") {
598 Some(uid) => uid.to_string(),
599 None => format!("{prefix}{key}"),
600 }
601}
602
603fn draft_id(prefix: &str, key: &str) -> String {
605 format!("{prefix}{key}").chars().filter(char::is_ascii_alphanumeric).collect()
606}
607
608fn import(s: &Session, file: &Path, no_sync: bool, dry_run: bool) -> Result<(), String> {
609 let text = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?;
610 let plan: Plan_ = toml::from_str(&text).map_err(|e| format!("{}: {e}", file.display()))?;
611 for key in plan.tasks.iter().map(|t| &t.key).chain(plan.contacts.iter().map(|c| &c.key)).chain(plan.events.iter().map(|e| &e.key)) {
614 let uid = uid_of(&plan.prefix, key);
615 if uid.is_empty() || uid.contains(['/', '\\']) || (cfg!(windows) && uid.contains(':')) {
616 return Err(s.say("import-bad-key", &[("key", key.clone())]));
617 }
618 }
619 for task in &plan.tasks {
621 if plan.events.iter().any(|e| e.key == task.key) {
622 return Err(s.say("import-both-kinds", &[("key", task.key.clone())]));
623 }
624 let kind_says_event = task.other.get("kind").and_then(|v| v.as_str()).is_some_and(|k| matches!(k.to_ascii_lowercase().as_str(), "event" | "appointment"));
625 if let Some(field) = task.other.keys().find(|k| ONLY_EVENTS.contains(&k.as_str())).cloned().or(kind_says_event.then(|| "kind".to_string())) {
626 return Err(s.say("import-task-or-event", &[("task", task.key.clone()), ("field", field)]));
627 }
628 for field in task.other.keys() {
629 eprintln!("{}", s.say("import-unknown-field", &[("task", task.key.clone()), ("field", field.clone())]));
630 }
631 }
632 let zone = TimeZone::system();
633 let now = Zoned::now();
634 let existing = tasks::all(&zone);
635 let target = target_list(s, plan.list.as_deref())?;
636 let keys: BTreeSet<&str> = plan.tasks.iter().map(|t| t.key.as_str()).collect();
637 let missing = |key: &str| !key.starts_with("uid:") && !keys.contains(key);
638 for task in &plan.tasks {
639 for key in task.after.iter().chain(task.after_gap.keys()).chain((!task.parent.is_empty()).then_some(&task.parent)) {
640 if missing(key) {
641 return Err(s.say("import-unknown-key", &[("key", key.clone()), ("task", task.key.clone())]));
642 }
643 }
644 }
645
646 let mut contact_refs: BTreeMap<String, ContactRef> = BTreeMap::new();
648 let everyone = contacts::all();
649 let book = match plan.book.as_deref() {
650 Some(id) => vdir::collections(Kind::Contacts).into_iter().find(|c| format!("{}/{}", c.account, c.id) == id && !c.read_only),
651 None => contacts::default_book(),
652 };
653 let mut changed_books: BTreeSet<String> = BTreeSet::new();
654 for contact in &plan.contacts {
655 let uid = uid_of(&plan.prefix, &contact.key);
656 let edit = ContactEdit {
657 name: contact.name.clone(),
658 org: contact.org.clone(),
659 emails: contact.emails.iter().map(|e| Labeled { label: String::new(), value: e.clone() }).collect(),
660 phones: contact.phones.iter().map(|p| Labeled { label: String::new(), value: p.clone() }).collect(),
661 addresses: if contact.address.is_empty() { Vec::new() } else { vec![Labeled { label: String::new(), value: contacts::tidy_address(&contact.address.replace(", ", "\n")) }] },
662 urls: if contact.url.is_empty() { Vec::new() } else { vec![contact.url.clone()] },
663 notes: contact.notes.clone(),
664 ..ContactEdit::default()
665 };
666 let (path, new_text) = match everyone.iter().find(|c| c.uid == uid) {
667 Some(found) => {
668 let current = std::fs::read_to_string(&found.key).map_err(|e| e.to_string())?;
669 (PathBuf::from(&found.key), contacts::apply(¤t, &edit))
670 }
671 None => {
672 let book = book.clone().ok_or_else(|| s.tr.text("dav-no-book", None))?;
673 (book.dir.join(format!("{uid}.vcf")), contacts::new_card_with_uid(&edit, &uid))
674 }
675 };
676 let old = std::fs::read_to_string(&path).unwrap_or_default();
677 if old != new_text {
678 println!("{} {}", if old.is_empty() { "+" } else { "~" }, one_line(&contact.name));
679 if !dry_run {
680 vdir::write_item(&path, &new_text)?;
681 if let Some(account) = path.strip_prefix(Kind::Contacts.root()).ok().and_then(|p| p.components().next()) {
682 changed_books.insert(account.as_os_str().to_string_lossy().to_string());
683 }
684 }
685 }
686 contact_refs.insert(contact.key.clone(), ContactRef { name: contact.name.clone(), uri: links::contact_uri(&uid) });
687 }
688
689 let mut draft_links: BTreeMap<String, String> = BTreeMap::new();
691 for draft in &plan.drafts {
692 let id = draft_id(&plan.prefix, &draft.key);
693 let mut saved = Draft::by_id(&id).unwrap_or_else(|| Draft { id: id.clone(), ..Draft::default() });
694 let before = saved.clone();
695 saved.account = draft.account.clone();
696 saved.to = draft.to.clone();
697 saved.cc = draft.cc.clone();
698 saved.subject = draft.subject.clone();
699 saved.body = draft.body.clone();
700 let tasks_of: Vec<String> = plan.tasks.iter().filter(|t| t.drafts.contains(&draft.key)).map(|t| links::task_uri(&uid_of(&plan.prefix, &t.key))).collect();
701 for link in tasks_of {
702 if !saved.links.contains(&link) {
703 saved.links.push(link);
704 }
705 }
706 if saved != before {
707 println!("{} {}", if before.subject.is_empty() && before.body.is_empty() { "+" } else { "~" }, one_line(&draft.subject));
708 if !dry_run {
709 saved.save()?;
710 }
711 }
712 draft_links.insert(draft.key.clone(), links::draft_uri(&id));
713 }
714
715 let mut lists_touched: BTreeSet<String> = BTreeSet::new();
717 let mut written: BTreeMap<String, PathBuf> = BTreeMap::new();
718 for (position, task) in plan.tasks.iter().enumerate() {
719 let made = now.checked_add(jiff::Span::new().seconds(position as i64)).unwrap_or_else(|_| now.clone());
721 let uid = uid_of(&plan.prefix, &task.key);
722 let found = existing.iter().find(|t| t.uid == uid);
723 let mut edit = found.map(TaskEdit::of).unwrap_or_default();
724 edit.title = task.title.clone();
725 edit.notes = task.notes.clone();
726 edit.start = task.start.clone();
727 edit.due = task.due.clone();
728 edit.estimate = task.estimate;
729 edit.priority = task.priority;
730 let merge = |into: &mut Vec<String>, more: Vec<String>| {
731 for item in more {
732 if !into.contains(&item) {
733 into.push(item);
734 }
735 }
736 };
737 merge(&mut edit.categories, task.tags.clone());
738 merge(&mut edit.projects, task.projects.clone());
739 merge(&mut edit.waits_for, task.after.iter().map(|k| uid_of(&plan.prefix, k)).collect());
740 if !task.parent.is_empty() {
741 edit.parent = uid_of(&plan.prefix, &task.parent);
742 }
743 let mut wanted_links: Vec<Link> = task.note.iter().map(|path| Link { uri: notes::uri_of(path.split('#').next().unwrap_or(path)), label: path.split_once('#').map(|(_, h)| h.to_string()).unwrap_or_default(), rel: "describedby".into() }).collect();
744 wanted_links.extend(task.links.iter().cloned());
745 wanted_links.extend(task.drafts.iter().filter_map(|k| draft_links.get(k)).map(|uri| Link { uri: uri.clone(), label: String::new(), rel: "related".into() }));
746 for link in wanted_links {
747 if !edit.links.iter().any(|l| l.uri == link.uri) {
748 edit.links.push(link);
749 }
750 }
751 for key in &task.contacts {
752 let contact = contact_refs.get(key).cloned().ok_or_else(|| s.say("import-unknown-key", &[("key", key.clone()), ("task", task.key.clone())]))?;
753 if !edit.contacts.contains(&contact) {
754 edit.contacts.push(contact);
755 }
756 }
757 if let Some(status) = task.status {
758 edit.status = status;
759 }
760 let done_on: Option<Zoned> = (!task.done.is_empty()).then(|| task.done.parse::<jiff::civil::Date>().ok()).flatten().and_then(|d| d.at(12, 0, 0, 0).to_zoned(zone.clone()).ok());
761 if done_on.is_some() {
762 edit.status = Status::Completed;
763 }
764 let (path, old, new_text) = match found {
765 Some(found) => {
766 let current = std::fs::read_to_string(&found.key).map_err(|e| e.to_string())?;
767 let text = tasks::apply(¤t, &edit, &zone, &now)?;
768 (PathBuf::from(&found.key), current, text)
769 }
770 None => (target.dir.join(format!("{uid}.ics")), String::new(), tasks::new_task(&edit, &uid, &zone, &made)?),
771 };
772 let new_text = match &done_on {
773 Some(at) => tasks::completed_at(&new_text, at)?,
774 None => new_text,
775 };
776 if old != new_text {
777 println!("{} {}", if old.is_empty() { "+" } else { "~" }, one_line(&task.title));
778 if !dry_run {
779 vdir::write_item(&path, &new_text)?;
780 lists_touched.insert(found.map_or_else(|| format!("{}/{}", target.account, target.id), |f| f.list_id.clone()));
781 }
782 }
783 written.insert(uid, path);
784 }
785
786 lists_touched.extend(import_events(s, &plan, &existing, dry_run, &zone)?);
789
790 for task in &plan.tasks {
792 let successor = uid_of(&plan.prefix, &task.key);
793 for (key, days) in &task.after_gap {
794 let predecessor = uid_of(&plan.prefix, key);
795 let Some(path) = written.get(&predecessor).filter(|p| p.exists()) else { continue };
796 let current = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
797 let text = tasks::set_gap(¤t, &successor, i64::from(*days) * 1440, &now)?;
798 if text != current && !dry_run {
799 vdir::write_item(path, &text)?;
800 }
801 }
802 }
803
804 if dry_run || no_sync {
805 return Ok(());
806 }
807 let accounts: BTreeSet<String> = lists_touched.iter().filter_map(|l| l.split('/').next().map(str::to_string)).chain(changed_books).collect();
808 for account in accounts {
809 if let Some(account) = s.config.account(&account).filter(|a| a.is_dav()) {
810 crate::dav::sync_one(s, account)?;
811 }
812 }
813 Ok(())
814}
815
816fn event_file(uid: &str) -> Option<PathBuf> {
818 let wanted = format!("UID:{uid}");
819 vdir::collections(Kind::Calendars).into_iter().flat_map(|c| c.items()).find(|p| {
820 std::fs::read_to_string(p).is_ok_and(|t| {
821 let lines = sioul_core::lines::unfold(&t);
822 lines.iter().any(|l| l == "BEGIN:VEVENT") && lines.iter().any(|l| l.trim_end() == wanted)
823 })
824 })
825}
826
827fn blocks_of(uid: &str) -> Vec<PathBuf> {
829 vdir::collections(Kind::Calendars)
830 .into_iter()
831 .filter(|c| !c.read_only)
832 .flat_map(|c| c.items())
833 .filter(|p| std::fs::read_to_string(p).is_ok_and(|t| sioul_core::lines::unfold(&t).iter().any(|l| sioul_core::lines::name(l) == sioul_core::blocks::TASK && sioul_core::lines::value(l).trim() == uid)))
834 .collect()
835}
836
837fn import_events(s: &Session, plan: &Plan_, existing: &[Task], dry_run: bool, zone: &TimeZone) -> Result<BTreeSet<String>, String> {
842 let mut touched = BTreeSet::new();
843 for event in &plan.events {
844 let uid = uid_of(&plan.prefix, &event.key);
845 let all_day = !event.start.contains('T');
846 let bad_start = || s.say("import-event-start", &[("key", event.key.clone()), ("start", event.start.clone())]);
847 let found = event_file(&uid);
848 let before = found.as_ref().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| sioul_core::agenda::edit_of_text(&t, zone));
851 let end = match (&event.end, &before) {
852 (Some(end), _) if !end.is_empty() => end.clone(),
853 (_, Some(b)) if !all_day && b.start.contains('T') && b.end.contains('T') => {
855 let (start, was, was_end): (jiff::civil::DateTime, jiff::civil::DateTime, jiff::civil::DateTime) =
856 (event.start.parse().map_err(|_| bad_start())?, b.start.parse().map_err(|_| bad_start())?, b.end.parse().map_err(|_| bad_start())?);
857 let length = was_end.since(was).map_err(|_| bad_start())?;
858 start.checked_add(length).map_err(|_| bad_start())?.strftime("%Y-%m-%dT%H:%M").to_string()
859 }
860 _ if all_day => event.start.clone(),
861 _ => {
862 let start: jiff::civil::DateTime = event.start.parse().map_err(|_| bad_start())?;
863 start.checked_add(jiff::Span::new().hours(1)).map_err(|_| bad_start())?.strftime("%Y-%m-%dT%H:%M").to_string()
864 }
865 };
866 let mut edit = before.clone().unwrap_or_default();
867 edit.title = event.title.clone();
868 edit.start = event.start.clone();
869 edit.end = end;
870 edit.all_day = all_day;
871 for (field, given) in [(&mut edit.location, &event.location), (&mut edit.notes, &event.notes), (&mut edit.repeat, &event.repeat)] {
872 if let Some(given) = given {
873 *field = given.clone();
874 }
875 }
876 let wanted = event.calendar.clone().or_else(|| plan.calendar.clone()).unwrap_or_default();
877 let calendar = || {
878 vdir::collections(Kind::Calendars)
879 .into_iter()
880 .find(|c| !c.read_only && c.holds("VEVENT") && format!("{}/{}", c.account, c.id) == wanted)
881 .or_else(sioul_core::agenda::default_calendar)
882 .ok_or_else(|| s.tr.text("dav-no-calendar", None))
883 };
884 let as_task = existing.iter().find(|t| t.uid == uid);
885 match (found, as_task) {
886 (Some(path), _) => {
887 let current = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
888 let text = sioul_core::agenda::apply(¤t, &edit, zone)?;
889 if text != current {
890 println!("~ {}", one_line(&event.title));
891 if !dry_run {
892 vdir::write_item(&path, &text)?;
893 }
894 }
895 touched.extend(path.strip_prefix(Kind::Calendars.root()).ok().and_then(|p| p.components().next()).map(|a| format!("{}/", a.as_os_str().to_string_lossy())));
896 }
897 (None, Some(task)) => {
898 if task.read_only {
899 return Err(s.tr.text("task-read-only", None));
900 }
901 let calendar = calendar()?;
902 let text = sioul_core::agenda::new_event_with_uid(&edit, &uid, zone)?;
903 println!("→ {}", one_line(&event.title));
904 if !dry_run {
905 let gone: Vec<PathBuf> = std::iter::once(PathBuf::from(&task.key)).chain(blocks_of(&uid)).collect();
906 for path in &gone {
907 touched.extend(path.strip_prefix(Kind::Calendars.root()).ok().and_then(|p| p.components().next()).map(|a| format!("{}/", a.as_os_str().to_string_lossy())));
908 }
909 vdir::replace_items(&gone, &[], &calendar.dir.join(format!("{uid}.ics")), &text)?;
910 }
911 touched.insert(format!("{}/{}", calendar.account, calendar.id));
912 }
913 (None, None) => {
914 let calendar = calendar()?;
915 let text = sioul_core::agenda::new_event_with_uid(&edit, &uid, zone)?;
916 println!("+ {}", one_line(&event.title));
917 if !dry_run {
918 vdir::write_item(&calendar.dir.join(format!("{uid}.ics")), &text)?;
919 }
920 touched.insert(format!("{}/{}", calendar.account, calendar.id));
921 }
922 }
923 }
924 Ok(touched)
925}
926
927pub(crate) fn focus(s: &Session, command: FocusCommand) -> Result<(), String> {
928 let now = Timestamp::now().as_second();
929 match command {
930 FocusCommand::Start { task, minutes } => {
931 let desk = Desk::read(s);
932 let task = desk.find(s, &task)?;
933 if let Some(running) = timelog::running() {
934 return Err(s.say("focus-already", &[("title", desk.find(s, &running.task).map(|t| t.title.clone()).unwrap_or(running.task))]));
935 }
936 timelog::keep_running(Some(&timelog::Running { task: task.uid.clone(), start: now, planned: minutes.max(1), ..timelog::Running::default() }))?;
937 println!("{}", s.say("focus-started", &[("title", one_line(&task.title)), ("minutes", minutes.to_string())]));
938 Ok(())
939 }
940 FocusCommand::Status => {
941 match timelog::running() {
942 Some(r) => {
943 let title = one_line(&Desk::read(s).find(s, &r.task).map(|t| t.title.clone()).unwrap_or(r.task.clone()));
944 println!("{}", s.say("focus-status", &[("task", title), ("elapsed", (r.elapsed(now) / 60).to_string()), ("left", (r.remaining(now).max(0) / 60).to_string())]));
945 }
946 None => println!("{}", s.tr.text("focus-none", None)),
947 }
948 Ok(())
949 }
950 FocusCommand::Stop { done, note } => {
951 let Some(running) = timelog::running() else { return Err(s.tr.text("focus-none", None)) };
952 let session = timelog::finish_with_note(now, done, ¬e.unwrap_or_default())?.ok_or_else(|| s.tr.text("focus-none", None))?;
953 let mut args = s.tr.counted(session.minutes as usize);
954 args.set("minutes", session.minutes.to_string());
955 println!("{}", s.tr.text("focus-stopped", Some(&args)));
956 if done {
957 set_status(s, &running.task, Status::Completed)?;
958 }
959 Ok(())
960 }
961 }
962}
963
964pub(crate) fn links_command(s: &Session, uri: &str) -> Result<(), String> {
966 let loaded = Loaded::read(&s.config);
967 let world = loaded.world();
968 let me = world.describe(uri);
969 println!("{} <{}>", one_line(&me.title), one_line(&me.uri));
970 for r in taskview::related_views(&s.tr, &world.related(uri)) {
971 let when = if r.when.is_empty() { String::new() } else { format!(" · {}", one_line(&r.when)) };
972 println!(" {}: {}{when} <{}>", one_line(&r.how), one_line(&r.title), one_line(&r.uri));
973 }
974 Ok(())
975}
976
977pub(crate) fn notes_command(s: &Session, query: &str, path: Option<&str>) -> Result<(), String> {
979 let root = s.config.notes_root_path().ok_or_else(|| s.tr.text("error-no-store", None))?;
980 let vault = notes::Vault::open(&root);
981 if let Some(path) = path {
982 let note = vault.note(path).ok_or_else(|| s.say("note-not-found", &[("path", path.to_string())]))?;
983 println!("{} <{}>", one_line(¬e.title), one_line(¬e.uri()));
984 if !note.tags.is_empty() {
985 println!(" #{}", one_line(¬e.tags.join(" #")));
986 }
987 let loaded = Loaded { vault: Some(vault.clone()), ..Loaded::read(&s.config) };
988 for r in taskview::related_views(&s.tr, &loaded.world().related(¬e.uri())) {
989 println!(" {}: {} <{}>", one_line(&r.how), one_line(&r.title), one_line(&r.uri));
990 }
991 for checkbox in note.checkboxes.iter().filter(|c| !c.done) {
992 println!(" ○ {} (l. {})", one_line(&checkbox.text), checkbox.line);
993 }
994 return Ok(());
995 }
996 let found = if query.is_empty() { vault.notes.iter().collect() } else { vault.search(query) };
997 for note in found {
998 println!("{} ({})", one_line(¬e.title), one_line(¬e.path));
999 }
1000 Ok(())
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 #[test]
1008 fn a_plan_file_names_projects_either_way() {
1009 let plan: Plan_ = toml::from_str("[[task]]\nkey = \"a\"\ntitle = \"A\"\nprojects = [\"taxes\"]\n\n[[task]]\nkey = \"b\"\ntitle = \"B\"\ncases = [\"housing\"]\n").unwrap();
1011 assert_eq!(plan.tasks.iter().map(|t| t.projects.clone()).collect::<Vec<_>>(), [vec!["taxes".to_string()], vec!["housing".to_string()]]);
1012 }
1013
1014 #[test]
1019 fn an_appointment_in_a_plan_file_is_an_event_said_so() {
1020 let s = Session { config: sioul_core::config::Config::default(), config_path: PathBuf::new(), tr: sioul_core::i18n::Translator::new("en") };
1021 let list = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds plan", None, &["VTODO"]).unwrap();
1022 let calendar = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds personal", None, &["VEVENT"]).unwrap();
1023 let head = format!("list = \"local/{}\"\ncalendar = \"local/{}\"\nprefix = \"kinds-\"\n\n", list.id, calendar.id);
1024 let file = sioul_core::config::state_dir().join("kinds-import.toml");
1025 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1026 let run = |body: &str| {
1027 std::fs::write(&file, format!("{head}{body}")).unwrap();
1028 import(&s, &file, true, false)
1029 };
1030 let files = |c: &Collection| c.items();
1031
1032 let asked = run("[[task]]\nkey = \"dentist\"\ntitle = \"Dentist\"\nstart = \"2099-10-23T14:00\"\nend = \"2099-10-23T15:00\"\n").unwrap_err();
1034 assert!(asked.contains("dentist") && asked.contains("[[event]]"), "{asked}");
1035 let kind = run("[[task]]\nkey = \"dentist\"\ntitle = \"Dentist\"\nkind = \"appointment\"\n").unwrap_err();
1036 assert!(kind.contains("[[event]]"), "{kind}");
1037 assert!(files(&list).is_empty() && files(&calendar).is_empty());
1038
1039 run("[[task]]\nkey = \"dentist\"\ntitle = \"Dentist\"\nstart = \"2099-10-23T14:00\"\n").unwrap();
1041 assert_eq!(files(&list).len(), 1);
1042 std::fs::write(calendar.dir.join("block.ics"), "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:block\r\nDTSTART:20991023T120000Z\r\nDTEND:20991023T130000Z\r\nSUMMARY:Dentist\r\nX-SIOUL-TASK:kinds-dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
1043 let both = run("[[task]]\nkey = \"dentist\"\ntitle = \"Dentist\"\n\n[[event]]\nkey = \"dentist\"\ntitle = \"Dentist\"\nstart = \"2099-10-23T14:00\"\n").unwrap_err();
1044 assert!(both.contains("dentist"), "{both}");
1045
1046 let appointment = "[[event]]\nkey = \"dentist\"\ntitle = \"Dentist\"\nstart = \"2099-10-23T14:00\"\nlocation = \"1 Example Street\"\n";
1048 run(appointment).unwrap();
1049 assert!(files(&list).is_empty(), "the task gone");
1050 let events = files(&calendar);
1051 assert_eq!(events.len(), 1, "one event, the block gone: {events:?}");
1052 let text = std::fs::read_to_string(&events[0]).unwrap();
1053 assert!(text.contains("UID:kinds-dentist") && text.contains("BEGIN:VEVENT") && text.contains("LOCATION:1 Example Street") && !text.contains("X-SIOUL-TASK"), "{text}");
1054 let edit = sioul_core::agenda::edit_of(&events[0]).unwrap();
1055 assert_eq!((edit.start.as_str(), edit.end.as_str()), ("2099-10-23T14:00", "2099-10-23T15:00"), "an hour without an end");
1056
1057 run(appointment).unwrap();
1059 assert_eq!(files(&calendar), events);
1060 assert_eq!(std::fs::read_to_string(&events[0]).unwrap(), text);
1061 let kept = text.replace("END:VEVENT", "X-SIOUL-BEFORE:20\r\nX-SIOUL-AFTER:15\r\nX-SIOUL-COST:ANXIETY=7\r\nX-SIOUL-REMIND:30\r\nEND:VEVENT");
1063 std::fs::write(&events[0], &kept).unwrap();
1064 run(&appointment.replace("2099-10-23T14:00", "2099-10-23T16:00")).unwrap();
1065 let moved = std::fs::read_to_string(&events[0]).unwrap();
1066 for line in ["X-SIOUL-BEFORE:20", "X-SIOUL-AFTER:15", "ANXIETY=7", "X-SIOUL-REMIND:30", "LOCATION:1 Example Street"] {
1067 assert!(moved.contains(line), "{line} kept: {moved}");
1068 }
1069 let edit = sioul_core::agenda::edit_of(&events[0]).unwrap();
1070 assert_eq!((edit.start.as_str(), edit.end.as_str()), ("2099-10-23T16:00", "2099-10-23T17:00"), "moved, its length kept");
1071 let _ = std::fs::remove_dir_all(&list.dir);
1072 let _ = std::fs::remove_dir_all(&calendar.dir);
1073 let _ = std::fs::remove_file(&file);
1074 }
1075}