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    Jira,
21    Kanboard,
22    Excel,
23    Deck,
24    Openproject,
25    Github,
26    Gitlab,
27    Gitea,
28    Forgejo,
29    Asana,
30    Zenkit,
31}
32
33impl ImportSource {
34    pub const ALL: [Self; 14] = [
35        Self::Wekan,
36        Self::Trello,
37        Self::Csv,
38        Self::Jira,
39        Self::Kanboard,
40        Self::Excel,
41        Self::Deck,
42        Self::Openproject,
43        Self::Github,
44        Self::Gitlab,
45        Self::Gitea,
46        Self::Forgejo,
47        Self::Asana,
48        Self::Zenkit,
49    ];
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Wekan => "wekan",
53            Self::Trello => "trello",
54            Self::Csv => "csv",
55            Self::Jira => "jira",
56            Self::Kanboard => "kanboard",
57            Self::Excel => "excel",
58            Self::Deck => "deck",
59            Self::Openproject => "openproject",
60            Self::Github => "github",
61            Self::Gitlab => "gitlab",
62            Self::Gitea => "gitea",
63            Self::Forgejo => "forgejo",
64            Self::Asana => "asana",
65            Self::Zenkit => "zenkit",
66        }
67    }
68}
69
70impl std::str::FromStr for ImportSource {
71    type Err = &'static str;
72    fn from_str(value: &str) -> Result<Self, Self::Err> {
73        Self::ALL
74            .into_iter()
75            .find(|source| source.as_str() == value)
76            .ok_or("unsupported import source")
77    }
78}
79
80#[derive(Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct BoardImportRequest {
83    pub board: serde_json::Value,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub members_mapping: Option<BTreeMap<String, String>>,
86}
87
88impl BoardImportRequest {
89    pub fn excel(bytes: &[u8], members_mapping: Option<BTreeMap<String, String>>) -> Self {
90        Self {
91            board: serde_json::json!({"excelBase64": STANDARD.encode(bytes)}),
92            members_mapping,
93        }
94    }
95}
96
97#[derive(Debug, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct BoardImportResponse {
100    #[serde(rename = "_id")]
101    pub board_id: String,
102}
103
104#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
105#[serde(deny_unknown_fields)]
106pub struct ZipImportCounts {
107    pub swimlanes: u64,
108    pub lists: u64,
109    pub cards: u64,
110    pub checklists: u64,
111    pub comments: u64,
112    pub attachments: u64,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct ZipImportResponse {
118    pub ok: bool,
119    pub counts: ZipImportCounts,
120}
121
122#[derive(Clone, Debug, Default, Eq, PartialEq)]
123pub struct ZipImportTarget {
124    pub board_id: String,
125    pub fields: Vec<String>,
126    pub swimlane_id: Option<String>,
127    pub list_id: Option<String>,
128    pub card_id: Option<String>,
129    pub checklist_id: Option<String>,
130}
131
132impl WekanClient {
133    /// Submit a typed JSON wrapper containing the complete source document.
134    pub async fn import_board(
135        &self,
136        source: Option<ImportSource>,
137        request: PreparedImportRequest<BoardImportRequest>,
138        token: &SecretString,
139        timeout: Option<Duration>,
140    ) -> Result<BoardImportResponse, ClientError> {
141        let path = source.map_or_else(
142            || "api/boards/import".to_owned(),
143            |s| format!("api/boards/import/{}", s.as_str()),
144        );
145        let mut request = self
146            .http
147            .post(self.boards_api_endpoint(&path, "board import")?)
148            .header(ACCEPT, "application/json")
149            .header(CONTENT_TYPE, "application/json")
150            .bearer_auth(token.expose_secret())
151            .body(request.into_body());
152        if let Some(timeout) = timeout {
153            request = request.timeout(timeout);
154        }
155        let body = self.execute_success_request(request).await?;
156        let response: BoardImportResponse = decode_success(&body, "board import", true)?;
157        require_non_empty(&response.board_id, "board import", "board id")?;
158        Ok(response)
159    }
160
161    pub async fn import_zip(
162        &self,
163        target: &ZipImportTarget,
164        reader: impl AsyncRead + Send + Unpin + 'static,
165        length: u64,
166        token: &SecretString,
167        query_token: bool,
168        timeout: Option<Duration>,
169    ) -> Result<ZipImportResponse, ClientError> {
170        let mut url = self.boards_api_endpoint("api/import/zip", "ZIP import")?;
171        {
172            let mut query = url.query_pairs_mut();
173            query.append_pair("boardId", &target.board_id);
174            if !target.fields.is_empty() {
175                query.append_pair("fields", &target.fields.join(","));
176            }
177            for (key, value) in [
178                ("swimlaneId", &target.swimlane_id),
179                ("listId", &target.list_id),
180                ("cardId", &target.card_id),
181                ("checklistId", &target.checklist_id),
182            ] {
183                if let Some(value) = value {
184                    query.append_pair(key, value);
185                }
186            }
187            if query_token {
188                query.append_pair("authToken", token.expose_secret());
189            }
190        }
191        let mut request = self
192            .http
193            .post(url)
194            .header(ACCEPT, "application/json")
195            .header(CONTENT_TYPE, "application/zip")
196            .header(CONTENT_LENGTH, length)
197            .body(reqwest::Body::wrap_stream(ReaderStream::new(reader)));
198        if !query_token {
199            request = request.bearer_auth(token.expose_secret());
200        }
201        if let Some(timeout) = timeout {
202            request = request.timeout(timeout);
203        }
204        let body = self
205            .execute_success_request(request)
206            .await
207            .map_err(|error| match error {
208                ClientError::Transport(source) => ClientError::Transport(source.without_url()),
209                ClientError::ResponseBody {
210                    status,
211                    retry_after_seconds,
212                    source,
213                } => ClientError::ResponseBody {
214                    status,
215                    retry_after_seconds,
216                    source: source.without_url(),
217                },
218                other => other,
219            })?;
220        let response: ZipImportResponse = decode_success(&body, "ZIP import", true)?;
221        if !response.ok {
222            return Err(ClientError::Protocol {
223                message: "ZIP import did not return ok: true".into(),
224                diagnostic: None,
225                success_status_received: true,
226            });
227        }
228        Ok(response)
229    }
230}