1use std::{collections::BTreeMap, time::Duration};
2
3use base64::{Engine, engine::general_purpose::STANDARD};
4use reqwest::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
5use secrecy::{ExposeSecret, SecretString};
6use serde::{Deserialize, Serialize};
7use tokio::io::AsyncRead;
8use tokio_util::io::ReaderStream;
9
10use super::{ClientError, WekanClient, require_non_empty};
11use crate::client::imports::PreparedImportRequest;
12use crate::client::transport::decode_success;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ImportSource {
17 Wekan,
18 Trello,
19 Csv,
20 Excel,
21 Jira,
22 Kanboard,
23 Deck,
24 Openproject,
25 Github,
26 Gitlab,
27 Gitea,
28 Forgejo,
29 Asana,
30 Zenkit,
31 Markdown,
32 Leo,
33 Opml,
34 Orgmode,
35 Todotxt,
36 Taskwarrior,
37 Focalboard,
38 Todoist,
39 Planner,
40 Meistertask,
41 Obsidian,
42 Linear,
43 Ticktick,
44 Clickup,
45 Nullboard,
46 Kanri,
47 Pivotal,
48 Redmine,
49 Tasksorg,
50 Monday,
51 Superproductivity,
52 Taiga,
53 Vikunja,
54 Wrike,
55 Teamwork,
56 Businessmap,
57 Quire,
58 Notion,
59 Plane,
60 Planka,
61 Mstodo,
62 Kanbanflow,
63 Basecamp,
64 Googletasks,
65 Googlekeep,
66}
67
68impl ImportSource {
69 pub const ALL: [Self; 49] = [
70 Self::Wekan,
71 Self::Trello,
72 Self::Csv,
73 Self::Excel,
74 Self::Jira,
75 Self::Kanboard,
76 Self::Deck,
77 Self::Openproject,
78 Self::Github,
79 Self::Gitlab,
80 Self::Gitea,
81 Self::Forgejo,
82 Self::Asana,
83 Self::Zenkit,
84 Self::Markdown,
85 Self::Leo,
86 Self::Opml,
87 Self::Orgmode,
88 Self::Todotxt,
89 Self::Taskwarrior,
90 Self::Focalboard,
91 Self::Todoist,
92 Self::Planner,
93 Self::Meistertask,
94 Self::Obsidian,
95 Self::Linear,
96 Self::Ticktick,
97 Self::Clickup,
98 Self::Nullboard,
99 Self::Kanri,
100 Self::Pivotal,
101 Self::Redmine,
102 Self::Tasksorg,
103 Self::Monday,
104 Self::Superproductivity,
105 Self::Taiga,
106 Self::Vikunja,
107 Self::Wrike,
108 Self::Teamwork,
109 Self::Businessmap,
110 Self::Quire,
111 Self::Notion,
112 Self::Plane,
113 Self::Planka,
114 Self::Mstodo,
115 Self::Kanbanflow,
116 Self::Basecamp,
117 Self::Googletasks,
118 Self::Googlekeep,
119 ];
120 pub fn as_str(self) -> &'static str {
121 match self {
122 Self::Wekan => "wekan",
123 Self::Trello => "trello",
124 Self::Csv => "csv",
125 Self::Excel => "excel",
126 Self::Jira => "jira",
127 Self::Kanboard => "kanboard",
128 Self::Deck => "deck",
129 Self::Openproject => "openproject",
130 Self::Github => "github",
131 Self::Gitlab => "gitlab",
132 Self::Gitea => "gitea",
133 Self::Forgejo => "forgejo",
134 Self::Asana => "asana",
135 Self::Zenkit => "zenkit",
136 Self::Markdown => "markdown",
137 Self::Leo => "leo",
138 Self::Opml => "opml",
139 Self::Orgmode => "orgmode",
140 Self::Todotxt => "todotxt",
141 Self::Taskwarrior => "taskwarrior",
142 Self::Focalboard => "focalboard",
143 Self::Todoist => "todoist",
144 Self::Planner => "planner",
145 Self::Meistertask => "meistertask",
146 Self::Obsidian => "obsidian",
147 Self::Linear => "linear",
148 Self::Ticktick => "ticktick",
149 Self::Clickup => "clickup",
150 Self::Nullboard => "nullboard",
151 Self::Kanri => "kanri",
152 Self::Pivotal => "pivotal",
153 Self::Redmine => "redmine",
154 Self::Tasksorg => "tasksorg",
155 Self::Monday => "monday",
156 Self::Superproductivity => "superproductivity",
157 Self::Taiga => "taiga",
158 Self::Vikunja => "vikunja",
159 Self::Wrike => "wrike",
160 Self::Teamwork => "teamwork",
161 Self::Businessmap => "businessmap",
162 Self::Quire => "quire",
163 Self::Notion => "notion",
164 Self::Plane => "plane",
165 Self::Planka => "planka",
166 Self::Mstodo => "mstodo",
167 Self::Kanbanflow => "kanbanflow",
168 Self::Basecamp => "basecamp",
169 Self::Googletasks => "googletasks",
170 Self::Googlekeep => "googlekeep",
171 }
172 }
173 pub fn is_text(self) -> bool {
174 matches!(
175 self,
176 Self::Markdown
177 | Self::Leo
178 | Self::Opml
179 | Self::Orgmode
180 | Self::Todotxt
181 | Self::Taskwarrior
182 | Self::Focalboard
183 | Self::Todoist
184 | Self::Meistertask
185 | Self::Obsidian
186 | Self::Linear
187 | Self::Ticktick
188 | Self::Clickup
189 | Self::Nullboard
190 | Self::Pivotal
191 | Self::Redmine
192 | Self::Superproductivity
193 | Self::Vikunja
194 | Self::Quire
195 | Self::Notion
196 | Self::Plane
197 )
198 }
199 pub fn is_workbook(self) -> bool {
200 matches!(
201 self,
202 Self::Excel
203 | Self::Planner
204 | Self::Monday
205 | Self::Wrike
206 | Self::Teamwork
207 | Self::Businessmap
208 )
209 }
210}
211
212impl std::str::FromStr for ImportSource {
213 type Err = &'static str;
214 fn from_str(value: &str) -> Result<Self, Self::Err> {
215 Self::ALL
216 .into_iter()
217 .find(|source| source.as_str() == value)
218 .ok_or("unsupported import source")
219 }
220}
221
222#[derive(Serialize)]
223#[serde(rename_all = "camelCase")]
224pub struct BoardImportRequest {
225 pub board: serde_json::Value,
226 #[serde(flatten)]
227 pub options: BoardImportOptions,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub members_mapping: Option<BTreeMap<String, String>>,
230}
231
232impl BoardImportRequest {
233 pub fn excel(bytes: &[u8], members_mapping: Option<BTreeMap<String, String>>) -> Self {
234 Self {
235 board: serde_json::json!({"excelBase64": STANDARD.encode(bytes)}),
236 members_mapping,
237 options: BoardImportOptions::default(),
238 }
239 }
240
241 pub fn xlsx(bytes: &[u8], members_mapping: Option<BTreeMap<String, String>>) -> Self {
242 Self {
243 board: serde_json::json!({"xlsxBase64": STANDARD.encode(bytes)}),
244 members_mapping,
245 options: BoardImportOptions::default(),
246 }
247 }
248
249 pub fn zip(bytes: &[u8], members_mapping: Option<BTreeMap<String, String>>) -> Self {
250 Self {
251 board: serde_json::json!({"zipBase64": STANDARD.encode(bytes)}),
252 members_mapping,
253 options: BoardImportOptions::default(),
254 }
255 }
256}
257
258#[derive(Default, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct BoardImportOptions {
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub split_by: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub members_mode: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub csv_mapping: Option<CsvImportMapping>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub attachment_auth: Option<ImportAttachmentAuth>,
270}
271#[derive(Deserialize, Serialize)]
272#[serde(deny_unknown_fields, rename_all = "camelCase")]
273pub struct CsvImportMapping {
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub columns: Option<BTreeMap<String, u16>>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub list_name: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub custom_field_columns: Option<Vec<u16>>,
280}
281#[derive(Deserialize, Serialize)]
282#[serde(deny_unknown_fields)]
283pub struct ImportAttachmentAuth {
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub url: Option<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub username: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub token: Option<crate::client::admin::StorageSecret>,
290}
291
292#[derive(Debug, Deserialize)]
293#[serde(deny_unknown_fields)]
294pub struct BoardImportResponse {
295 #[serde(rename = "_id")]
296 pub board_id: String,
297}
298
299#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
300#[serde(deny_unknown_fields)]
301pub struct ZipImportCounts {
302 pub swimlanes: u64,
303 pub lists: u64,
304 pub cards: u64,
305 pub checklists: u64,
306 pub comments: u64,
307 pub attachments: u64,
308}
309
310#[derive(Debug, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct ZipImportResponse {
313 pub ok: bool,
314 pub counts: ZipImportCounts,
315}
316
317#[derive(Debug, Deserialize)]
318#[serde(deny_unknown_fields, rename_all = "camelCase")]
319pub struct NewBoardZipImportResponse {
320 pub ok: bool,
321 pub board_id: String,
322}
323
324#[derive(Clone, Debug, Default, Eq, PartialEq)]
325pub struct NewBoardZipImportTarget {
326 pub fields: Vec<String>,
327 pub members_mode: Option<String>,
328}
329
330enum ZipDestination<'a> {
331 Existing(&'a ZipImportTarget),
332 New(&'a NewBoardZipImportTarget),
333}
334
335#[derive(Clone, Debug, Default, Eq, PartialEq)]
336pub struct ZipImportTarget {
337 pub board_id: String,
338 pub fields: Vec<String>,
339 pub swimlane_id: Option<String>,
340 pub list_id: Option<String>,
341 pub card_id: Option<String>,
342 pub checklist_id: Option<String>,
343}
344
345impl WekanClient {
346 pub async fn import_board(
348 &self,
349 source: Option<ImportSource>,
350 request: PreparedImportRequest<BoardImportRequest>,
351 token: &SecretString,
352 timeout: Option<Duration>,
353 ) -> Result<BoardImportResponse, ClientError> {
354 let path = source.map_or_else(
355 || "api/boards/import".to_owned(),
356 |s| format!("api/boards/import/{}", s.as_str()),
357 );
358 let mut request = self
359 .http
360 .post(self.boards_api_endpoint(&path, "board import")?)
361 .header(ACCEPT, "application/json")
362 .header(CONTENT_TYPE, "application/json")
363 .bearer_auth(token.expose_secret())
364 .body(request.into_body());
365 if let Some(timeout) = timeout {
366 request = request.timeout(timeout);
367 }
368 let body = self.execute_success_request(request).await?;
369 let response: BoardImportResponse = decode_success(&body, "board import", true)?;
370 require_non_empty(&response.board_id, "board import", "board id")?;
371 Ok(response)
372 }
373
374 pub async fn import_zip(
375 &self,
376 target: &ZipImportTarget,
377 reader: impl AsyncRead + Send + Unpin + 'static,
378 length: u64,
379 token: &SecretString,
380 query_token: bool,
381 timeout: Option<Duration>,
382 ) -> Result<ZipImportResponse, ClientError> {
383 let body = self
384 .import_zip_body(
385 ZipDestination::Existing(target),
386 reader,
387 length,
388 token,
389 query_token,
390 timeout,
391 )
392 .await?;
393 let response: ZipImportResponse = decode_success(&body, "ZIP import", true)?;
394 if !response.ok {
395 return Err(ClientError::Protocol {
396 message: "ZIP import did not return ok: true".into(),
397 diagnostic: None,
398 success_status_received: true,
399 });
400 }
401 Ok(response)
402 }
403
404 pub async fn import_zip_new_board(
405 &self,
406 target: &NewBoardZipImportTarget,
407 reader: impl AsyncRead + Send + Unpin + 'static,
408 length: u64,
409 token: &SecretString,
410 query_token: bool,
411 timeout: Option<Duration>,
412 ) -> Result<NewBoardZipImportResponse, ClientError> {
413 let body = self
414 .import_zip_body(
415 ZipDestination::New(target),
416 reader,
417 length,
418 token,
419 query_token,
420 timeout,
421 )
422 .await?;
423 let response: NewBoardZipImportResponse =
424 decode_success(&body, "ZIP new-board import", true)?;
425 if !response.ok {
426 return Err(ClientError::Protocol {
427 message: "ZIP import did not return ok: true".into(),
428 diagnostic: None,
429 success_status_received: true,
430 });
431 }
432 require_non_empty(&response.board_id, "ZIP new-board import", "board id")?;
433 Ok(response)
434 }
435
436 async fn import_zip_body(
437 &self,
438 target: ZipDestination<'_>,
439 reader: impl AsyncRead + Send + Unpin + 'static,
440 length: u64,
441 token: &SecretString,
442 query_token: bool,
443 timeout: Option<Duration>,
444 ) -> Result<Vec<u8>, ClientError> {
445 let mut url = self.boards_api_endpoint("api/import/zip", "ZIP import")?;
446 {
447 let mut query = url.query_pairs_mut();
448 let fields = match target {
449 ZipDestination::Existing(target) => {
450 query.append_pair("boardId", &target.board_id);
451 for (key, value) in [
452 ("swimlaneId", &target.swimlane_id),
453 ("listId", &target.list_id),
454 ("cardId", &target.card_id),
455 ("checklistId", &target.checklist_id),
456 ] {
457 if let Some(value) = value {
458 query.append_pair(key, value);
459 }
460 }
461 &target.fields
462 }
463 ZipDestination::New(target) => {
464 query.append_pair("newBoard", "1");
465 if let Some(mode) = &target.members_mode {
466 query.append_pair("membersMode", mode);
467 }
468 &target.fields
469 }
470 };
471 if !fields.is_empty() {
472 query.append_pair("fields", &fields.join(","));
473 }
474 if query_token {
475 query.append_pair("authToken", token.expose_secret());
476 }
477 }
478 let mut request = self
479 .http
480 .post(url)
481 .header(ACCEPT, "application/json")
482 .header(CONTENT_TYPE, "application/zip")
483 .header(CONTENT_LENGTH, length)
484 .body(reqwest::Body::wrap_stream(ReaderStream::new(reader)));
485 if !query_token {
486 request = request.bearer_auth(token.expose_secret());
487 }
488 if let Some(timeout) = timeout {
489 request = request.timeout(timeout);
490 }
491 self.execute_success_request(request)
492 .await
493 .map_err(|error| match error {
494 ClientError::Transport(source) => ClientError::Transport(source.without_url()),
495 ClientError::ResponseBody {
496 status,
497 retry_after_seconds,
498 source,
499 } => ClientError::ResponseBody {
500 status,
501 retry_after_seconds,
502 source: source.without_url(),
503 },
504 other => other,
505 })
506 }
507}