1use 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
95fn minutes(ms: i64) -> u32 {
97 u32::try_from(ms / 60_000).unwrap_or(0)
98}
99
100fn 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 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 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 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 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 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 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}