Skip to main content

wekan_cli/client/boards/
trello_import.rs

1//! The application HTTP importer is distinct from the /api import engines.
2use std::time::Duration;
3
4use reqwest::{
5    RequestBuilder,
6    header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, COOKIE},
7};
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Serialize};
10use tokio::io::AsyncRead;
11use tokio_util::io::ReaderStream;
12
13use super::import::BoardImportRequest;
14use crate::client::{
15    ClientError, WekanClient, imports::PreparedImportRequest, transport::decode_success,
16};
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum TrelloImportFormat {
21    Json,
22    Zip,
23}
24
25#[derive(Clone, Copy, Debug)]
26pub enum TrelloImportAuth {
27    Bearer,
28    XAuthToken,
29    MeteorCookie,
30    WekanCookie,
31}
32
33/// Wekan echoes an unvalidated name on failure. Compound names are deliberately
34/// rejected rather than admitting unmapped nested response objects.
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(untagged)]
37pub enum TrelloImportBoardName {
38    String(String),
39    Number(serde_json::Number),
40    Boolean(bool),
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct TrelloImportBoardError {
46    pub board: TrelloImportBoardName,
47    pub error: String,
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct JsonResponse {
53    #[serde(rename = "boardIds")]
54    board_ids: Vec<String>,
55}
56
57#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct ZipResponse {
60    #[serde(rename = "boardIds")]
61    board_ids: Vec<String>,
62    errors: Vec<TrelloImportBoardError>,
63}
64
65#[derive(Deserialize)]
66#[serde(deny_unknown_fields)]
67struct ErrorResponse {
68    #[serde(rename = "error")]
69    _error: String,
70}
71
72#[derive(Debug)]
73pub struct TrelloImportResponse {
74    pub board_ids: Vec<String>,
75    pub errors: Option<Vec<TrelloImportBoardError>>,
76}
77
78impl WekanClient {
79    pub async fn import_trello_json(
80        &self,
81        body: PreparedImportRequest<BoardImportRequest>,
82        token: &SecretString,
83        auth: TrelloImportAuth,
84        timeout: Option<Duration>,
85    ) -> Result<TrelloImportResponse, ClientError> {
86        let request = self
87            .http
88            .post(self.boards_api_endpoint("import-trello", "Trello import")?)
89            .header(CONTENT_TYPE, "application/json")
90            .body(body.into_body());
91        self.send_trello_import(request, TrelloImportFormat::Json, token, auth, timeout)
92            .await
93    }
94
95    pub async fn import_trello_zip(
96        &self,
97        reader: impl AsyncRead + Send + Unpin + 'static,
98        length: u64,
99        token: &SecretString,
100        auth: TrelloImportAuth,
101        members_mode: Option<&str>,
102        timeout: Option<Duration>,
103    ) -> Result<TrelloImportResponse, ClientError> {
104        let mut endpoint = self.boards_api_endpoint("import-trello", "Trello import")?;
105        if let Some(mode) = members_mode {
106            endpoint.query_pairs_mut().append_pair("membersMode", mode);
107        }
108        let request = self
109            .http
110            .post(endpoint)
111            .header(CONTENT_TYPE, "application/zip")
112            .header(CONTENT_LENGTH, length)
113            .body(reqwest::Body::wrap_stream(ReaderStream::new(reader)));
114        self.send_trello_import(request, TrelloImportFormat::Zip, token, auth, timeout)
115            .await
116    }
117
118    async fn send_trello_import(
119        &self,
120        request: RequestBuilder,
121        format: TrelloImportFormat,
122        token: &SecretString,
123        auth: TrelloImportAuth,
124        timeout: Option<Duration>,
125    ) -> Result<TrelloImportResponse, ClientError> {
126        let mut request = request.header(ACCEPT, "application/json");
127        request = match auth {
128            TrelloImportAuth::Bearer => request.bearer_auth(token.expose_secret()),
129            TrelloImportAuth::XAuthToken => request.header("X-Auth-Token", token.expose_secret()),
130            TrelloImportAuth::MeteorCookie | TrelloImportAuth::WekanCookie => {
131                let name = if matches!(auth, TrelloImportAuth::MeteorCookie) {
132                    "meteor_login_token"
133                } else {
134                    "wekan_login_token"
135                };
136                let encoded =
137                    url::form_urlencoded::byte_serialize(token.expose_secret().as_bytes())
138                        .collect::<String>()
139                        .replace('+', "%20");
140                request.header(COOKIE, format!("{name}={encoded}"))
141            }
142        };
143        if let Some(timeout) = timeout {
144            request = request.timeout(timeout);
145        }
146        let body = self
147            .execute_success_request_with_error::<ClientError>(request, |_, body| {
148                // Middleware may return HTML. JSON errors belong to this route's
149                // stricter {error: string} contract; retain shared HTTP metadata.
150                if let Ok(value) = serde_json::from_slice(body) {
151                    let _: ErrorResponse =
152                        crate::client::decoding::decode_value(value, "Trello import error", false)?;
153                }
154                Ok(None)
155            })
156            .await?;
157        match format {
158            TrelloImportFormat::Json => {
159                let response: JsonResponse = decode_success(&body, "Trello JSON import", true)?;
160                Ok(TrelloImportResponse {
161                    board_ids: response.board_ids,
162                    errors: None,
163                })
164            }
165            TrelloImportFormat::Zip => {
166                let response: ZipResponse = decode_success(&body, "Trello ZIP import", true)?;
167                Ok(TrelloImportResponse {
168                    board_ids: response.board_ids,
169                    errors: Some(response.errors),
170                })
171            }
172        }
173    }
174}