Skip to main content

sioul/mcp/
write.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The tools that write, on this computer only: a task added or marked
5//! done, an event added, a note written, a draft saved, two things tied.
6//! Nothing is sent from here. A task or an event changed goes to its server
7//! with the next sync, as what you change in the window does; a draft waits
8//! in Drafts until you read and send it. Nothing is deleted or overwritten.
9//!
10//! What an agent writes may come from mail it read, so it is checked as
11//! mail is: a title, a place, a subject on one line; an address one address;
12//! a tie to Sioul's own things or a web page only; a note inside the notes.
13//!
14//! Nothing is written into a project closed to agents, nor outside projects
15//! while those are closed (`access`); a thing kept from agents is not tied to,
16//! nor answered: each refusal says so plainly.
17
18use super::access;
19use super::read::{self, Desk};
20use super::tools::{Answer, Args};
21use crate::{Session, one_line};
22use jiff::Zoned;
23use jiff::civil::{Date, DateTime};
24use jiff::tz::TimeZone;
25use serde_json::json;
26use sioul_core::agenda::{self, EventEdit};
27use sioul_core::compose::{self, Draft, DraftKind};
28use sioul_core::config::Account;
29use sioul_core::consent::Consent;
30use sioul_core::links::{self, Kind, LocalLinks, Loaded, World};
31use sioul_core::porch::Lane;
32use sioul_core::tasks::{self, ContactRef, Link, Status, TaskEdit};
33use sioul_core::vdir::{self, Collection};
34use sioul_core::{notes, taskview};
35use std::path::{Path, PathBuf};
36
37/// The longest address taken: a link, not a document.
38const LONGEST_ADDRESS: usize = 2000;
39
40/// An address as Sioul writes it, of something it knows: a message kept
41/// here, a task, a note…; or a web page (`https://…`). It is written into a
42/// task, an event or a note's front matter as it is, so it is one line of
43/// visible characters; and the window opens it when the person clicks it, so
44/// it is never a file of this computer (`file:`) nor another program's
45/// scheme (`smb:`, `ms-…:`). A thing kept from agents is refused: it is not
46/// tied to, whatever ties it.
47fn known(world: &World, consent: &Consent, uri: &str) -> Result<String, String> {
48    let uri = uri.trim();
49    let shown = || one_line(&uri.chars().take(200).collect::<String>());
50    // Sioul's own addresses come out percent-encoded ("Call%20with…"); anything else as it was given.
51    let canonical = world.canonical(uri);
52    if canonical.chars().count() > LONGEST_ADDRESS || canonical.chars().any(|c| c.is_whitespace() || c.is_control()) {
53        return Err(format!("“{}” is not an address: one line without spaces, as mid:…, sioul:task/… or https://…", shown()));
54    }
55    let unknown = || format!("Sioul knows nothing at “{}”: find, search_mail, search_notes and list_tasks give addresses; a web page is https://…", shown());
56    match links::kind_of(&canonical) {
57        Kind::Web => Ok(canonical),
58        Kind::File | Kind::Other => Err(unknown()),
59        Kind::Project if world.describe(&canonical).found && !consent.is_open(&links::id_of(&canonical)) => Err(access::closed_project(&links::id_of(&canonical))),
60        _ if world.describe(&canonical).found && !access::allows(consent, world, &canonical) => Err(access::kept(&format!("“{}”", shown()))),
61        _ if world.describe(&canonical).found => Ok(canonical),
62        _ => Err(unknown()),
63    }
64}
65
66/// The projects named among addresses (`sioul:project/…`, or `sioul:case/…` as written before).
67fn projects_among(uris: &[String]) -> Vec<String> {
68    uris.iter().filter(|u| links::kind_of(u) == Kind::Project).map(|u| links::id_of(u)).collect()
69}
70
71/// Whether something new in these projects may be written for an agent: each
72/// open; in none, while things outside projects are open.
73fn may_write(consent: &Consent, projects: &[String]) -> Result<(), String> {
74    if let Some(closed) = projects.iter().find(|p| consent.knows(p) && !consent.is_open(p)) {
75        return Err(access::closed_project(closed));
76    }
77    if consent.allows_projects(projects.iter().map(String::as_str)) { Ok(()) } else { Err(access::OUTSIDE_CLOSED.into()) }
78}
79
80/// Addresses checked, each once.
81fn known_all(world: &World, consent: &Consent, uris: &[String]) -> Result<Vec<String>, String> {
82    let mut out: Vec<String> = Vec::new();
83    for uri in uris {
84        let uri = known(world, consent, uri)?;
85        if !out.contains(&uri) {
86            out.push(uri);
87        }
88    }
89    Ok(out)
90}
91
92/// A tie held by a new task, as `Loaded::tie` writes one in a task: its
93/// people as CONTACT, its projects as REFID, a note as what describes it, the rest as LINK.
94fn tie_in_task(edit: &mut TaskEdit, world: &World, uri: &str) {
95    let link = |uri: String, rel: &str| Link { uri, label: String::new(), rel: rel.into() };
96    match links::kind_of(uri) {
97        Kind::Contact => edit.contacts.push(ContactRef { name: world.describe(uri).title, uri: uri.to_string() }),
98        Kind::Project if edit.projects.contains(&links::id_of(uri)) => {}
99        Kind::Project => edit.projects.push(links::id_of(uri)),
100        Kind::Note => edit.links.push(link(uri.to_string(), "describedby")),
101        Kind::Task | Kind::Event => edit.links.push(link(format!("uid:{}", links::id_of(uri)), "related")),
102        _ => edit.links.push(link(uri.to_string(), "related")),
103    }
104}
105
106/// The list a new task goes into: the one named ("account/id"), else the first made for tasks.
107fn target_list(s: &Session, named: Option<&str>) -> Result<Collection, String> {
108    match named {
109        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", one_line(id))])),
110        None => tasks::default_list().ok_or_else(|| s.tr.text("task-no-list-yet", None)),
111    }
112}
113
114/// A new item's file, under a name no other file has: never over another one.
115fn free_item(mut path: PathBuf, again: impl Fn() -> PathBuf) -> PathBuf {
116    while path.exists() {
117        path = again();
118    }
119    path
120}
121
122pub fn add_task(s: &Session, args: &Args) -> Result<Answer, String> {
123    let mut edit = TaskEdit {
124        title: args.needed_line("title")?,
125        notes: args.body("notes")?,
126        start: args.moment("start")?.unwrap_or_default(),
127        due: args.moment("due")?.unwrap_or_default(),
128        estimate: args.number("estimate", 0, 1, 6000)? as u32,
129        priority: args.number("priority", 0, 1, 9)? as u8,
130        categories: args.list("tags")?.iter().map(|tag| one_line(tag)).filter(|tag| !tag.is_empty()).collect(),
131        ..TaskEdit::default()
132    };
133    if let Some(kind) = args.line("kind")? {
134        let kinds = s.config.task_kinds(&s.tr);
135        if !kinds.iter().any(|(id, _)| *id == kind) {
136            return Err(format!("“kind” is one of: {}; not “{kind}”.", kinds.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>().join(", ")));
137        }
138        edit.kind = kind;
139    }
140    let list = target_list(s, args.text("list")?.as_deref())?;
141    let loaded = Loaded::read(&s.config);
142    let consent = access::of(s, &loaded);
143    // "cases": the argument's name before the one name (`tools::FORMER`).
144    let mut wanted = args.list("projects")?;
145    wanted.extend(args.list("cases")?);
146    for project in wanted {
147        if !loaded.projects.iter().any(|c| c.id == project) {
148            return Err(format!("No project “{}”: list_projects gives their ids.", one_line(&project)));
149        }
150        if !consent.is_open(&project) {
151            return Err(access::closed_project(&project));
152        }
153        if !edit.projects.contains(&project) {
154            edit.projects.push(project);
155        }
156    }
157    // Only the tasks an agent may see are found: a step of one kept from agents is never made.
158    let open_tasks: Vec<tasks::Task> = loaded.tasks.iter().filter(|t| consent.allows(&links::task_uri(&t.uid))).cloned().collect();
159    let mut projects = edit.projects.clone();
160    if let Some(parent) = args.text("parent")? {
161        let parent = read::find_task(s, &open_tasks, &parent)?;
162        edit.parent = parent.uid.clone();
163        projects.extend(consent.projects_of(&links::task_uri(&parent.uid)));
164    }
165    for wanted in args.list("after")? {
166        edit.waits_for.push(read::find_task(s, &open_tasks, &wanted)?.uid.clone());
167    }
168    let world = loaded.world();
169    if let Some(source) = args.text("source")? {
170        edit.links.push(Link { uri: known(&world, &consent, &source)?, label: String::new(), rel: "via".into() });
171    }
172    let tied = known_all(&world, &consent, &args.list("links")?)?;
173    projects.extend(projects_among(&tied));
174    may_write(&consent, &projects)?;
175    for uri in tied {
176        tie_in_task(&mut edit, &world, &uri);
177    }
178    let uid = vdir::new_name();
179    let text = tasks::new_task(&edit, &uid, &TimeZone::system(), &Zoned::now())?;
180    let path = free_item(tasks::new_path(&list), || tasks::new_path(&list));
181    vdir::write_item(&path, &text)?;
182    let uri = links::task_uri(&uid);
183    let text = format!("{}\n{}  [{uid}]  <{uri}>", s.say("ui-saved", &[("path", path.display().to_string())]), edit.title);
184    Ok(Answer { text, data: json!({ "uid": uid, "uri": uri, "list": format!("{}/{}", list.account, list.id), "file": path, "sent": false }) })
185}
186
187pub fn complete_task(s: &Session, args: &Args) -> Result<Answer, String> {
188    let wanted = args.needed("task")?;
189    let desk = Desk::read(s);
190    // Only the tasks an agent may see are found: one kept from agents is never marked done by one.
191    let open = desk.open_tasks();
192    let task = read::find_task(s, &open, &wanted)?;
193    if task.read_only {
194        return Err(s.tr.text("task-read-only", None));
195    }
196    let said = s.tr.text("task-done-said", None);
197    // Done already, and not one that comes back: nothing to write.
198    if task.status == Status::Completed && task.repeat.is_empty() {
199        return Ok(Answer { text: format!("{said} {}  [{}]", one_line(&task.title), task.uid), data: json!({ "uid": task.uid, "changed": false }) });
200    }
201    let current = std::fs::read_to_string(&task.key).map_err(|e| format!("{}: {e}", task.key))?;
202    let text = tasks::set_status(&current, Status::Completed, &TimeZone::system(), &Zoned::now())?;
203    vdir::write_item(Path::new(&task.key), &text)?;
204    // What finishing it changed, said once.
205    let after = Desk::read(s);
206    let effect = taskview::done_effect(&after.context(s), &desk.plan, &task.uid);
207    let comes_back = !task.repeat.is_empty();
208    let text = [said, format!("{}  [{}]", one_line(&task.title), task.uid), one_line(&effect)].into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>().join("\n");
209    Ok(Answer { text, data: json!({ "uid": task.uid, "changed": true, "comes_back": comes_back, "effect": effect }) })
210}
211
212pub fn add_event(s: &Session, args: &Args) -> Result<Answer, String> {
213    // A new event is in no project.
214    if !s.config.mcp.outside_projects {
215        return Err(access::OUTSIDE_CLOSED.into());
216    }
217    let title = args.needed_line("title")?;
218    let all_day = args.flag("all_day")?;
219    let start = args.moment("start")?.ok_or("“start” is needed.")?;
220    let end = args.moment("end")?.unwrap_or_else(|| start.clone());
221    // `moment` gave days as "2026-10-05" and times as "2026-10-05T09:00".
222    let timed = |m: &str| m.contains('T');
223    let backwards = if all_day {
224        if timed(&start) || timed(&end) {
225            return Err("A whole-day event starts and ends on days, as 2026-10-05.".into());
226        }
227        end.parse::<Date>().ok() < start.parse::<Date>().ok()
228    } else {
229        if !timed(&start) || !timed(&end) {
230            return Err("An event at a time starts and ends at times, as 2026-10-05T09:00; a day alone is all_day.".into());
231        }
232        end.parse::<DateTime>().ok() < start.parse::<DateTime>().ok()
233    };
234    if backwards {
235        return Err("“end” comes before “start”.".into());
236    }
237    let repeat = args.text("repeat")?.unwrap_or_default();
238    if !matches!(repeat.as_str(), "" | "daily" | "weekly" | "monthly" | "yearly") {
239        return Err(format!("“repeat” is daily, weekly, monthly or yearly; not “{}”.", one_line(&repeat)));
240    }
241    let calendar = match args.text("calendar")? {
242        Some(id) => vdir::collections(vdir::Kind::Calendars)
243            .into_iter()
244            .find(|c| format!("{}/{}", c.account, c.id) == id && !c.read_only && c.holds("VEVENT"))
245            .ok_or_else(|| format!("No calendar “{}” takes events here (account/id).", one_line(&id)))?,
246        None => agenda::default_calendar().ok_or_else(|| s.tr.text("dav-no-calendar", None))?,
247    };
248    let edit = EventEdit { title, location: args.line("location")?.unwrap_or_default(), notes: args.body("notes")?, start, end, all_day, repeat, ..EventEdit::default() };
249    let zone = TimeZone::system();
250    let text = agenda::new_event(&edit, &zone)?;
251    let path = free_item(agenda::new_path(&calendar), || agenda::new_path(&calendar));
252    vdir::write_item(&path, &text)?;
253    let uid = agenda::event_ref(&text, &path.display().to_string(), &zone).map(|e| e.uid).unwrap_or_default();
254    let uri = links::event_uri(&uid);
255    let when = if edit.start == edit.end { edit.start.clone() } else { format!("{} – {}", edit.start, edit.end) };
256    let text = format!("{}\n{} · {when}  <{uri}>", s.tr.text("event-saved", None), edit.title);
257    Ok(Answer { text, data: json!({ "uid": uid, "uri": uri, "calendar": format!("{}/{}", calendar.account, calendar.id), "file": path, "sent": false }) })
258}
259
260/// The folder new notes go into: the configuration's, else "notes" (as the window does).
261fn notes_folder(s: &Session) -> String {
262    s.config.notes_folder.clone().filter(|f| !f.trim().is_empty()).unwrap_or_else(|| "notes".into())
263}
264
265/// A name Windows keeps for a device, whatever comes after its first dot
266/// ("CON", "nul.md", "COM1"): a file or a folder of that name is no file there.
267fn reserved(name: &str) -> bool {
268    let stem = name.split('.').next().unwrap_or(name).trim_end().to_ascii_lowercase();
269    let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| n.len() == 1 && n != "0" && n.chars().all(|c| c.is_ascii_digit()));
270    matches!(stem.as_str(), "con" | "prn" | "aux" | "nul" | "conin$" | "conout$") || numbered("com") || numbered("lpt")
271}
272
273/// The folder an agent asked for, as a folder of the notes: relative, inside,
274/// each name one that every system can hold (no drive as "C:", no device as
275/// "CON", no name ending with a dot or a space), no hidden folder.
276fn notes_folder_asked(asked: &str) -> Result<String, String> {
277    let wrong = || format!("“folder” is a folder inside the notes, as admin/letters; not “{}”.", one_line(asked));
278    if asked.starts_with(['/', '\\']) {
279        return Err(wrong());
280    }
281    let folder = notes::normalize(asked).ok_or_else(wrong)?;
282    let fits = |part: &str| !(part.starts_with('.') || part.ends_with(['.', ' ']) || part.contains(['<', '>', ':', '"', '|', '?', '*']) || part.chars().any(char::is_control) || reserved(part));
283    if folder.split('/').all(fits) { Ok(folder) } else { Err(wrong()) }
284}
285
286/// Whether a folder of the notes is reached without a link: a symbolic
287/// link or a junction on its way would take the note out of the notes.
288fn inside_notes(root: &Path, folder: &str) -> bool {
289    let mut at = root.to_path_buf();
290    for part in folder.split('/') {
291        at.push(part);
292        match std::fs::symlink_metadata(&at) {
293            Ok(meta) if meta.file_type().is_symlink() => return false,
294            Ok(_) => {}
295            // Not made yet: `notes::write` makes it, inside.
296            Err(_) => return true,
297        }
298    }
299    true
300}
301
302/// The title a note's file is named after: at most 120 bytes of it (a file
303/// name holds 255, and a number may follow), and never a name Windows keeps.
304fn file_title(title: &str) -> String {
305    let mut name = String::new();
306    for c in title.chars() {
307        if name.len() + c.len_utf8() > 120 {
308            break;
309        }
310        name.push(c);
311    }
312    if reserved(notes::file_name(&name).trim_end_matches(".md")) { format!("_{name}") } else { name }
313}
314
315pub fn add_note(s: &Session, args: &Args) -> Result<Answer, String> {
316    let title = args.needed_line("title")?;
317    let mut body = args.body("body")?;
318    let root = s.config.notes_root_path().ok_or_else(|| s.tr.text("error-no-store", None))?;
319    let folder = match args.text("folder")? {
320        Some(asked) => notes_folder_asked(&asked)?,
321        None => notes_folder(s),
322    };
323    if !inside_notes(&root, &folder) {
324        return Err(format!("“{}” leads out of the notes folder (a link): the note is not written there.", one_line(&folder)));
325    }
326    let loaded = Loaded::read(&s.config);
327    let consent = access::of(s, &loaded);
328    let links = known_all(&loaded.world(), &consent, &args.list("links")?)?;
329    // In the projects whose files hold its folder, and those it is tied to.
330    let mut projects: Vec<String> = consent.note_projects(&format!("{folder}/{}", notes::file_name(&file_title(&title)))).into_iter().collect();
331    projects.extend(projects_among(&links));
332    may_write(&consent, &projects)?;
333    // The title is written as the heading: a body starting with it again keeps one.
334    if let Some(rest) = body.trim_start().strip_prefix(&format!("# {title}")).filter(|r| r.is_empty() || r.starts_with('\n')) {
335        body = rest.trim_start().to_string();
336    }
337    // Its free name found and taken under the notes' lock: the window making a note of that title at once makes another.
338    let path = notes::create(&root, &folder, &file_title(&title), &notes::new_text(&title, &[("links", links.clone())], &body))?;
339    let uri = notes::uri_of(&path);
340    let text = format!("{}\n{title}  <{uri}>", s.say("ui-saved", &[("path", path.clone())]));
341    Ok(Answer { text, data: json!({ "uri": uri, "path": path, "links": links }) })
342}
343
344/// The account a message goes out from: the one asked or the message's,
345/// else the first by priority, among those Sioul sends from.
346fn sender<'a>(s: &'a Session, asked: Option<&str>) -> Result<&'a Account, String> {
347    if let Some(id) = asked {
348        return s.config.account(id).filter(|a| a.syncs()).ok_or_else(|| s.say("account-unknown", &[("id", one_line(id))]));
349    }
350    s.config.accounts.iter().filter(|a| a.syncs()).min_by_key(|a| a.priority).ok_or_else(|| s.tr.text("mail-no-account", None))
351}
352
353/// A draft's text: needed, and in Markdown.
354fn draft_body(args: &Args) -> Result<String, String> {
355    let body = args.body("body")?;
356    if body.trim().is_empty() {
357        return Err("“body” is needed: the text of the draft, in Markdown.".into());
358    }
359    Ok(body)
360}
361
362/// A recipient as the agent wrote it, checked: one address
363/// ("jane@example.org", or "Jane <jane@example.org>"), and a name that can
364/// neither hold another address nor pass for one, nor break a header.
365fn recipient(entry: &str) -> Result<String, String> {
366    let entry = entry.trim();
367    let wrong = || format!("“{}” is not one address: jane@example.org, or Jane <jane@example.org>.", one_line(entry));
368    let address = compose::address_of(entry).filter(|_| !entry.chars().any(char::is_control)).ok_or_else(wrong)?;
369    let (local, domain) = address.split_once('@').ok_or_else(wrong)?;
370    let plain = |part: &str| !part.is_empty() && !part.chars().any(|c| c.is_whitespace() || matches!(c, '@' | ',' | ';' | '<' | '>' | '"' | '(' | ')' | '[' | ']' | '\\'));
371    let name = entry.rfind('<').map_or("", |at| entry[..at].trim());
372    if !plain(local) || !plain(domain) || name.contains(['<', '>', '@', '"', ',', ';']) {
373        return Err(wrong());
374    }
375    Ok(entry.to_string())
376}
377
378/// A draft saved, said as the window says it, with what it holds. Saved
379/// under a name no other draft has: never over another one.
380fn draft_saved(s: &Session, draft: &Draft) -> Result<Answer, String> {
381    draft.save()?;
382    let uri = links::draft_uri(&draft.id);
383    let text = format!(
384        "{}\nTo: {}\nSubject: {}\n<{uri}>\n(Not sent: the person reads it in Sioul's window and sends it themselves.)",
385        s.tr.text("compose-kept", None),
386        draft.to.iter().chain(&draft.cc).map(|r| one_line(r)).collect::<Vec<_>>().join(", "),
387        one_line(&draft.subject)
388    );
389    let data = json!({ "id": draft.id, "uri": uri, "account": draft.account, "to": draft.to, "cc": draft.cc, "subject": draft.subject, "encrypt": draft.encrypt, "sent": false });
390    Ok(Answer { text, data })
391}
392
393/// A new draft's own name, one no draft has yet.
394fn free_draft(draft: &mut Draft) {
395    while draft.path().exists() {
396        draft.id = Draft::new(&draft.account).id;
397    }
398}
399
400pub fn draft_reply(s: &Session, args: &Args) -> Result<Answer, String> {
401    let path = read::find_message(s, &args.needed("message")?)?;
402    let body = draft_body(args)?;
403    // Mail kept from agents is not answered by one: a blocked sender, hostile mail to a shielded address.
404    let (judged, _) = read::judged(s, &path)?;
405    if judged.lane == Lane::Hostile {
406        return Err("This message is hostile mail to a shielded address: Sioul keeps it from agents. The person can answer it from the window, if they choose to.".into());
407    }
408    let loaded = Loaded::read(&s.config);
409    let consent = access::of(s, &loaded);
410    // Mail kept from agents is not answered by one.
411    if !consent.allows_mail(judged.card.message_id.as_deref(), Some(&judged.card)) {
412        return Err(access::kept("This message"));
413    }
414    let kind = if args.flag("reply_all")? { DraftKind::ReplyAll } else { DraftKind::Reply };
415    let links = known_all(&loaded.world(), &consent, &args.list("links")?)?;
416    let (own_account, _) = read::place_of(&s.config, &path);
417    let account = s.config.account(&own_account).filter(|a| a.syncs()).map_or_else(|| sender(s, None), Ok)?;
418    let mut draft = compose::answer(&path, kind, &account.id, &crate::mail::own_addresses(s)).ok_or_else(|| s.tr.text("mail-message-gone", None))?;
419    free_draft(&mut draft);
420    // A copy of the message answered stays with the draft: it may be archived before the answer leaves.
421    draft.keep_original()?;
422    draft.body = body;
423    draft.add_signature(account.signature.as_deref());
424    draft.links = links;
425    // An answer to encrypted mail is encrypted too; the window says when it cannot be.
426    let encrypted = sioul_core::maildir::read_one(&path).and_then(|c| c.headers.first("Content-Type").map(|t| t.trim().to_ascii_lowercase().starts_with("multipart/encrypted"))).unwrap_or(false);
427    draft.encrypt = encrypted;
428    draft_saved(s, &draft)
429}
430
431pub fn draft_message(s: &Session, args: &Args) -> Result<Answer, String> {
432    let to = args.list("to")?.iter().map(|entry| recipient(entry)).collect::<Result<Vec<_>, _>>()?;
433    let cc = args.list("cc")?.iter().map(|entry| recipient(entry)).collect::<Result<Vec<_>, _>>()?;
434    if to.is_empty() {
435        return Err("“to” is needed: who it goes to.".into());
436    }
437    let subject = args.needed_line("subject")?;
438    let body = draft_body(args)?;
439    let account = sender(s, args.text("account")?.as_deref())?;
440    let loaded = Loaded::read(&s.config);
441    let consent = access::of(s, &loaded);
442    let links = known_all(&loaded.world(), &consent, &args.list("links")?)?;
443    // A new message is in the projects it is tied to; in none, outside them.
444    may_write(&consent, &projects_among(&links))?;
445    let mut draft = Draft::new(&account.id);
446    free_draft(&mut draft);
447    draft.to = to;
448    draft.cc = cc;
449    draft.subject = subject;
450    draft.body = body;
451    draft.add_signature(account.signature.as_deref());
452    draft.links = links;
453    draft_saved(s, &draft)
454}
455
456/// Writes what makes a tie: a note through the notes, a task or an event as an item of its collection.
457fn rewrite(s: &Session, change: &links::Rewrite) -> Result<(), String> {
458    match change.kind {
459        Kind::Note => {
460            let root = s.config.notes_root_path().ok_or_else(|| s.tr.text("error-no-store", None))?;
461            let path = change.path.strip_prefix(&root).map_err(|_| format!("{}: outside the notes", change.path.display()))?;
462            notes::write(&root, &path.to_string_lossy().replace('\\', "/"), &change.text).map(|_| ())
463        }
464        _ => vdir::write_item(&change.path, &change.text),
465    }
466}
467
468pub fn link(s: &Session, args: &Args) -> Result<Answer, String> {
469    let loaded = Loaded::read(&s.config);
470    let world = loaded.world();
471    let consent = access::of(s, &loaded);
472    let (from, to) = (known(&world, &consent, &args.needed("from")?)?, known(&world, &consent, &args.needed("to")?)?);
473    if from == to {
474        return Err("A thing is not tied to itself.".into());
475    }
476    // In the task, the event or the note when one can hold it; else in Sioul's
477    // own file. Worked out and written with the notes' lock held, that file
478    // read again first: the window writing the same note meanwhile loses nothing.
479    let written = notes::with_lock(|| -> Result<String, String> {
480        match loaded.tie(&from, &to, &Zoned::now()) {
481            Some(change) => {
482                rewrite(s, &change)?;
483                Ok(change.path.display().to_string())
484            }
485            None => {
486                let path = LocalLinks::default_path();
487                LocalLinks::add_all(&path, &[links::Edge { from: from.clone(), to: to.clone(), how: "link".into() }])?;
488                Ok(path.display().to_string())
489            }
490        }
491    })?;
492    let tied = world.describe(&to);
493    let title = if tied.kind == Kind::Mail { read::mail_shown(s, read::Shield::of(&s.config).as_ref(), Path::new(&tied.key), &tied.title, "").0 } else { tied.title };
494    let text = s.say("link-made", &[("title", one_line(&title))]);
495    Ok(Answer { text, data: json!({ "from": from, "to": to, "written_in": written }) })
496}