Skip to main content

sioul/
tasks.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Tasks, notes, links and focus from the terminal: the next step and why,
5//! the list, the board, the timeline; adding a task in one line; importing a
6//! whole plan from a file (for agents and scripts); following links.
7
8use 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    /// The next step, why, and the one after it.
30    Now,
31    /// Every open task, a bigger task followed by its steps, in the plan's order.
32    List {
33        /// "project" (default) or "list"; "case", the word before, reads as "project".
34        #[arg(long, default_value = "project")]
35        by: String,
36        /// Also the tasks done in the last two weeks.
37        #[arg(long)]
38        done: bool,
39        query: Option<String>,
40    },
41    /// Free to start, started, waiting, done.
42    Board,
43    /// When each open task could happen, from today.
44    Timeline {
45        /// One project's tasks, by its id.
46        #[arg(long, alias = "case")]
47        project: Option<String>,
48    },
49    /// One task in full, with what it is tied to.
50    Show { task: String },
51    /// Adds a task from one line: "Call the CAF tomorrow ~15m #housing {30/10}".
52    Add {
53        line: Vec<String>,
54        /// The list ("account/id"); default: the first list made for tasks.
55        #[arg(long)]
56        list: Option<String>,
57        /// The task it is a step of.
58        #[arg(long)]
59        parent: Option<String>,
60        /// A task it waits for.
61        #[arg(long)]
62        after: Vec<String>,
63        /// Write it here only; the next sync sends it.
64        #[arg(long)]
65        no_sync: bool,
66    },
67    /// Marks a task done (a repeating one comes back at its next turn).
68    Done { task: String },
69    /// Marks a task started.
70    Start { task: String },
71    /// Puts a task off until tomorrow.
72    NotNow { task: String },
73    /// How today is: clear, haze or fog.
74    Weather { weather: String },
75    /// The task lists.
76    Lists,
77    /// Makes a task list; the next sync creates it on the account's server.
78    NewList {
79        account: String,
80        name: String,
81        #[arg(long)]
82        color: Option<String>,
83        /// Also takes events.
84        #[arg(long)]
85        events: bool,
86    },
87    /// Imports tasks, contacts and drafts from a file (docs/tasks.md, "Importing").
88    /// Importing again updates what the file made, by its keys.
89    Import {
90        file: PathBuf,
91        /// Write here only; the next sync sends it.
92        #[arg(long)]
93        no_sync: bool,
94        /// Say what would change, change nothing.
95        #[arg(long)]
96        dry_run: bool,
97    },
98}
99
100#[derive(Subcommand)]
101pub(crate) enum FocusCommand {
102    /// Starts a focus session on a task.
103    Start {
104        task: String,
105        #[arg(long, default_value_t = 25)]
106        minutes: u32,
107    },
108    /// The session running now.
109    Status,
110    /// Ends the session; --done marks the task done; --note keeps where you stopped.
111    Stop {
112        #[arg(long)]
113        done: bool,
114        #[arg(long)]
115        note: Option<String>,
116    },
117}
118
119/// Tasks, the plan and the day, read once.
120struct 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    /// The room the plan was made with: the timeline's days of rest come from it.
130    settings: Settings,
131}
132
133/// The room for tasks: your hours, each kind for its own tasks, less the
134/// events of the coming four weeks and the blocks tasks are pinned to;
135/// today's from now, scaled by the weather.
136pub(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    // As the window plans: the tasks pinned to a time, their blocks never events of their own (docs/tasks.md, "Pinned to a time").
141    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    // Meals, naps and the night first: the work goes around them, as each day has them,
148    // meals pushed past the events they would fall in, as the window plans.
149    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    // Meals move past the blocks as past any event.
152    let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &sioul_core::plan::event_spans(&read, 0));
153    // Today's end of work moved by free time, as the window plans (docs/pauses.md).
154    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    // As the window plans: how today is says how many heavy tasks it takes, no
160    // heavier than a hazy day after a pause.
161    (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    // Calls to offices only while they are open, as the window lays them out.
165    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        // As the window plans: what your record says of a day (docs/capacity.md), a block counted as its task.
182        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    /// A task by UID, else by the start of its title, else by a word of it; one only.
194    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    // Titles and UIDs come from shared lists and servers: one line, no escape sequences.
237    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    // The days of rest of the room the plan was made with, as in the window.
305    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        // Only the days drawn: a day out of them is left out, never a panic.
311        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
350/// The list a new task goes into: the one named ("account/id"), else the first made for tasks.
351fn 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        // In your words, as the window shows them: "date asked: 2026-10-30".
385        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(&current, status, &TimeZone::system(), &Zoned::now())?;
409    vdir::write_item(Path::new(&task.key), &text)?;
410    if status == Status::Completed {
411        // What finishing it changed, said once.
412        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    // The morning's weather, kept with the day's reviews (docs/reviews.md).
432    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/// A file to import: tasks, contacts and drafts, each with a key of its own
458/// (docs/tasks.md, "Importing"). Keys make UIDs stable: importing again
459/// updates what the file made instead of adding it twice.
460#[derive(Debug, Default, Deserialize)]
461struct Plan_ {
462    /// Where new tasks go: "account/id".
463    #[serde(default)]
464    list: Option<String>,
465    /// Where new contacts go: "account/id".
466    #[serde(default)]
467    book: Option<String>,
468    /// UIDs are this prefix and the key.
469    #[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    /// Where new appointments go: "account/id" (else the agenda's usual calendar).
478    #[serde(default)]
479    calendar: Option<String>,
480    /// Appointments: events, said so by the file (21 October 2026). Which an
481    /// item is, a task or an appointment, is the file's to say, never guessed
482    /// from its times.
483    #[serde(default, rename = "event")]
484    events: Vec<ImportEvent>,
485}
486
487/// An appointment: an event in a calendar, never a task.
488#[derive(Debug, Default, Deserialize)]
489#[serde(deny_unknown_fields)]
490struct ImportEvent {
491    key: String,
492    title: String,
493    /// "2026-10-23T14:00" (your time zone), or a day for a whole-day one.
494    start: String,
495    /// The same form; unsaid, an hour after a time, the same day for a day.
496    /// Unsaid on an event imported before: what it holds stays (F4).
497    #[serde(default)]
498    end: Option<String>,
499    #[serde(default)]
500    location: Option<String>,
501    #[serde(default)]
502    notes: Option<String>,
503    /// "daily", "weekly", "monthly", "yearly".
504    #[serde(default)]
505    repeat: Option<String>,
506    /// Its calendar, "account/id"; else the file's, else the agenda's usual.
507    #[serde(default)]
508    calendar: Option<String>,
509}
510
511/// What a task in an import never has, an appointment does: met in a
512/// `[[task]]`, the import asks which it is instead of guessing.
513const 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    /// Its projects' ids; `cases`, their name in files written before, reads too.
532    #[serde(default, alias = "cases")]
533    projects: Vec<String>,
534    /// The key of the bigger task, or `uid:<UID>`.
535    #[serde(default)]
536    parent: String,
537    /// Keys (or `uid:<UID>`) it waits for.
538    #[serde(default)]
539    after: Vec<String>,
540    /// Keys it waits for, with the days to wait after each is done.
541    #[serde(default)]
542    after_gap: BTreeMap<String, u32>,
543    /// Notes in the notes folder, by path ("admin/letters.md").
544    #[serde(default)]
545    note: Vec<String>,
546    #[serde(default)]
547    links: Vec<Link>,
548    /// Keys of contacts in this file.
549    #[serde(default)]
550    contacts: Vec<String>,
551    /// Keys of drafts in this file.
552    #[serde(default)]
553    drafts: Vec<String>,
554    /// "completed", "in-process", "cancelled"; unsaid, a new task is to do and an old one keeps its state.
555    #[serde(default)]
556    status: Option<Status>,
557    /// The day it was done ("2026-10-01"): done, on that day.
558    #[serde(default)]
559    done: String,
560    /// What else the entry says: never read as a task's, said (`ONLY_EVENTS`: asked).
561    #[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
595/// A key as a UID: the prefix, then the key.
596fn 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
603/// A draft's id holds letters and digits only (`Draft::by_id`).
604fn 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    // A new task's or contact's file is named by its UID: none may lead out of
612    // its folder ("../../.bashrc"), whoever wrote the file (an agent, a script).
613    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    // A task or an appointment: the file says which, or is asked.
620    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    // Contacts first: tasks name them.
647    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(&current, &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    // Drafts, kept here only: nothing is sent.
690    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    // Tasks: new ones made, known ones changed line by line; what you added since is kept.
716    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        // New tasks are made a second apart, in the file's order: among equals, the plan keeps it.
720        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(&current, &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    // Appointments: events; one imported before as a task becomes the event,
787    // the task and its blocks taken out with it (`vdir::replace_items`).
788    lists_touched.extend(import_events(s, &plan, &existing, dry_run, &zone)?);
789
790    // Waiting with a gap: written in the one that comes first (RFC 9253 §4).
791    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(&current, &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
816/// The file of the event with this UID, in any calendar.
817fn 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
827/// The time blocks of the task `uid` that can be changed (`blocks::TASK`).
828fn 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
837/// The file's appointments written: new ones in their calendar, known ones
838/// changed line by line, one imported before as a task made the event (the
839/// task and its blocks taken out, never both, never neither). The calendars
840/// touched, "account/id".
841fn 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        // An event imported before: what it holds is the start, the file's
849        // lines over it, its margins, costs and reminder kept (the review of 22 October 2026, F4).
850        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            // Unsaid on one known: its length kept, from its new start.
854            (_, 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(&current, &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, &note.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
964/// What a thing is tied to, both ways.
965pub(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
977/// Notes of the notes folder: those matching, or one in full with its links back.
978pub(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(&note.title), one_line(&note.uri()));
984        if !note.tags.is_empty() {
985            println!("  #{}", one_line(&note.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(&note.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(&note.title), one_line(&note.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        // `projects`, and `cases` as files written before the one name say it.
1010        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    /// The owner's appointment of 21 October 2026 had been imported as a task.
1015    /// A `[[task]]` with what only an appointment has is asked about, never
1016    /// guessed; the same key under `[[event]]` makes the event, the task and
1017    /// its block taken out, once; imported again, nothing changes.
1018    #[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        // An end is an appointment's: asked, nothing written.
1033        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        // As it was imported before: a task with a time, and its block.
1040        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        // Said an appointment: the event, the task and its block gone.
1047        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        // Imported again: the same event, unchanged.
1058        run(appointment).unwrap();
1059        assert_eq!(files(&calendar), events);
1060        assert_eq!(std::fs::read_to_string(&events[0]).unwrap(), text);
1061        // Its margins, costs and reminder, set in the window, stay through a re-import that moves it (F4).
1062        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}