1use 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
37const LONGEST_ADDRESS: usize = 2000;
39
40fn 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 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
66fn 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
71fn 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
80fn 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
92fn 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
106fn 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
114fn 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 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 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 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 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(¤t, Status::Completed, &TimeZone::system(), &Zoned::now())?;
203 vdir::write_item(Path::new(&task.key), &text)?;
204 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 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 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
260fn notes_folder(s: &Session) -> String {
262 s.config.notes_folder.clone().filter(|f| !f.trim().is_empty()).unwrap_or_else(|| "notes".into())
263}
264
265fn 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
273fn 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
286fn 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 Err(_) => return true,
297 }
298 }
299 true
300}
301
302fn 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 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 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 let path = notes::create(&root, &folder, &file_title(&title), ¬es::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
344fn 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
353fn 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
362fn 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
378fn 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
393fn 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 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 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 draft.keep_original()?;
422 draft.body = body;
423 draft.add_signature(account.signature.as_deref());
424 draft.links = links;
425 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 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
456fn 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 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}