Skip to main content

sioul_core/
capabilities.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What each kind of server keeps, so that moving a task or a contact never
5//! loses something silently, and so that what a server cannot do shows greyed
6//! with its reason rather than hidden (docs/google.md).
7//!
8//! A CalDAV or CardDAV server keeps everything Sioul writes. Google keeps
9//! less: its CalDAV holds events but no tasks and makes no calendar; its
10//! CardDAV writes vCard 3.0 and drops labels and birthdays without a year;
11//! Google Tasks keeps a title, notes, a status, a date and one level of steps.
12
13use crate::config::{Account, AccountKind};
14use crate::tasks::Task;
15use crate::vdir::{Collection, Kind};
16
17/// Who keeps a collection.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Provider {
20    /// This computer only.
21    Local,
22    /// A CalDAV/CardDAV server that keeps what it is given.
23    Dav,
24    /// Google, over its CalDAV and CardDAV.
25    Google,
26    /// Google Tasks, over its own API.
27    GoogleTasks,
28}
29
30/// Who keeps an account's collections.
31pub fn provider_of(account: Option<&Account>) -> Provider {
32    match account {
33        None => Provider::Local,
34        Some(a) if a.auth.as_deref() == Some("google") => Provider::Google,
35        Some(a) if a.host.as_deref().is_some_and(|h| h.ends_with("googleusercontent.com") || h.ends_with("googleapis.com") || h == "www.google.com") => Provider::Google,
36        Some(a) if a.kind == AccountKind::Dav => Provider::Dav,
37        Some(_) => Provider::Local,
38    }
39}
40
41/// Who keeps a collection: a Google account's task lists are Google Tasks'.
42pub fn provider_of_collection(account: Option<&Account>, collection: &Collection) -> Provider {
43    match provider_of(account) {
44        Provider::Google if collection.kind == Kind::Calendars && collection.components.iter().any(|c| c.eq_ignore_ascii_case("VTODO")) => Provider::GoogleTasks,
45        provider => provider,
46    }
47}
48
49/// The task form's fields a list kept by `provider` does not keep: shown
50/// greyed, with why. "steps" when the task is a step already (one level only).
51pub fn task_fields_lost(provider: Provider, is_step: bool) -> Vec<&'static str> {
52    if provider != Provider::GoogleTasks {
53        return Vec::new();
54    }
55    let mut lost = vec!["start", "estimate", "project", "kind", "office", "categories", "billable", "energy", "repeat", "waits", "margins", "costs"];
56    if is_step {
57        lost.push("steps");
58    }
59    lost
60}
61
62/// Whether a provider can make, rename and delete a calendar or an address
63/// book; Google Tasks makes task lists.
64pub fn makes_collections(provider: Provider) -> bool {
65    !matches!(provider, Provider::Google)
66}
67
68/// What a task uses that a list kept by `provider` would not keep, as words'
69/// keys ("waits", "repeat"…): what to say before moving it there.
70pub fn task_losses(task: &Task, to: Provider) -> Vec<&'static str> {
71    if to != Provider::GoogleTasks {
72        return Vec::new();
73    }
74    let mut lost = Vec::new();
75    let mut add = |used: bool, what: &'static str| {
76        if used {
77            lost.push(what);
78        }
79    };
80    add(task.relations.iter().any(|r| !r.kind.is_empty() && !r.kind.eq_ignore_ascii_case("PARENT") && !r.kind.eq_ignore_ascii_case("CHILD")), "waits");
81    add(!task.start.is_empty(), "start");
82    add(task.due.contains('T'), "due-time");
83    add(!task.repeat.is_empty(), "repeat");
84    add(task.priority > 0, "priority");
85    add(!task.kind.is_empty() || task.office_hours, "kind");
86    add(task.estimate > 0, "estimate");
87    add(!task.links.is_empty(), "links");
88    add(!task.contacts.is_empty(), "contacts");
89    add(!task.categories.is_empty(), "categories");
90    add(task.billable.is_some(), "billable");
91    add(!task.energy.is_empty(), "energy");
92    add(!task.margins.is_empty(), "margins");
93    add(task.demands != crate::demands::Demands::default(), "costs");
94    lost
95}
96
97/// What a contact's card uses that an address book kept by `provider` would
98/// not keep: Google writes vCard 3.0, and drops labels, birthdays without a
99/// year, and what only vCard 4.0 has.
100pub fn contact_losses(card: &str, to: Provider) -> Vec<&'static str> {
101    if to != Provider::Google {
102        return Vec::new();
103    }
104    let lines: Vec<String> = crate::lines::unfold(card).into_iter().map(|l| l.to_ascii_uppercase()).collect();
105    let has = |test: &dyn Fn(&str) -> bool| lines.iter().any(|l| test(l));
106    let mut lost = Vec::new();
107    if has(&|l| l.contains("X-ABLABEL")) {
108        lost.push("labels");
109    }
110    if has(&|l| l.starts_with("BDAY") && l.split_once(':').is_some_and(|(_, v)| v.starts_with("--"))) {
111        lost.push("birthday-no-year");
112    }
113    if has(&|l| ["KIND", "GENDER", "ANNIVERSARY", "MEMBER", "RELATED", "LANG"].iter().any(|p| l.starts_with(&format!("{p}:")) || l.starts_with(&format!("{p};")))) {
114        lost.push("vcard4");
115    }
116    lost
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn google_keeps_less() {
125        let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane\r\nitem1.EMAIL:jane@example.org\r\nitem1.X-ABLabel:Studio\r\nBDAY:--0412\r\nGENDER:F\r\nEND:VCARD\r\n";
126        assert_eq!(contact_losses(card, Provider::Google), vec!["labels", "birthday-no-year", "vcard4"]);
127        assert!(contact_losses(card, Provider::Dav).is_empty());
128        let task = Task { repeat: "weekly".into(), estimate: 30, ..Task::default() };
129        assert_eq!(task_losses(&task, Provider::GoogleTasks), vec!["repeat", "estimate"]);
130        assert!(task_losses(&task, Provider::Dav).is_empty());
131        assert!(!makes_collections(Provider::Google) && makes_collections(Provider::Dav) && makes_collections(Provider::GoogleTasks));
132        assert!(task_fields_lost(Provider::GoogleTasks, true).contains(&"steps") && task_fields_lost(Provider::Dav, true).is_empty());
133    }
134}