1use crate::agenda::EventRef;
27use crate::projects::Project;
28use crate::compose::Draft;
29use crate::contacts::Contact;
30use crate::mailindex::{MailIndex, bare_id};
31use crate::notes::{self, Vault};
32use crate::tasks::Task;
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeSet;
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Kind {
41 Task,
42 Event,
43 Mail,
44 Draft,
45 Note,
46 Contact,
47 Budget,
48 Project,
49 Paper,
50 Site,
52 Web,
53 File,
54 Other,
55}
56
57pub fn kind_of(uri: &str) -> Kind {
59 if uri.starts_with("mid:") {
60 return Kind::Mail;
61 }
62 if let Some(rest) = uri.strip_prefix("sioul:") {
63 return match rest.split('/').next().unwrap_or("") {
64 "task" => Kind::Task,
65 "event" => Kind::Event,
66 "contact" => Kind::Contact,
67 "note" => Kind::Note,
68 "draft" => Kind::Draft,
69 "budget" => Kind::Budget,
70 "project" | "case" => Kind::Project,
72 "paper" => Kind::Paper,
73 "site" => Kind::Site,
74 _ => Kind::Other,
75 };
76 }
77 if uri.starts_with("uid:") {
78 return Kind::Task;
79 }
80 if uri.starts_with("https://") || uri.starts_with("http://") {
81 return Kind::Web;
82 }
83 if uri.starts_with("file:") {
84 return Kind::File;
85 }
86 Kind::Other
87}
88
89fn encode(id: &str) -> String {
92 let mut out = String::with_capacity(id.len());
93 for c in id.chars() {
94 if matches!(c, '%' | '/' | ' ' | '#' | '?' | '"' | '<' | '>') || c.is_control() {
95 for byte in c.encode_utf8(&mut [0; 4]).bytes() {
96 out.push_str(&format!("%{byte:02X}"));
97 }
98 } else {
99 out.push(c);
100 }
101 }
102 out
103}
104
105pub fn openable(uri: &str) -> bool {
112 let lower = uri.trim().to_ascii_lowercase();
113 if let Some(rest) = lower.strip_prefix("file:") {
114 let host = rest.strip_prefix("//").map(|r| r.split(['/', '\\']).next().unwrap_or(""));
115 return host.is_none_or(|h| h.is_empty() || h == "localhost" || (h.len() == 2 && h.ends_with(':') && h.as_bytes()[0].is_ascii_alphabetic()));
117 }
118 ["https://", "http://", "mailto:", "tel:", "sms:", "geo:"].iter().any(|scheme| lower.starts_with(scheme))
119}
120
121const PROGRAMS: &[&str] = &[
124 "exe", "com", "scr", "pif", "bat", "cmd", "msi", "msp", "mst", "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", "wsh", "hta", "cpl", "lnk", "url", "reg", "inf", "scf", "chm", "msc", "jar", "appref-ms", "application", "appx", "appxbundle", "msix", "msixbundle", "settingcontent-ms", "library-ms", "iso", "img", "vhd", "vhdx", "py", "pyw",
126 "app", "command", "terminal", "tool", "pkg", "mpkg", "dmg", "workflow", "scpt", "scptd", "applescript", "fileloc", "webloc", "inetloc",
128 "desktop", "sh", "run", "appimage", "deb", "rpm", "flatpakref", "flatpakrepo", "snap",
130];
131
132const PHONE_PROGRAMS: &[&str] = &["apk", "apks", "apkm", "xapk"];
135
136pub fn is_program(name: &str) -> bool {
138 extension_in(name, PROGRAMS)
139}
140
141pub fn is_phone_program(name: &str) -> bool {
144 extension_in(name, PHONE_PROGRAMS)
145}
146
147fn extension_in(name: &str, list: &[&str]) -> bool {
150 let name = name.trim_end_matches(['.', ' ', '/', '\\']).to_ascii_lowercase();
151 let last = name.rsplit(['/', '\\']).next().unwrap_or(&name);
152 last.rsplit_once('.').is_some_and(|(_, extension)| list.contains(&extension))
153}
154
155fn folder_of(uri: &str) -> String {
157 let file = uri.trim_end_matches(['/', '\\']);
158 file.rfind(['/', '\\']).map_or_else(|| uri.to_string(), |at| file[..=at].to_string())
159}
160
161pub fn task_uri(uid: &str) -> String {
162 format!("sioul:task/{}", encode(uid))
163}
164
165pub fn event_uri(uid: &str) -> String {
166 format!("sioul:event/{}", encode(uid))
167}
168
169pub fn contact_uri(uid: &str) -> String {
170 format!("sioul:contact/{}", encode(uid))
171}
172
173pub fn paper_uri(id: &str) -> String {
174 format!("sioul:paper/{}", encode(id))
175}
176
177pub fn draft_uri(id: &str) -> String {
178 format!("sioul:draft/{}", encode(id))
179}
180
181pub fn budget_uri(budget: &str, position: usize) -> String {
183 format!("sioul:budget/{}/{position}", encode(budget))
184}
185
186pub fn site_uri(id: &str) -> String {
188 format!("sioul:site/{}", encode(id))
189}
190
191pub fn project_uri(id: &str) -> String {
194 format!("sioul:project/{}", encode(id))
195}
196
197pub fn mail_uri(message_id: &str) -> String {
199 format!("mid:{}", encode(&bare_id(message_id)))
200}
201
202pub fn id_of(uri: &str) -> String {
204 let rest = uri.strip_prefix("sioul:").and_then(|r| r.split_once('/')).map_or(uri, |(_, id)| id);
205 let rest = rest.strip_prefix("uid:").unwrap_or(rest);
206 notes::decode(rest.split('#').next().unwrap_or(rest))
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
211pub struct Edge {
212 pub from: String,
213 pub to: String,
214 #[serde(default)]
219 pub how: String,
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct LocalLinks {
225 #[serde(default, rename = "link")]
226 pub links: Vec<Edge>,
227}
228
229impl LocalLinks {
230 pub fn default_path() -> PathBuf {
232 crate::config::data_dir().join("links.toml")
233 }
234
235 pub fn load(path: &Path) -> LocalLinks {
236 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
237 }
238
239 pub fn save(&self, path: &Path) -> Result<(), String> {
240 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
241 match std::fs::read_to_string(path) {
244 Ok(text) => {
245 toml::from_str::<LocalLinks>(&text).map_err(|e| format!("{}: {e}", path.display()))?;
246 }
247 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
248 Err(e) => return Err(fail(e)),
249 }
250 if let Some(parent) = path.parent() {
251 std::fs::create_dir_all(parent).map_err(fail)?;
252 }
253 let temporary = path.with_extension("toml.new");
254 std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
255 std::fs::rename(&temporary, path).map_err(fail)
256 }
257
258 pub fn change(path: &Path, f: impl FnOnce(&mut LocalLinks) -> bool) -> Result<bool, String> {
266 crate::filelock::with_lock(path, || {
267 let mut links = LocalLinks::load(path);
268 if !f(&mut links) {
269 return Ok(false);
270 }
271 links.save(path).map(|()| true)
272 })
273 }
274
275 pub fn add_all(path: &Path, edges: &[Edge]) -> Result<bool, String> {
277 if edges.is_empty() {
278 return Ok(false);
279 }
280 LocalLinks::change(path, |links| {
281 let before = links.links.len();
282 for edge in edges {
283 links.add(&edge.from, &edge.to, &edge.how);
284 }
285 links.links.len() != before
286 })
287 }
288
289 pub fn add(&mut self, from: &str, to: &str, how: &str) {
291 let edge = Edge { from: from.to_string(), to: to.to_string(), how: how.to_string() };
292 if !self.links.contains(&edge) {
293 self.links.push(edge);
294 }
295 }
296
297 pub fn remove(&mut self, a: &str, b: &str) {
299 self.links.retain(|e| !((e.from == a && e.to == b) || (e.from == b && e.to == a)));
300 }
301
302 pub fn untie(&mut self, world: &World, a: &str, b: &str) -> bool {
305 let (a, b) = (world.canonical(a), world.canonical(b));
306 let before = self.links.len();
307 self.links.retain(|e| {
308 let (from, to) = (world.canonical(&e.from), world.canonical(&e.to));
309 !((from == a && to == b) || (from == b && to == a))
310 });
311 self.links.len() != before
312 }
313}
314
315pub fn mail_projects(links: &LocalLinks) -> std::collections::BTreeMap<String, String> {
317 let mut out = std::collections::BTreeMap::new();
318 for edge in &links.links {
319 for (mail, project) in [(&edge.from, &edge.to), (&edge.to, &edge.from)] {
320 if mail.starts_with("mid:") && kind_of(project) == Kind::Project {
321 out.insert(bare_id(mail), id_of(project));
322 }
323 }
324 }
325 out
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct BudgetRef {
331 pub uri: String,
333 pub title: String,
335 pub budget: String,
336 pub date: String,
337 pub links: Vec<String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
342pub struct Related {
343 pub uri: String,
344 pub kind: Kind,
345 pub title: String,
346 pub detail: String,
348 pub when: Option<i64>,
350 pub key: String,
352 pub how: String,
357 pub found: bool,
359 pub program: bool,
361}
362
363fn inverse(how: &str) -> &'static str {
365 match how {
366 "note" => "notes-of",
367 "source" => "made",
368 "waits" => "unblocks",
369 "step" => "step",
370 "contact" => "involves",
371 "project" | "case" => "in-project",
372 "mention" => "mentioned-by",
373 "answers" => "answered-by",
374 _ => "link",
375 }
376}
377
378fn forward(how: &str) -> &'static str {
380 match how {
381 "note" => "note",
382 "source" => "source",
383 "waits" => "waits-for",
384 "step" => "part-of",
385 "contact" => "contact",
386 "project" | "case" => "project",
387 "mention" => "mentions",
388 "answers" => "answers",
389 _ => "link",
390 }
391}
392
393fn closeness(how: &str) -> usize {
395 const ORDER: &[&str] = &["waits-for", "unblocks", "part-of", "step", "source", "made", "answers", "answered-by", "note", "notes-of", "contact", "involves", "project", "in-project", "mentions", "mentioned-by", "link"];
396 ORDER.iter().position(|h| *h == how).unwrap_or(ORDER.len())
397}
398
399pub struct World<'a> {
401 pub tasks: &'a [Task],
402 pub events: &'a [EventRef],
403 pub contacts: &'a [Contact],
404 pub vault: Option<&'a Vault>,
405 pub drafts: &'a [Draft],
406 pub mail: &'a MailIndex,
407 pub projects: &'a [Project],
408 pub budget: &'a [BudgetRef],
409 pub local: &'a LocalLinks,
410 pub sites: &'a [crate::sites::Site],
411}
412
413impl World<'_> {
414 pub fn canonical(&self, uri: &str) -> String {
418 let uri = uri.trim();
419 if let Some(uid) = uri.strip_prefix("uid:") {
420 return if self.events.iter().any(|e| e.uid == uid) && !self.tasks.iter().any(|t| t.uid == uid) { event_uri(uid) } else { task_uri(uid) };
421 }
422 if uri.starts_with("mid:") {
423 return mail_uri(uri);
424 }
425 if let Some(path) = notes::path_of(uri) {
426 return notes::uri_of(&path);
427 }
428 if let Some(rest) = uri.strip_prefix("sioul:")
429 && let Some((kind, id)) = rest.split_once('/')
430 {
431 let id = notes::decode(id.split('#').next().unwrap_or(id));
432 let kind = if kind == "case" { "project" } else { kind };
434 return format!("sioul:{kind}/{}", encode(&id));
435 }
436 uri.to_string()
437 }
438
439 pub fn edges(&self) -> Vec<Edge> {
441 let mut out: Vec<Edge> = Vec::new();
442 let mut push = |from: String, to: String, how: &str| {
443 if from != to {
444 out.push(Edge { from, to, how: how.to_string() });
445 }
446 };
447 for task in self.tasks {
448 let me = task_uri(&task.uid);
449 for link in &task.links {
450 let how = match link.rel.as_str() {
451 "describedby" => "note",
452 "via" => "source",
453 _ => "link",
454 };
455 push(me.clone(), self.canonical(&link.uri), how);
456 }
457 for relation in &task.relations {
458 let other = task_uri(&relation.uid);
459 match relation.kind.as_str() {
460 "DEPENDS-ON" => push(me.clone(), other, "waits"),
461 "FINISHTOSTART" | "NEXT" => push(other, me.clone(), "waits"),
462 "PARENT" => push(me.clone(), other, "step"),
463 "CHILD" => push(other, me.clone(), "step"),
464 _ => push(me.clone(), self.canonical(&format!("uid:{}", relation.uid)), "link"),
465 }
466 }
467 for contact in task.contacts.iter().filter(|c| !c.uri.is_empty()) {
468 push(me.clone(), self.canonical(&contact.uri), "contact");
469 }
470 for project in &task.projects {
471 push(me.clone(), project_uri(project), "project");
472 }
473 }
474 for event in self.events {
475 let me = event_uri(&event.uid);
476 for link in &event.links {
477 let how = if link.rel == "describedby" { "note" } else if link.rel == "via" { "source" } else { "link" };
478 push(me.clone(), self.canonical(&link.uri), how);
479 }
480 for relation in &event.related {
481 push(me.clone(), self.canonical(&format!("uid:{}", relation.uid)), "link");
482 }
483 for project in &event.projects {
484 push(me.clone(), project_uri(project), "project");
485 }
486 for person in &event.people {
487 if let Some(contact) = crate::contacts::by_address(self.contacts, person) {
488 push(me.clone(), contact_uri(&contact.uid), "contact");
489 }
490 }
491 }
492 if let Some(vault) = self.vault {
493 for note in &vault.notes {
494 let me = note.uri();
495 for link in ¬e.links {
496 let target = self.canonical(&link.target);
497 if matches!(kind_of(&target), Kind::Web | Kind::File | Kind::Other) {
498 continue;
499 }
500 let how = if kind_of(&target) == Kind::Project { "project" } else { "mention" };
501 push(me.clone(), target, how);
502 }
503 }
504 }
505 for draft in self.drafts {
506 let me = draft_uri(&draft.id);
507 for link in &draft.links {
508 push(me.clone(), self.canonical(link), "link");
509 }
510 if let Some(answered) = draft.in_reply_to.as_deref().filter(|id| !id.is_empty()) {
511 push(me.clone(), mail_uri(answered), "answers");
512 }
513 }
514 for line in self.budget {
515 for link in &line.links {
516 push(line.uri.clone(), self.canonical(link), "source");
517 }
518 }
519 for edge in &self.local.links {
520 push(self.canonical(&edge.from), self.canonical(&edge.to), &edge.how);
521 }
522 let mut seen = BTreeSet::new();
523 out.retain(|e| seen.insert((e.from.clone(), e.to.clone(), e.how.clone())));
524 out
525 }
526
527 pub fn related(&self, uri: &str) -> Vec<Related> {
530 let me = self.canonical(uri);
531 let mut out: Vec<Related> = Vec::new();
532 for edge in self.edges() {
533 let (other, how) = if edge.from == me {
534 (edge.to, forward(&edge.how))
535 } else if edge.to == me {
536 (edge.from, inverse(&edge.how))
537 } else {
538 continue;
539 };
540 match out.iter_mut().find(|r| r.uri == other) {
542 Some(row) if closeness(how) < closeness(&row.how) => row.how = how.to_string(),
543 Some(_) => {}
544 None => {
545 let mut found = self.describe(&other);
546 found.how = how.to_string();
547 out.push(found);
548 }
549 }
550 }
551 out.sort_by(|a, b| (a.kind, a.when.map(|w| -w), a.title.to_lowercase()).cmp(&(b.kind, b.when.map(|w| -w), b.title.to_lowercase())));
552 out
553 }
554
555 pub fn search(&self, query: &str, kind: Option<Kind>, except: &str, limit: usize) -> Vec<Related> {
560 let folded = |text: &str| crate::text::fold(text).into_iter().collect::<String>();
561 let typed = folded(query.trim());
562 let words: Vec<&str> = typed.split_whitespace().collect();
563 let matches = |title: &str, detail: &str| {
564 let text = folded(&format!("{title} {detail}"));
565 words.iter().all(|w| text.contains(w))
566 };
567 let want = |k: Kind| kind.is_none_or(|wanted| wanted == k);
568 let mut found: Vec<(Kind, bool, i64, String)> = Vec::new();
570 let mut push = |k: Kind, title: &str, detail: &str, when: i64, uri: String| {
571 if want(k) && matches(title, detail) {
572 found.push((k, !typed.is_empty() && folded(title).starts_with(&typed), when, uri));
573 }
574 };
575 for task in self.tasks {
576 push(Kind::Task, &task.title, &task.list, 0, task_uri(&task.uid));
577 }
578 for event in self.events {
579 push(Kind::Event, &event.summary, "", event.start, event_uri(&event.uid));
580 }
581 for (id, mail) in self.mail.iter() {
582 push(Kind::Mail, &mail.subject, &mail.from, mail.date, mail_uri(id));
583 }
584 for note in self.vault.map_or(&[][..], |v| &v.notes[..]) {
585 push(Kind::Note, ¬e.title, ¬e.path, note.modified, note.uri());
586 }
587 for contact in self.contacts {
588 let emails: Vec<&str> = contact.emails.iter().map(|e| e.value.as_str()).collect();
589 push(Kind::Contact, &contact.name, &format!("{} {}", contact.org, emails.join(" ")), 0, contact_uri(&contact.uid));
590 }
591 for line in self.budget {
592 push(Kind::Budget, &line.title, &line.budget, 0, line.uri.clone());
593 }
594 for site in self.sites {
595 push(Kind::Site, &site.name, &crate::sites::host_of(&site.url), 0, site_uri(&site.id));
596 }
597 for project in self.projects {
598 push(Kind::Project, &project.title, &project.id, 0, project_uri(&project.id));
599 }
600 let except = self.canonical(except);
601 found.retain(|(_, _, _, uri)| *uri != except);
602 found.sort_by(|a, b| (!a.1, std::cmp::Reverse(a.2), a.0).cmp(&(!b.1, std::cmp::Reverse(b.2), b.0)));
603 if words.is_empty() {
605 let mut per_kind: std::collections::BTreeMap<Kind, usize> = Default::default();
606 found.retain(|(k, ..)| {
607 let n = per_kind.entry(*k).or_default();
608 *n += 1;
609 *n <= 6
610 });
611 }
612 found.into_iter().take(limit).map(|(_, _, _, uri)| self.describe(&uri)).collect()
613 }
614
615 pub fn describe(&self, uri: &str) -> Related {
617 let uri = self.canonical(uri);
618 let kind = kind_of(&uri);
619 let id = id_of(&uri);
620 let mut out = Related { uri: uri.clone(), kind, title: id.clone(), detail: String::new(), when: None, key: String::new(), how: String::new(), found: false, program: false };
621 match kind {
622 Kind::Task => {
623 if let Some(task) = self.tasks.iter().find(|t| t.uid == id) {
624 out.title = task.title.clone();
625 out.detail = task.list.clone();
626 out.key = task.key.clone();
627 out.found = true;
628 }
629 }
630 Kind::Event => {
631 if let Some(event) = self.events.iter().find(|e| e.uid == id) {
632 out.title = event.summary.clone();
633 out.when = (event.start > 0).then_some(event.start);
634 out.key = event.key.clone();
635 out.found = true;
636 }
637 }
638 Kind::Mail => {
639 if let Some(mail) = self.mail.get(&uri) {
640 out.title = if mail.subject.trim().is_empty() { id.clone() } else { mail.subject.clone() };
641 out.detail = mail.from.clone();
642 out.when = (mail.date > 0).then_some(mail.date);
643 out.key = mail.path.display().to_string();
644 out.found = true;
645 }
646 }
647 Kind::Draft => {
648 if let Some(draft) = self.drafts.iter().find(|d| d.id == id) {
649 out.title = draft.subject.clone();
650 out.detail = draft.to.join(", ");
651 out.key = draft.id.clone();
652 out.found = true;
653 }
654 }
655 Kind::Note => {
656 if let (Some(vault), Some(path)) = (self.vault, notes::path_of(&uri)) {
657 if let Some(note) = vault.note(&path) {
658 out.title = note.title.clone();
659 out.detail = note.folder().to_string();
660 out.key = vault.root.join(¬e.path).display().to_string();
661 out.found = true;
662 }
663 }
664 }
665 Kind::Contact => {
666 if let Some(contact) = self.contacts.iter().find(|c| c.uid == id) {
667 out.title = contact.name.clone();
668 out.detail = if contact.org.is_empty() { contact.emails.first().map(|e| e.value.clone()).unwrap_or_default() } else { contact.org.clone() };
669 out.key = contact.key.clone();
670 out.found = true;
671 }
672 }
673 Kind::Budget => {
674 if let Some(line) = self.budget.iter().find(|l| l.uri == uri) {
675 out.title = line.title.clone();
676 out.detail = line.budget.clone();
677 out.key = line.uri.clone();
678 out.found = true;
679 }
680 }
681 Kind::Project => {
682 if let Some(project) = self.projects.iter().find(|c| c.id == id) {
683 out.title = project.title.clone();
684 out.detail = project.status.clone().unwrap_or_default();
685 out.key = project.id.clone();
686 out.found = true;
687 }
688 }
689 Kind::Site => {
690 if let Some(site) = self.sites.iter().find(|s| s.id == id) {
691 out.title = site.name.clone();
692 out.detail = crate::sites::host_of(&site.url);
693 out.key = site.id.clone();
694 out.found = true;
695 }
696 }
697 Kind::Paper => {
699 out.title = id.clone();
700 out.key = id.clone();
701 out.found = true;
702 }
703 Kind::Web | Kind::File | Kind::Other => {
704 let file = uri.get(..5).is_some_and(|s| s.eq_ignore_ascii_case("file:"));
705 out.title = if file {
706 notes::decode(uri.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or(&uri))
708 } else {
709 uri.split("://").nth(1).map_or(uri.as_str(), |rest| rest.split('/').next().unwrap_or(rest)).to_string()
710 };
711 out.detail = uri.clone();
712 out.program = file && is_program(¬es::decode(&uri));
715 out.key = match (openable(&uri), out.program) {
717 (false, _) => String::new(),
718 (true, true) => folder_of(&uri),
719 (true, false) => uri.clone(),
720 };
721 out.found = true;
722 }
723 }
724 out
725 }
726}
727
728#[derive(Default)]
730pub struct Loaded {
731 pub tasks: Vec<Task>,
732 pub events: Vec<EventRef>,
733 pub contacts: Vec<Contact>,
734 pub vault: Option<Vault>,
735 pub drafts: Vec<Draft>,
736 pub mail: MailIndex,
737 pub projects: Vec<Project>,
738 pub budget: Vec<BudgetRef>,
739 pub local: LocalLinks,
740 pub sites: Vec<crate::sites::Site>,
741}
742
743#[derive(Debug, Clone, PartialEq, Eq)]
745pub struct Rewrite {
746 pub kind: Kind,
747 pub path: PathBuf,
748 pub text: String,
749}
750
751impl Loaded {
752 fn writable_event(&self, uid: &str) -> Option<PathBuf> {
754 let event = self.events.iter().find(|e| e.uid == uid)?;
755 let path = PathBuf::from(&event.key);
756 crate::vdir::collections(crate::vdir::Kind::Calendars).into_iter().find(|c| path.starts_with(&c.dir)).filter(|c| !c.read_only).map(|_| path)
757 }
758
759 fn tie_line(&self, other: &str, in_task: bool) -> String {
761 use crate::tasks::{ContactRef, Link, contact_line, link_line};
762 let id = id_of(other);
763 match kind_of(other) {
764 Kind::Contact if in_task => contact_line(&ContactRef { name: self.world().describe(other).title, uri: other.to_string() }),
765 Kind::Project => format!("REFID:{}", crate::lines::escape(&id)),
766 Kind::Note => link_line(&Link { uri: other.to_string(), label: String::new(), rel: "describedby".into() }),
767 Kind::Task | Kind::Event => link_line(&Link { uri: format!("uid:{id}"), label: String::new(), rel: "related".into() }),
768 _ => link_line(&Link { uri: other.to_string(), label: String::new(), rel: "related".into() }),
769 }
770 }
771
772 fn holding(&self, holder: &str, other: &str, now: &jiff::Zoned) -> Option<Rewrite> {
775 let id = id_of(holder);
776 match kind_of(holder) {
777 Kind::Task => {
778 let task = self.tasks.iter().find(|t| t.uid == id).filter(|t| !t.read_only)?;
779 let text = std::fs::read_to_string(&task.key).ok()?;
780 let text = crate::tasks::add_lines(&text, &[self.tie_line(other, true)], now).ok()?;
781 Some(Rewrite { kind: Kind::Task, path: PathBuf::from(&task.key), text })
782 }
783 Kind::Event => {
784 let path = self.writable_event(&id)?;
785 let text = std::fs::read_to_string(&path).ok()?;
786 let text = crate::agenda::add_lines(&text, &[self.tie_line(other, false)])?;
787 Some(Rewrite { kind: Kind::Event, path, text })
788 }
789 Kind::Note => {
790 let vault = self.vault.as_ref()?;
791 let path = vault.file(¬es::path_of(holder)?)?;
792 let text = std::fs::read_to_string(&path).ok()?;
793 Some(Rewrite { kind: Kind::Note, path, text: notes::add_front_link(&text, other) })
794 }
795 _ => None,
796 }
797 }
798
799 pub fn tie(&self, a: &str, b: &str, now: &jiff::Zoned) -> Option<Rewrite> {
803 let world = self.world();
804 let (a, b) = (world.canonical(a), world.canonical(b));
805 if a == b {
806 return None;
807 }
808 self.holding(&a, &b, now).or_else(|| self.holding(&b, &a, now))
809 }
810
811 pub fn untie(&self, a: &str, b: &str, now: &jiff::Zoned) -> Vec<Rewrite> {
816 let world = self.world();
817 let (a, b) = (world.canonical(a), world.canonical(b));
818 let points_at = |line: &str, other: &str| -> bool {
819 let value = crate::lines::value(line).trim();
820 match crate::lines::name(line).as_str() {
821 "LINK" if crate::lines::param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("UID")) => world.canonical(&format!("uid:{value}")) == other,
822 "LINK" => world.canonical(value) == other,
823 "CONTACT" => crate::lines::param(line, "ALTREP").is_some_and(|uri| world.canonical(&uri) == other),
824 "REFID" => project_uri(&crate::lines::unescape(value)) == other,
825 _ => false,
826 }
827 };
828 let mut out = Vec::new();
829 for (holder, other) in [(&a, &b), (&b, &a)] {
830 let id = id_of(holder);
831 match kind_of(holder) {
832 Kind::Task => {
833 if let Some(task) = self.tasks.iter().find(|t| t.uid == id).filter(|t| !t.read_only)
834 && let Ok(text) = std::fs::read_to_string(&task.key)
835 && crate::lines::unfold(&text).iter().any(|l| points_at(l, other))
836 && let Ok(less) = crate::tasks::remove_lines(&text, |l| points_at(l, other), now)
837 {
838 out.push(Rewrite { kind: Kind::Task, path: PathBuf::from(&task.key), text: less });
839 }
840 }
841 Kind::Event => {
842 if let Some(path) = self.writable_event(&id)
843 && let Ok(text) = std::fs::read_to_string(&path)
844 && let Some(less) = crate::agenda::remove_lines(&text, |l| points_at(l, other))
845 && less != text
846 {
847 out.push(Rewrite { kind: Kind::Event, path, text: less });
848 }
849 }
850 Kind::Note => {
851 if let Some(vault) = self.vault.as_ref()
852 && let Some(path) = notes::path_of(holder).and_then(|p| vault.file(&p))
853 && let Ok(text) = std::fs::read_to_string(&path)
854 {
855 let (less, removed) = notes::remove_front_link(&text, |v| world.canonical(v) == **other);
856 if removed {
857 out.push(Rewrite { kind: Kind::Note, path, text: less });
858 }
859 }
860 }
861 _ => {}
862 }
863 }
864 out
865 }
866
867 pub fn read(config: &crate::config::Config) -> Loaded {
870 let zone = jiff::tz::TimeZone::system();
871 let root = config.notes_root_path();
872 let mail_roots: Vec<PathBuf> = config.accounts.iter().filter(|a| a.kind == crate::config::AccountKind::Imap).map(|a| a.maildir_path()).collect();
873 let budget = root
874 .as_deref()
875 .and_then(|r| crate::budget::Ledger::load_with_bank(r, &crate::words::Words::of(config)).ok())
876 .map(|ledger| {
877 let budgets: Vec<(String, String)> = ledger.budgets.iter().map(|b| (b.id.clone(), b.title.clone())).collect();
878 ledger
879 .lines
880 .iter()
881 .enumerate()
882 .map(|(i, line)| BudgetRef {
883 uri: budget_uri(&line.budget, i),
884 title: line.label.clone(),
885 budget: budgets.iter().find(|(id, _)| *id == line.budget).map_or_else(|| line.budget.clone(), |(_, t)| t.clone()),
886 date: line.date.to_string(),
887 links: line.links.clone(),
888 })
889 .collect()
890 })
891 .unwrap_or_default();
892 Loaded {
893 tasks: crate::tasks::all(&zone),
894 events: crate::agenda::event_refs(),
895 contacts: crate::contacts::all(),
896 projects: root.as_deref().and_then(|r| crate::projects::ProjectStore::load(r).ok()).map(|s| s.projects).unwrap_or_default(),
897 vault: root.as_deref().map(Vault::open),
898 drafts: Draft::all(),
899 mail: MailIndex::build(&mail_roots, &crate::config::cache_dir().join("mail-index.tsv")),
900 budget,
901 local: LocalLinks::load(&LocalLinks::default_path()),
902 sites: crate::sites::sites(config),
903 }
904 }
905
906 pub fn world(&self) -> World<'_> {
907 World {
908 tasks: &self.tasks,
909 events: &self.events,
910 contacts: &self.contacts,
911 vault: self.vault.as_ref(),
912 drafts: &self.drafts,
913 mail: &self.mail,
914 projects: &self.projects,
915 budget: &self.budget,
916 local: &self.local,
917 sites: &self.sites,
918 }
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::tasks::{ContactRef, Link, Relation};
926
927 #[test]
928 fn ties_go_where_they_can_be_written() {
929 let dir = std::env::temp_dir().join(format!("sioul-ties-{}", std::process::id()));
930 let _ = std::fs::remove_dir_all(&dir);
931 std::fs::create_dir_all(dir.join("vault")).unwrap();
932 let task_file = dir.join("t.ics");
933 std::fs::write(&task_file, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nSUMMARY:Call the CAF\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
934 std::fs::write(dir.join("vault/plan.md"), "# Plan\n").unwrap();
935 let task = crate::tasks::task_of_text(&std::fs::read_to_string(&task_file).unwrap(), &jiff::tz::TimeZone::UTC).map(|mut t| {
936 t.key = task_file.display().to_string();
937 t
938 });
939 let loaded = Loaded {
940 tasks: task.into_iter().collect(),
941 vault: Some(Vault::open(&dir.join("vault"))),
942 contacts: vec![Contact { uid: "jane".into(), name: "Jane".into(), ..Contact::default() }],
943 ..Loaded::default()
944 };
945 let now = jiff::Zoned::now();
946 let tied = loaded.tie("sioul:task/t", "mid:abc@example.org", &now).unwrap();
948 assert_eq!((tied.kind, tied.path.clone()), (Kind::Task, task_file.clone()));
949 assert!(tied.text.contains("LINK;LINKREL=related;VALUE=URI:mid:abc@example.org"), "{}", tied.text);
950 let to_jane = loaded.tie("mid:abc@example.org", "sioul:contact/jane", &now);
951 assert_eq!(to_jane, None, "a message and a contact: the local file");
952 let jane = loaded.tie("sioul:contact/jane", "sioul:task/t", &now).unwrap();
953 assert!(jane.text.contains("CONTACT;ALTREP=\"sioul:contact/jane\":Jane"), "{}", jane.text);
954 let noted = loaded.tie("mid:abc@example.org", "sioul:note/plan.md", &now).unwrap();
956 assert_eq!(noted.kind, Kind::Note);
957 assert!(noted.text.starts_with("---\nlinks:\n - mid:abc@example.org\n---"), "{}", noted.text);
958 std::fs::write(&task_file, &tied.text).unwrap();
960 std::fs::write(¬ed.path, ¬ed.text).unwrap();
961 let loaded = Loaded { vault: Some(Vault::open(&dir.join("vault"))), ..loaded };
962 let undone = loaded.untie("mid:abc@example.org", "sioul:task/t", &now);
963 assert_eq!(undone.len(), 1);
964 assert!(!undone[0].text.contains("mid:abc@example.org"));
965 let undone = loaded.untie("sioul:note/plan.md", "mid:abc@example.org", &now);
966 assert_eq!(undone[0].text, "# Plan\n");
967 let kept = dir.join("links.toml");
969 std::fs::write(&kept, "[[link]]\nfrom = \"mid:a@example.org\"\nto = ").unwrap();
970 let mut local = LocalLinks::load(&kept);
971 local.add("mid:a@example.org", "sioul:project/x", "project");
972 assert!(local.save(&kept).is_err());
973 assert_eq!(std::fs::read_to_string(&kept).unwrap(), "[[link]]\nfrom = \"mid:a@example.org\"\nto = ");
974 std::fs::remove_file(&kept).unwrap();
975 local.save(&kept).unwrap();
976 assert_eq!(LocalLinks::load(&kept), local);
977 let _ = std::fs::remove_dir_all(&dir);
978 }
979
980 #[test]
986 fn two_writers_at_once_lose_nothing() {
987 let dir = std::env::temp_dir().join(format!("sioul-ties-writers-{}", std::process::id()));
988 let _ = std::fs::remove_dir_all(&dir);
989 let (vault_dir, state) = (dir.join("vault"), dir.join("state"));
990 std::fs::create_dir_all(&vault_dir).unwrap();
991 std::fs::write(vault_dir.join("plan.md"), "# Plan\n").unwrap();
992 let kept = dir.join("links.toml");
993 let loaded = Loaded { vault: Some(Vault::open(&vault_dir)), ..Loaded::default() };
994 let now = jiff::Zoned::now();
995 std::thread::scope(|s| {
996 for writer in 0..8 {
997 let (kept, loaded, now, vault_dir, state) = (&kept, &loaded, &now, &vault_dir, &state);
998 s.spawn(move || {
999 for n in 0..25 {
1000 let edge = Edge { from: format!("mid:{writer}-{n}@example.org"), to: "sioul:project/x".into(), how: "project".into() };
1001 assert!(LocalLinks::add_all(kept, &[edge]).unwrap());
1002 }
1003 notes::with_lock_in(state, || {
1005 let change = loaded.tie(&format!("mid:note-{writer}@example.org"), "sioul:note/plan.md", now).unwrap();
1006 assert_eq!(change.kind, Kind::Note);
1007 notes::write(vault_dir, "plan.md", &change.text).unwrap();
1008 });
1009 notes::create_in(state, vault_dir, "", "Meeting", &format!("# Meeting\n\nWriter {writer}\n")).unwrap();
1010 });
1011 }
1012 });
1013 let local = LocalLinks::load(&kept);
1014 assert_eq!(local.links.len(), 200, "every tie kept");
1015 let plan = std::fs::read_to_string(vault_dir.join("plan.md")).unwrap();
1016 for writer in 0..8 {
1017 assert!(plan.contains(&format!("mid:note-{writer}@example.org")), "{plan}");
1018 }
1019 let made: Vec<String> = (0..8).map(|n| if n == 0 { "Meeting.md".to_string() } else { format!("Meeting {}.md", n + 1) }).collect();
1020 let texts: BTreeSet<String> = made.iter().map(|name| std::fs::read_to_string(vault_dir.join(name)).unwrap()).collect();
1021 assert_eq!(texts.len(), 8, "eight notes, none written over: {texts:?}");
1022 assert!(state.join(".notes.lock").exists() && !vault_dir.join(".notes.lock").exists(), "the lock in Sioul's state, never in the notes");
1023 let _ = std::fs::remove_dir_all(&dir);
1024 }
1025
1026 #[test]
1027 fn ties_read_both_ways() {
1028 let certificate = Task {
1029 uid: "certificate".into(),
1030 title: "Ask for the certificate".into(),
1031 key: "/cal/certificate.ics".into(),
1032 links: vec![
1033 Link { uri: "sioul:note/admin/letters.md".into(), label: String::new(), rel: "describedby".into() },
1034 Link { uri: "mid:<abc@caf.example>".into(), label: String::new(), rel: "via".into() },
1035 Link { uri: "uid:meeting".into(), label: String::new(), rel: "related".into() },
1036 ],
1037 relations: vec![Relation { kind: "DEPENDS-ON".into(), uid: "form".into(), gap: 0 }],
1038 contacts: vec![ContactRef { name: "School".into(), uri: "sioul:contact/school".into() }],
1039 projects: vec!["school".into()],
1040 ..Task::default()
1041 };
1042 let form = Task { uid: "form".into(), title: "Fill the form".into(), ..Task::default() };
1043 let meeting = EventRef { uid: "meeting".into(), summary: "Meeting".into(), start: 1_791_000_000, ..EventRef::default() };
1044 let vault = Vault {
1045 root: PathBuf::from("/vault"),
1046 notes: vec.\n"), notes::read("plan.md", "# Plan\n\n[[letters]]\n")],
1047 folders: vec!["admin".into()],
1048 };
1049 let mut local = LocalLinks::default();
1050 local.add("mid:abc@caf.example", "sioul:contact/caf", "contact");
1051 let (tasks, events) = (vec![certificate, form], vec![meeting]);
1052 let world = World { tasks: &tasks, events: &events, contacts: &[], vault: Some(&vault), drafts: &[], mail: &MailIndex::default(), projects: &[], budget: &[], local: &local, sites: &[] };
1053 let related = world.related("sioul:task/certificate");
1054 let seen: Vec<(Kind, &str, &str)> = related.iter().map(|r| (r.kind, r.how.as_str(), r.uri.as_str())).collect();
1055 assert_eq!(
1056 seen,
1057 vec![
1058 (Kind::Task, "waits-for", "sioul:task/form"),
1059 (Kind::Event, "link", "sioul:event/meeting"),
1060 (Kind::Mail, "source", "mid:abc@caf.example"),
1061 (Kind::Note, "note", "sioul:note/admin/letters.md"),
1062 (Kind::Contact, "contact", "sioul:contact/school"),
1063 (Kind::Project, "project", "sioul:project/school"),
1064 ]
1065 );
1066 assert_eq!(world.related("sioul:task/form")[0].how, "unblocks");
1067 let mail: Vec<String> = world.related("mid:<abc@caf.example>").into_iter().map(|r| r.how).collect();
1069 assert_eq!(mail, vec!["made", "contact"]);
1070 assert_eq!(world.describe("sioul:task/form").title, "Fill the form");
1071 assert!(!world.describe("sioul:task/gone").found);
1072 assert_eq!(id_of(&task_uri("a/b c")), "a/b c");
1073 let found: Vec<String> = world.search("meeting", None, "", 10).into_iter().map(|r| r.uri).collect();
1075 assert_eq!(found, vec!["sioul:event/meeting"]);
1076 let found: Vec<String> = world.search("CERTIFICATE ask", Some(Kind::Task), "", 10).into_iter().map(|r| r.uri).collect();
1077 assert_eq!(found, vec!["sioul:task/certificate"]);
1078 assert!(world.search("form", None, "sioul:task/form", 10).is_empty());
1079 assert_eq!(world.search("", None, "", 10).len(), 5, "typed nothing: some of each kind");
1080 assert_eq!(id_of(&task_uri("tâche/1")), "tâche/1");
1082 assert_eq!(world.canonical(¬es::uri_of("Santé/impôts.md")), notes::uri_of("Santé/impôts.md"));
1083 assert_eq!(world.describe("https://example.org/a").key, "https://example.org/a");
1085 assert_eq!(world.describe("file:///home/a/scan.pdf").key, "file:///home/a/scan.pdf");
1086 assert!(world.describe("ms-msdt:/id PCWDiagnostic").key.is_empty() && world.describe("file://attacker.example/share/x").key.is_empty());
1087 let program = world.describe("file:///home/a/Downloads/update%20now.desktop");
1089 assert_eq!((program.title.as_str(), program.key.as_str(), program.program), ("update now.desktop", "file:///home/a/Downloads/", true));
1090 assert!(is_program("C:\\Users\\a\\setup.EXE") && is_program("/Applications/Tool.app/") && !is_program("/home/a/scan.pdf"));
1091 assert!(is_phone_program("/sdcard/Download/App.APK") && is_phone_program("bundle.apks ") && is_phone_program("game.xapk") && is_phone_program("split.apkm"));
1093 assert!(!is_phone_program("apk.pdf") && !is_phone_program("setup.exe") && !is_program("app.apk"));
1094 }
1095
1096 #[test]
1097 fn a_project_written_before_the_name_changed_still_opens() {
1098 assert_eq!(project_uri("taxes 2025"), "sioul:project/taxes%202025");
1100 assert_eq!((kind_of("sioul:case/taxes"), kind_of("sioul:project/taxes")), (Kind::Project, Kind::Project));
1101 assert_eq!(id_of("sioul:case/taxes%202025"), "taxes 2025");
1102 let housing = Project { id: "housing".into(), title: "Housing".into(), ..Project::default() };
1103 let mut local = LocalLinks::default();
1105 local.add("mid:caf-1@caf.example", "sioul:case/housing", "case");
1106 local.add("mid:caf-2@caf.example", "sioul:project/housing", "project");
1107 assert_eq!(mail_projects(&local).into_iter().collect::<Vec<_>>(), [("caf-1@caf.example".to_string(), "housing".to_string()), ("caf-2@caf.example".to_string(), "housing".to_string())]);
1108 let vault = Vault { root: PathBuf::from("/vault"), notes: vec.\n")], folders: Vec::new() };
1110 let tasks = vec![Task { uid: "rent".into(), title: "Pay the rent".into(), projects: vec!["housing".into()], ..Task::default() }];
1111 let projects = vec![housing];
1112 let world = World { tasks: &tasks, events: &[], contacts: &[], vault: Some(&vault), drafts: &[], mail: &MailIndex::default(), projects: &projects, budget: &[], local: &local, sites: &[] };
1113 assert_eq!(world.canonical("sioul:case/housing"), "sioul:project/housing");
1114 for address in ["sioul:case/housing", "sioul:project/housing"] {
1115 let mut tied: Vec<(Kind, String, String)> = world.related(address).into_iter().map(|r| (r.kind, r.how, r.uri)).collect();
1116 tied.sort();
1117 assert_eq!(
1118 tied,
1119 [
1120 (Kind::Task, "in-project".to_string(), "sioul:task/rent".to_string()),
1121 (Kind::Mail, "in-project".to_string(), "mid:caf-1@caf.example".to_string()),
1122 (Kind::Mail, "in-project".to_string(), "mid:caf-2@caf.example".to_string()),
1123 (Kind::Note, "in-project".to_string(), "sioul:note/lease.md".to_string()),
1124 (Kind::Note, "in-project".to_string(), "sioul:note/rent.md".to_string()),
1125 ],
1126 "{address}"
1127 );
1128 assert_eq!(world.describe(address).title, "Housing");
1129 }
1130 assert_eq!(world.related("mid:caf-1@caf.example")[0].how, "project");
1131 let mut kept = local.clone();
1133 assert!(kept.untie(&world, "sioul:project/housing", "mid:caf-1@caf.example"));
1134 assert_eq!(kept.links.len(), 1);
1135 }
1136}