Skip to main content

todoapp_app/
import_sp.rs

1//! Import tasks from a Super Productivity JSON backup export. Only fields
2//! that map onto an existing tda capability are read; the rest of the export
3//! (boards, planner, reminders, standalone notes, taskRepeatCfg templates,
4//! project theming) is ignored. See tda-spec.md FR-27..FR-31 for the
5//! capabilities this relies on.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde::Deserialize;
10use todoapp_core::{
11    Attachment, AttachmentKind, ComponentStore, Date, Due, Duration, Id, IssueRef, Status,
12    TaskEntityStore,
13};
14
15use crate::service::{Error, Services, TaskSnapshot};
16
17#[derive(Deserialize)]
18struct SpExport {
19    data: SpData,
20}
21
22#[derive(Deserialize)]
23struct SpData {
24    task: SpEntityMap<SpTask>,
25    project: SpEntityMap<SpProject>,
26    tag: SpEntityMap<SpTag>,
27    #[serde(rename = "archiveYoung", default)]
28    archive_young: Option<SpArchive>,
29    #[serde(rename = "archiveOld", default)]
30    archive_old: Option<SpArchive>,
31}
32
33#[derive(Deserialize)]
34struct SpEntityMap<T> {
35    entities: HashMap<String, T>,
36}
37
38#[derive(Deserialize)]
39struct SpArchive {
40    task: SpEntityMap<SpTask>,
41}
42
43#[derive(Deserialize)]
44struct SpProject {
45    title: String,
46}
47
48#[derive(Deserialize)]
49struct SpTag {
50    title: String,
51}
52
53#[derive(Deserialize, Default)]
54#[serde(rename_all = "camelCase")]
55struct SpTask {
56    #[serde(default)]
57    parent_id: Option<String>,
58    title: String,
59    #[serde(default)]
60    notes: Option<String>,
61    #[serde(default)]
62    is_done: bool,
63    #[serde(default)]
64    tag_ids: Vec<String>,
65    #[serde(default)]
66    project_id: Option<String>,
67    #[serde(default)]
68    due_day: Option<String>,
69    #[serde(default)]
70    due_with_time: Option<i64>,
71    #[serde(default)]
72    time_estimate: i64,
73    #[serde(default)]
74    time_spent: i64,
75    #[serde(default)]
76    time_spent_on_day: HashMap<String, i64>,
77    #[serde(default)]
78    issue_type: Option<String>,
79    #[serde(default)]
80    issue_id: Option<String>,
81    #[serde(default)]
82    attachments: Vec<SpAttachment>,
83}
84
85#[derive(Deserialize)]
86struct SpAttachment {
87    #[serde(rename = "type", default)]
88    kind: String,
89    #[serde(default)]
90    path: Option<String>,
91    #[serde(default)]
92    title: Option<String>,
93}
94
95/// ms → whole minutes, saturating (Duration is minute-precision).
96fn minutes(ms: i64) -> u32 {
97    u32::try_from(ms / 60_000).unwrap_or(0)
98}
99
100/// SP's `dueWithTime` is an epoch-ms instant; format it through the local
101/// wall-clock so the due time-of-day matches what the user saw in SP.
102fn ms_to_due(ms: i64) -> Option<Due> {
103    let ts = jiff::Timestamp::from_millisecond(ms).ok()?;
104    let dt = ts.to_zoned(jiff::tz::TimeZone::system()).datetime();
105    Due::parse(&format!(
106        "{:04}-{:02}-{:02} {:02}:{:02}",
107        dt.year(),
108        dt.month(),
109        dt.day(),
110        dt.hour(),
111        dt.minute()
112    ))
113    .ok()
114}
115
116impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
117    /// Import a Super Productivity JSON backup. Returns the created project
118    /// root tasks (one per SP project, per user decision — not a tag).
119    pub async fn import_superproductivity(&self, json: &str) -> Result<Vec<TaskSnapshot>, Error> {
120        let export: SpExport =
121            serde_json::from_str(json).map_err(|e| Error::Import(e.to_string()))?;
122        let data = export.data;
123
124        let tag_titles: HashMap<String, String> = data
125            .tag
126            .entities
127            .into_iter()
128            .map(|(id, t)| (id, t.title))
129            .collect();
130
131        // One top-level task per SP project; every task in that project
132        // attaches under it (§B.2 decision: parent task, not a tag).
133        let mut project_tda: HashMap<String, Id> = HashMap::new();
134        let mut roots = Vec::new();
135        for (pid, project) in data.project.entities {
136            let root = self.create(project.title, None, Status::Todo, []).await?;
137            project_tda.insert(pid, root.id.clone());
138            roots.push(root);
139        }
140
141        // Flatten active + archived tasks into one map (archived ones marked).
142        let mut all_tasks: HashMap<String, (SpTask, bool)> = HashMap::new();
143        for (id, t) in data.task.entities {
144            all_tasks.insert(id, (t, false));
145        }
146        for archive in [data.archive_young, data.archive_old].into_iter().flatten() {
147            for (id, t) in archive.task.entities {
148                all_tasks.insert(id, (t, true));
149            }
150        }
151
152        // Pass 1: write every task's components, minting a fresh tda `Id`.
153        let now = self.clock.now();
154        let mut sp_to_tda: HashMap<String, Id> = HashMap::new();
155        for sp_id in all_tasks.keys() {
156            sp_to_tda.insert(sp_id.clone(), self.ids.next_id());
157        }
158        for (sp_id, (t, archived)) in &all_tasks {
159            let todoapp_id = sp_to_tda[sp_id].clone();
160
161            let tags: BTreeSet<String> = t
162                .tag_ids
163                .iter()
164                .filter_map(|tid| tag_titles.get(tid).cloned())
165                .collect();
166
167            let mut notes = t.notes.clone();
168            if let Some(issue_id) = &t.issue_id {
169                let provider = t.issue_type.clone().unwrap_or_default();
170                let line = format!("\n\n_Imported from Super Productivity: {provider}#{issue_id}_");
171                notes = Some(notes.unwrap_or_default() + &line);
172            }
173
174            let due_date = t
175                .due_with_time
176                .and_then(ms_to_due)
177                .or_else(|| t.due_day.as_deref().and_then(|d| Due::parse(d).ok()));
178
179            let issue_ref = t.issue_id.as_ref().map(|id| IssueRef {
180                provider: t.issue_type.clone().unwrap_or_default(),
181                id: id.clone(),
182                url: None,
183            });
184
185            let time_log: BTreeMap<Date, Duration> = t
186                .time_spent_on_day
187                .iter()
188                .filter_map(|(d, ms)| {
189                    Date::parse(d)
190                        .ok()
191                        .map(|date| (date, Duration::from_minutes(minutes(*ms))))
192                })
193                .collect();
194
195            let mut attachments = Vec::new();
196            for sp_att in &t.attachments {
197                attachments.push(self.to_attachment(sp_att).await);
198            }
199
200            let snapshot = TaskSnapshot {
201                id: todoapp_id,
202                title: t.title.clone(),
203                status: if t.is_done || *archived {
204                    Status::Done
205                } else {
206                    Status::Todo
207                },
208                notes,
209                due_date,
210                eta_minutes: (t.time_estimate > 0)
211                    .then(|| Duration::from_minutes(minutes(t.time_estimate))),
212                time_spent_minutes: Duration::from_minutes(minutes(t.time_spent)),
213                tags,
214                assignments: vec![],
215                recurrence: None,
216                issue_ref,
217                time_log,
218                archived: *archived,
219                attachments,
220                created_at: now,
221                updated_at: now,
222            };
223            self.write_snapshot(&snapshot).await;
224        }
225
226        // Pass 2: attach each task under its resolved parent — its own
227        // `parentId` if set, else its project's root task, else the tree root.
228        for (sp_id, (t, _)) in &all_tasks {
229            let todoapp_id = &sp_to_tda[sp_id];
230            let parent = t
231                .parent_id
232                .as_ref()
233                .and_then(|pid| sp_to_tda.get(pid))
234                .or_else(|| t.project_id.as_ref().and_then(|pid| project_tda.get(pid)))
235                .cloned()
236                .unwrap_or_else(Id::root);
237            self.attach(todoapp_id, &parent, None).await?;
238        }
239
240        Ok(roots)
241    }
242
243    /// Map one SP attachment reference; for `FILE`/`IMG`, opportunistically
244    /// read the referenced path's bytes if it still resolves on this machine
245    /// (SP's export JSON never embeds the bytes itself) — best-effort, never
246    /// fails the import if the file is missing.
247    async fn to_attachment(&self, sp: &SpAttachment) -> Attachment {
248        let kind = match sp.kind.as_str() {
249            "FILE" => AttachmentKind::File,
250            "IMG" => AttachmentKind::Image,
251            _ => AttachmentKind::Link,
252        };
253        let blob = if kind == AttachmentKind::Link {
254            None
255        } else {
256            match &sp.path {
257                Some(path) => match std::fs::read(path) {
258                    Ok(bytes) => Some(self.blobs.put(bytes).await),
259                    Err(_) => None,
260                },
261                None => None,
262            }
263        };
264        Attachment {
265            id: self.ids.next_id(),
266            kind,
267            title: sp.title.clone().unwrap_or_default(),
268            url: sp.path.clone(),
269            blob,
270            mime: None,
271        }
272    }
273}