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 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}