Skip to main content

sioul_core/
links.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Links between everything: mail, tasks, events, contacts, notes, drafts,
5//! budget lines and projects.
6//!
7//! Each link lives in the thing that makes it, in that thing's own format,
8//! wherever one can: in a task or an event (LINK, RELATED-TO, CONTACT, REFID:
9//! CalDAV carries them to every device and other applications keep them), in
10//! a note (a Markdown link or its front matter), in a draft (its `links`), in
11//! a budget line (its `links`). What none of them can hold goes in a small
12//! local file, `links.toml`: a message is never changed, a shared calendar may
13//! be read-only. This module gathers both directions: what a thing points at,
14//! and what points at it.
15//!
16//! Things are named by addresses, one scheme per kind:
17//! - a message: `mid:<Message-ID>` (RFC 2392);
18//! - a task, an event, a contact: `sioul:task/<UID>`, `sioul:event/<UID>`, `sioul:contact/<UID>`;
19//! - a note: `sioul:note/<path in the notes folder>`;
20//! - a draft: `sioul:draft/<id>`; a paper: `sioul:paper/<id>`;
21//! - a project: `sioul:project/<id>`; one written before the name changed,
22//!   `sioul:case/<id>`, still reads as the same project, and always will;
23//! - a budget line: `sioul:budget/<budget>/<position in the file>`;
24//! - anything else: its URL.
25
26use 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/// What a link points at.
38#[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    /// A site kept in Sioul's Sites: a bank, a tax office, a chat.
51    Site,
52    Web,
53    File,
54    Other,
55}
56
57/// The kind of thing an address names.
58pub 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            // A project's address as Sioul wrote it before the name changed.
71            "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
89/// A UID or an id inside an address: "/" and "%" and spaces percent-encoded,
90/// letters of every language kept as they are, so `id_of` gives it back.
91fn 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
105/// Whether the system may be asked to open an address read from a file
106/// (someone else's task or event, a note): the web, mail, a phone, a map, a
107/// file on this computer. Not a share of another computer ("file://host/…",
108/// which Windows reaches with your credentials), nor a scheme that starts a
109/// program ("ms-msdt:", "search-ms:"): those are shown, not opened. The
110/// window should ask it too before `Qt.openUrlExternally`.
111pub 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        // "file:/…", "file:///…", "file://localhost/…", and "file://C:\…" as Sioul wrote it on Windows.
116        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
121/// What the desktop would start rather than show, by its extension: programs,
122/// scripts, installers, shortcuts, disk images (the list attachments are kept from).
123const PROGRAMS: &[&str] = &[
124    // Windows
125    "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    // macOS
127    "app", "command", "terminal", "tool", "pkg", "mpkg", "dmg", "workflow", "scpt", "scptd", "applescript", "fileloc", "webloc", "inetloc",
128    // Linux
129    "desktop", "sh", "run", "appimage", "deb", "rpm", "flatpakref", "flatpakrepo", "snap",
130];
131
132/// Android's installers, which a phone would offer to install rather than
133/// show: an app, and the bundles of split apps other stores pass around.
134const PHONE_PROGRAMS: &[&str] = &["apk", "apks", "apkm", "xapk"];
135
136/// Whether a file of this name (or path, or address) is one the desktop would start.
137pub fn is_program(name: &str) -> bool {
138    extension_in(name, PROGRAMS)
139}
140
141/// Whether a file of this name is one of Android's installers: on a phone,
142/// kept from opening as programs are (`is_program`).
143pub fn is_phone_program(name: &str) -> bool {
144    extension_in(name, PHONE_PROGRAMS)
145}
146
147/// Whether a name's extension, in any case, is one of `list`; the dots,
148/// spaces and slashes a name may end with left aside.
149fn 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
155/// The folder a file's address is in: "file:///home/a/" for "file:///home/a/run.sh".
156fn 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
181/// A budget line by its budget and its position in the file.
182pub fn budget_uri(budget: &str, position: usize) -> String {
183    format!("sioul:budget/{}/{position}", encode(budget))
184}
185
186/// A site of Sioul's Sites, by its id.
187pub fn site_uri(id: &str) -> String {
188    format!("sioul:site/{}", encode(id))
189}
190
191/// A project, by its id. An address written before the name changed,
192/// `sioul:case/<id>`, reads as the same project (`kind_of`, `World::canonical`).
193pub fn project_uri(id: &str) -> String {
194    format!("sioul:project/{}", encode(id))
195}
196
197/// `mid:` and the Message-ID, without its angle brackets (RFC 2392).
198pub fn mail_uri(message_id: &str) -> String {
199    format!("mid:{}", encode(&bare_id(message_id)))
200}
201
202/// The id an address of a kind carries: "sioul:task/abc" → "abc".
203pub 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/// A tie from one thing to another, and how.
210#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
211pub struct Edge {
212    pub from: String,
213    pub to: String,
214    /// "note" (its notes), "source" (made from), "waits" (waits for), "step"
215    /// (a step of), "contact", "project" ("case" in ties written before the
216    /// name changed), "mention" (a note names it),
217    /// "answers" (a reply), "link".
218    #[serde(default)]
219    pub how: String,
220}
221
222/// Links Sioul keeps itself, for what cannot hold them.
223#[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    /// `$XDG_DATA_HOME/sioul/links.toml`.
231    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        // A file there that does not read as ties (half written, edited by hand) is
242        // left alone: `load` gave it as empty, and written over, every tie kept here would go.
243        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    /// The ties kept at `path` changed by `f`, under the file's lock
259    /// (`filelock`, the one the sharing takes to write this file), read again
260    /// once it is held: the window, an agent (`sioul mcp`) and the sharing
261    /// writing at once never write an older copy over another's tie. `f` says
262    /// whether it changed anything; the file is written only then. Returns
263    /// what `f` said. Every change to the file goes through here, never
264    /// `load` then `save` (which hold no lock: one held inside would wait for itself).
265    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    /// Ties added to those kept at `path`, each once (`change`); whether any was new.
276    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    /// Adds a link once.
290    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    /// Removes the links between two things, either way.
298    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    /// Takes out every tie between `a` and `b`, however each end was written;
303    /// returns whether there was one.
304    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
315/// The messages tied to a project, by bare Message-ID, and the project's id.
316pub 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/// A budget line, as links see it.
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct BudgetRef {
331    /// `sioul:budget/<budget>/<position>`.
332    pub uri: String,
333    /// Its label and amount, as the budget page writes them.
334    pub title: String,
335    pub budget: String,
336    pub date: String,
337    pub links: Vec<String>,
338}
339
340/// One thing tied to the one open, as the Related panel shows it.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
342pub struct Related {
343    pub uri: String,
344    pub kind: Kind,
345    pub title: String,
346    /// Who, which list, which folder: names only, no sentence.
347    pub detail: String,
348    /// Its date, Unix seconds, when it has one (a message, an event, a deadline).
349    pub when: Option<i64>,
350    /// What the window opens: a file, a draft's id, an address.
351    pub key: String,
352    /// How it is tied, seen from the thing open: "note", "notes-of", "source",
353    /// "made", "waits-for", "unblocks", "part-of", "step", "contact",
354    /// "involves", "project", "in-project", "mentions", "mentioned-by", "answers",
355    /// "answered-by", "link".
356    pub how: String,
357    /// Still there.
358    pub found: bool,
359    /// A program, a script or an installer on this computer: `key` is its folder, never it.
360    pub program: bool,
361}
362
363/// How a tie reads from its other end.
364fn 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
378/// How a tie reads from where it starts.
379fn 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
393/// Which tie says most, when two things are tied twice: smaller first.
394fn 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
399/// Everything Sioul knows, to follow links both ways.
400pub 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    /// One address for one thing, however it was written: `uid:` made a task's
415    /// or an event's, a message's id bare, a note's path decoded and without its
416    /// heading, a project's `sioul:case/` (its address before the name changed) `sioul:project/`.
417    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            // A project's address as written before the name changed: the same project.
433            let kind = if kind == "case" { "project" } else { kind };
434            return format!("sioul:{kind}/{}", encode(&id));
435        }
436        uri.to_string()
437    }
438
439    /// Every tie, from every place that holds them.
440    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 &note.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    /// What is tied to `uri`, both ways, each once: tasks first, then events,
528    /// mail, drafts, notes, contacts, budget lines, projects.
529    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            // One row per thing: the closest tie says how.
541            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    /// Things to link to: those whose title or detail holds every word typed
556    /// (case and accents aside), of one kind or all, `except` left out. Titles
557    /// starting with what was typed come first, then the newest. Typed nothing:
558    /// the newest few of each kind.
559    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        // (kind, starts with what was typed, date, address) for each match.
569        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, &note.title, &note.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        // Typed nothing: a few of each kind rather than a flood of one.
604        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    /// A thing by its address: its title, a detail, a date, and what opens it.
616    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(&note.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            // A paper of the wallet: named by its link's label where the item gives one.
698            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                    // A file by its name, not by its host (which a file has none of).
707                    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                // A program, a script, an installer is never started from a link (a synced
713                // task's is someone else's word): its folder opens instead, to start it from there.
714                out.program = file && is_program(&notes::decode(&uri));
715                // What opens it (`openThing` hands the key to the system): only what is safe to.
716                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/// Everything links reach, read from disk.
729#[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/// A file to write so that a tie is made or undone: the thing that holds it, and its new text.
744#[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    /// The file of an event whose calendar can be written.
753    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    /// The line a task or an event holds to point at `other`.
760    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    /// `holder` with a tie to `other`, when it can hold one: a task of a list
773    /// that can be written, an event of such a calendar, a note.
774    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(&notes::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    /// Where a tie between `a` and `b` is written: in `a` when it can hold
800    /// it, else in `b`; None when neither can (a message, a read-only
801    /// calendar): it then goes in the local file.
802    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    /// What undoes a tie between `a` and `b`, wherever it is written: a
812    /// task's LINK, CONTACT or REFID, an event's LINK, a note's front matter.
813    /// The local file is the caller's (`LocalLinks::untie`). A step, a wait,
814    /// a guest or a link in a note's text stay: they are changed where they are.
815    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    /// Reads every task, event, contact, draft and link, the notes of the notes
868    /// folder, and the headers of the mail kept for the configured accounts.
869    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        // Into the task: it syncs to the phone.
947        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        // Into the note when the task is not one of the two.
955        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        // Undone where written.
959        std::fs::write(&task_file, &tied.text).unwrap();
960        std::fs::write(&noted.path, &noted.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        // Ties kept here: a file that does not read is never written over with the one tie added.
968        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    /// Item 13 of 9 October: the window and an agent writing at once. Eight
981    /// writers tie things at the same moment, in the file of ties kept here,
982    /// into one note's front matter, and make a note of the same title: each
983    /// reads the file again under its lock, so no tie and no note is lost,
984    /// and no two notes take one name.
985    #[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                    // The note read again under the notes' lock, then written.
1004                    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![notes::read("admin/letters.md", "# Outbox\n\nSee [the task](sioul:task/certificate).\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        // The mail knows the task it made, and the contact kept locally.
1068        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        // Searching: words anywhere, accents aside, the thing itself left out.
1074        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        // Accented ids and notes keep their address: read back as they were, the same once made canonical.
1081        assert_eq!(id_of(&task_uri("tâche/1")), "tâche/1");
1082        assert_eq!(world.canonical(&notes::uri_of("Santé/impôts.md")), notes::uri_of("Santé/impôts.md"));
1083        // Handed to the system only when safe: the web, a file here; not a program's scheme, nor another computer's share.
1084        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        // A program on this computer, from a synced task: shown by its name, its folder opens, never it.
1088        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        // Android's installers: a phone's list, apart; a computer's is as it was.
1092        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        // New addresses say "project"; the first form, "case", reads as the same project.
1099        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        // Ties kept here before: a message tied with the old address and the old word.
1104        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        // A note's front matter naming it the old way, and a task by its REFID.
1109        let vault = Vault { root: PathBuf::from("/vault"), notes: vec![notes::read("lease.md", "---\ncase: housing\n---\n# Lease\n"), notes::read("rent.md", "# Rent\n\nFor [housing](sioul:project/housing).\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        // Untied, however the tie was written.
1132        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}