Skip to main content

wekan_cli/client/boards/
import.rs

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/// Native per-import options from v12.57's importBoard and REST dispatch.
259#[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    /// Submit a typed JSON wrapper containing the complete source document.
347    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}