Skip to main content

wekan_cli/client/
exports.rs

1//! Native export transport. Complete artifacts are staged before validation/delivery.
2mod documents;
3mod validation;
4
5use super::{
6    ClientError, WekanClient,
7    transport::{read_limited_body, require_success_status},
8};
9use reqwest::{
10    StatusCode,
11    header::{CONTENT_TYPE, RETRY_AFTER},
12};
13use secrecy::{ExposeSecret, SecretString};
14use serde::Serialize;
15use std::{
16    fs::File,
17    io::{Read, Seek, SeekFrom, Write},
18    time::Duration,
19};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ExportFormat {
24    Json,
25    Csv,
26    Tsv,
27    Xlsx,
28    Pdf,
29    Zip,
30    Kanboard,
31    Deck,
32    Openproject,
33    Github,
34    Gitlab,
35    Gitea,
36    Forgejo,
37    Trello,
38    Jira,
39    Asana,
40    Zenkit,
41}
42impl ExportFormat {
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Json => "json",
46            Self::Csv => "csv",
47            Self::Tsv => "tsv",
48            Self::Xlsx => "xlsx",
49            Self::Pdf => "pdf",
50            Self::Zip => "zip",
51            Self::Kanboard => "kanboard",
52            Self::Deck => "deck",
53            Self::Openproject => "openproject",
54            Self::Github => "github",
55            Self::Gitlab => "gitlab",
56            Self::Gitea => "gitea",
57            Self::Forgejo => "forgejo",
58            Self::Trello => "trello",
59            Self::Jira => "jira",
60            Self::Asana => "asana",
61            Self::Zenkit => "zenkit",
62        }
63    }
64    pub(super) fn suffix(self) -> &'static [&'static str] {
65        match self {
66            Self::Json => &["export"],
67            Self::Csv | Self::Tsv => &["export", "csv"],
68            Self::Xlsx => &["exportExcel"],
69            Self::Pdf => &["exportPDF"],
70            Self::Zip => &["exportZip"],
71            Self::Kanboard => &["export", "kanboard"],
72            Self::Deck => &["export", "deck"],
73            Self::Openproject => &["export", "openproject"],
74            Self::Github => &["export", "github"],
75            Self::Gitlab => &["export", "gitlab"],
76            Self::Gitea => &["export", "gitea"],
77            Self::Forgejo => &["export", "forgejo"],
78            Self::Trello => &["export", "trello"],
79            Self::Jira => &["export", "jira"],
80            Self::Asana => &["export", "asana"],
81            Self::Zenkit => &["export", "zenkit"],
82        }
83    }
84    fn media_type_matches(self, value: &str) -> bool {
85        match self {
86            Self::Csv | Self::Tsv => {
87                matches!(value, "text/csv" | "text/tsv")
88            }
89            Self::Xlsx => {
90                value == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
91            }
92            Self::Pdf => value == "application/pdf",
93            Self::Zip => value == "application/zip",
94            _ => value == "application/json",
95        }
96    }
97}
98impl std::str::FromStr for ExportFormat {
99    type Err = String;
100    fn from_str(value: &str) -> Result<Self, Self::Err> {
101        Ok(match value {
102            "json" => Self::Json,
103            "csv" => Self::Csv,
104            "tsv" => Self::Tsv,
105            "xlsx" => Self::Xlsx,
106            "pdf" => Self::Pdf,
107            "zip" => Self::Zip,
108            "kanboard" => Self::Kanboard,
109            "deck" => Self::Deck,
110            "openproject" => Self::Openproject,
111            "github" => Self::Github,
112            "gitlab" => Self::Gitlab,
113            "gitea" => Self::Gitea,
114            "forgejo" => Self::Forgejo,
115            "trello" => Self::Trello,
116            "jira" => Self::Jira,
117            "asana" => Self::Asana,
118            "zenkit" => Self::Zenkit,
119            _ => return Err("unsupported export format".into()),
120        })
121    }
122}
123#[derive(Debug, Eq, PartialEq)]
124pub(crate) enum ExportTarget {
125    Board {
126        board_id: String,
127    },
128    Card {
129        board_id: String,
130        list_id: String,
131        card_id: String,
132    },
133    Attachment {
134        board_id: String,
135        attachment_id: String,
136    },
137}
138#[derive(Debug, Default)]
139pub struct ExportRequest {
140    pub fields: Vec<String>,
141    pub swimlane_id: Option<String>,
142    pub list_id: Option<String>,
143    pub card_id: Option<String>,
144    pub checklist_id: Option<String>,
145    pub attachments: Option<bool>,
146    pub delimiter: Option<String>,
147    pub language: Option<String>,
148    pub date_format: Option<String>,
149    pub timezone: Option<String>,
150}
151
152pub struct ExportOptions<'a> {
153    pub format: ExportFormat,
154    pub request: ExportRequest,
155    pub token: Option<&'a SecretString>,
156    pub query_token: bool,
157    pub timeout: Option<Duration>,
158}
159#[derive(Debug, Eq, PartialEq)]
160pub struct ExportMetadata {
161    pub bytes_written: u64,
162    pub http_status: u16,
163    pub content_type: Option<String>,
164}
165#[derive(Debug, thiserror::Error)]
166pub enum ExportError {
167    #[error(transparent)]
168    Client(#[from] ClientError),
169    #[error("export staging or validation failed")]
170    Io(#[from] std::io::Error),
171}
172pub(super) fn protocol(message: &str) -> ClientError {
173    ClientError::Protocol {
174        diagnostic: None,
175        message: message.into(),
176        success_status_received: true,
177    }
178}
179
180impl WekanClient {
181    pub(super) async fn export_resource(
182        &self,
183        segments: &[&str],
184        options: ExportOptions<'_>,
185        file: &mut File,
186        attachment: bool,
187    ) -> Result<ExportMetadata, ExportError> {
188        let mut url = self
189            .server()
190            .join("api/boards/")
191            .map_err(|_| protocol("could not build export endpoint"))?;
192        url.path_segments_mut()
193            .map_err(|_| protocol("could not build export endpoint"))?
194            .pop_if_empty()
195            .extend(segments);
196        {
197            let mut pairs = url.query_pairs_mut();
198            let request = &options.request;
199            if !request.fields.is_empty() {
200                pairs.append_pair("fields", &request.fields.join(","));
201            }
202            for (key, value) in [
203                ("swimlaneId", &request.swimlane_id),
204                ("listId", &request.list_id),
205                ("cardId", &request.card_id),
206                ("checklistId", &request.checklist_id),
207            ] {
208                if let Some(value) = value {
209                    pairs.append_pair(key, value);
210                }
211            }
212            if let Some(value) = request.attachments {
213                pairs.append_pair("attachments", if value { "true" } else { "false" });
214            }
215            for (key, value) in [
216                ("delimiter", &request.delimiter),
217                ("lang", &request.language),
218                ("dateFormat", &request.date_format),
219                ("tz", &request.timezone),
220            ] {
221                if let Some(value) = value {
222                    pairs.append_pair(key, value);
223                }
224            }
225            if options.format == ExportFormat::Tsv {
226                pairs.append_pair("delimiter", "\t");
227            }
228            if options.query_token {
229                if let Some(token) = options.token {
230                    pairs.append_pair("authToken", token.expose_secret());
231                }
232            }
233        }
234        let mut request = self.http.get(url);
235        if !options.query_token {
236            if let Some(token) = options.token {
237                request = request.bearer_auth(token.expose_secret());
238            }
239        }
240        if let Some(timeout) = options.timeout {
241            request = request.timeout(timeout);
242        }
243        let mut response = request
244            .send()
245            .await
246            .map_err(|e| ClientError::Transport(e.without_url()))?;
247        let status = response.status();
248        if status.is_redirection() {
249            return Err(ClientError::UnexpectedRedirect { status }.into());
250        }
251        let retry = response
252            .headers()
253            .get(RETRY_AFTER)
254            .and_then(|v| v.to_str().ok())
255            .and_then(|v| v.parse().ok());
256        if status != StatusCode::OK {
257            let body = read_limited_body(response, retry, 1024 * 1024).await?;
258            require_success_status(status, body, retry)?;
259            unreachable!("non-200 status must fail");
260        }
261        let content_type = response
262            .headers()
263            .get(CONTENT_TYPE)
264            .and_then(|v| v.to_str().ok())
265            .map(str::to_owned);
266        let media = content_type
267            .as_deref()
268            .unwrap_or("")
269            .split(';')
270            .next()
271            .unwrap_or("")
272            .trim()
273            .to_ascii_lowercase();
274        if !options.format.media_type_matches(&media) {
275            // These exact pinned routes can send an HTTP-200 plain-text error,
276            // including responses with no Content-Type. Never deliver it as a file.
277            let body = read_limited_body(response, retry, 1024 * 1024).await?;
278            if matches!(options.format, ExportFormat::Xlsx | ExportFormat::Pdf)
279                && !attachment
280                && (media.is_empty() || media == "text/plain")
281            {
282                let text = String::from_utf8_lossy(&body);
283                return Err(ClientError::EmbeddedProtocol {
284                    http_status: status,
285                    server_error: None,
286                    server_reason: None,
287                    server_message: Some(text.chars().take(1024).collect()),
288                    server_error_type: None,
289                    server_is_client_safe: None,
290                }
291                .into());
292            }
293            return Err(protocol("the export response has an unexpected Content-Type").into());
294        }
295        file.set_len(0)?;
296        file.seek(SeekFrom::Start(0))?;
297        let mut bytes_written = 0u64;
298        while let Some(chunk) =
299            response
300                .chunk()
301                .await
302                .map_err(|source| ClientError::ResponseBody {
303                    status,
304                    retry_after_seconds: retry,
305                    source: source.without_url(),
306                })?
307        {
308            file.write_all(&chunk)?;
309            bytes_written = bytes_written
310                .checked_add(chunk.len() as u64)
311                .ok_or_else(|| protocol("export byte count overflow"))?;
312        }
313        file.seek(SeekFrom::Start(0))?;
314        match options.format {
315            ExportFormat::Csv | ExportFormat::Tsv => {}
316            ExportFormat::Pdf | ExportFormat::Xlsx | ExportFormat::Zip => {
317                let mut prefix = [0; 5];
318                let count = file.read(&mut prefix)?;
319                let valid = if options.format == ExportFormat::Pdf {
320                    count == 5 && &prefix == b"%PDF-"
321                } else {
322                    count >= 4 && (&prefix[..4] == b"PK\x03\x04" || &prefix[..4] == b"PK\x05\x06")
323                };
324                if !valid {
325                    return Err(
326                        protocol("the export response has an invalid file signature").into(),
327                    );
328                }
329            }
330            format => validation::validate(file, format, attachment)?,
331        }
332        file.seek(SeekFrom::Start(0))?;
333        Ok(ExportMetadata {
334            bytes_written,
335            http_status: status.as_u16(),
336            content_type,
337        })
338    }
339}