Skip to main content

wekan_cli/client/
exports.rs

1//! Native export transport. Complete artifacts are staged before validation/delivery.
2mod documents;
3mod dynamic_fields;
4mod external_documents;
5mod scrum_transfer;
6mod validation;
7
8use super::{
9    ClientError, WekanClient,
10    transport::{read_limited_body, require_success_status},
11};
12use reqwest::{
13    StatusCode,
14    header::{CONTENT_TYPE, RETRY_AFTER},
15};
16use secrecy::{ExposeSecret, SecretString};
17use serde::Serialize;
18use std::{
19    fs::File,
20    io::{Read, Seek, SeekFrom, Write},
21    time::Duration,
22};
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ExportFormat {
27    Json,
28    Csv,
29    Tsv,
30    Xlsx,
31    Pdf,
32    Zip,
33    Ics,
34    Wekan,
35    Excel,
36    Scsv,
37    Kanboard,
38    Deck,
39    Openproject,
40    Github,
41    Gitlab,
42    Gitea,
43    Forgejo,
44    Trello,
45    Jira,
46    Asana,
47    Zenkit,
48    Markdown,
49    Leo,
50    Todotxt,
51    Taskwarrior,
52    Focalboard,
53    Todoist,
54    Planner,
55    Meistertask,
56    Obsidian,
57    Linear,
58    Ticktick,
59    Clickup,
60    Nullboard,
61    Kanri,
62    Pivotal,
63    Redmine,
64    Tasksorg,
65    Monday,
66    Businessmap,
67    Superproductivity,
68    Taiga,
69    Vikunja,
70    Wrike,
71    Wrikeworkflow,
72    Teamwork,
73    Quire,
74    Notion,
75    Opml,
76    Orgmode,
77}
78#[derive(Clone, Copy, Eq, PartialEq)]
79enum ArtifactKind {
80    Json,
81    Csv,
82    Text,
83    Ics,
84    Ndjson,
85    Workbook,
86    Pdf,
87    Zip,
88}
89impl ExportFormat {
90    pub const ALL: [Self; 50] = [
91        Self::Json,
92        Self::Csv,
93        Self::Tsv,
94        Self::Xlsx,
95        Self::Pdf,
96        Self::Zip,
97        Self::Ics,
98        Self::Wekan,
99        Self::Excel,
100        Self::Scsv,
101        Self::Kanboard,
102        Self::Deck,
103        Self::Openproject,
104        Self::Github,
105        Self::Gitlab,
106        Self::Gitea,
107        Self::Forgejo,
108        Self::Trello,
109        Self::Jira,
110        Self::Asana,
111        Self::Zenkit,
112        Self::Markdown,
113        Self::Leo,
114        Self::Todotxt,
115        Self::Taskwarrior,
116        Self::Focalboard,
117        Self::Todoist,
118        Self::Planner,
119        Self::Meistertask,
120        Self::Obsidian,
121        Self::Linear,
122        Self::Ticktick,
123        Self::Clickup,
124        Self::Nullboard,
125        Self::Kanri,
126        Self::Pivotal,
127        Self::Redmine,
128        Self::Tasksorg,
129        Self::Monday,
130        Self::Businessmap,
131        Self::Superproductivity,
132        Self::Taiga,
133        Self::Vikunja,
134        Self::Wrike,
135        Self::Wrikeworkflow,
136        Self::Teamwork,
137        Self::Quire,
138        Self::Notion,
139        Self::Opml,
140        Self::Orgmode,
141    ];
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Self::Json => "json",
145            Self::Csv => "csv",
146            Self::Tsv => "tsv",
147            Self::Xlsx => "xlsx",
148            Self::Pdf => "pdf",
149            Self::Zip => "zip",
150            Self::Ics => "ics",
151            Self::Wekan => "wekan",
152            Self::Excel => "excel",
153            Self::Scsv => "scsv",
154            Self::Kanboard => "kanboard",
155            Self::Deck => "deck",
156            Self::Openproject => "openproject",
157            Self::Github => "github",
158            Self::Gitlab => "gitlab",
159            Self::Gitea => "gitea",
160            Self::Forgejo => "forgejo",
161            Self::Trello => "trello",
162            Self::Jira => "jira",
163            Self::Asana => "asana",
164            Self::Zenkit => "zenkit",
165            Self::Markdown => "markdown",
166            Self::Leo => "leo",
167            Self::Todotxt => "todotxt",
168            Self::Taskwarrior => "taskwarrior",
169            Self::Focalboard => "focalboard",
170            Self::Todoist => "todoist",
171            Self::Planner => "planner",
172            Self::Meistertask => "meistertask",
173            Self::Obsidian => "obsidian",
174            Self::Linear => "linear",
175            Self::Ticktick => "ticktick",
176            Self::Clickup => "clickup",
177            Self::Nullboard => "nullboard",
178            Self::Kanri => "kanri",
179            Self::Pivotal => "pivotal",
180            Self::Redmine => "redmine",
181            Self::Tasksorg => "tasksorg",
182            Self::Monday => "monday",
183            Self::Businessmap => "businessmap",
184            Self::Superproductivity => "superproductivity",
185            Self::Taiga => "taiga",
186            Self::Vikunja => "vikunja",
187            Self::Wrike => "wrike",
188            Self::Wrikeworkflow => "wrikeworkflow",
189            Self::Teamwork => "teamwork",
190            Self::Quire => "quire",
191            Self::Notion => "notion",
192            Self::Opml => "opml",
193            Self::Orgmode => "orgmode",
194        }
195    }
196    pub(super) fn suffix(self) -> &'static [&'static str] {
197        match self {
198            Self::Json => &["export"],
199            Self::Csv => &["export", "csv"],
200            Self::Tsv => &["export", "csv"],
201            Self::Xlsx => &["exportExcel"],
202            Self::Pdf => &["exportPDF"],
203            Self::Zip => &["exportZip"],
204            Self::Ics => &["calendar.ics"],
205            Self::Wekan => &["export"],
206            Self::Excel => &["exportExcel"],
207            Self::Scsv => &["export", "csv"],
208            Self::Kanboard => &["export", "kanboard"],
209            Self::Deck => &["export", "deck"],
210            Self::Openproject => &["export", "openproject"],
211            Self::Github => &["export", "github"],
212            Self::Gitlab => &["export", "gitlab"],
213            Self::Gitea => &["export", "gitea"],
214            Self::Forgejo => &["export", "forgejo"],
215            Self::Trello => &["export", "trello"],
216            Self::Jira => &["export", "jira"],
217            Self::Asana => &["export", "asana"],
218            Self::Zenkit => &["export", "zenkit"],
219            Self::Markdown => &["export", "markdown"],
220            Self::Leo => &["export", "leo"],
221            Self::Todotxt => &["export", "todotxt"],
222            Self::Taskwarrior => &["export", "taskwarrior"],
223            Self::Focalboard => &["export", "focalboard"],
224            Self::Todoist => &["export", "todoist"],
225            Self::Planner => &["export", "planner"],
226            Self::Meistertask => &["export", "meistertask"],
227            Self::Obsidian => &["export", "obsidian"],
228            Self::Linear => &["export", "linear"],
229            Self::Ticktick => &["export", "ticktick"],
230            Self::Clickup => &["export", "clickup"],
231            Self::Nullboard => &["export", "nullboard"],
232            Self::Kanri => &["export", "kanri"],
233            Self::Pivotal => &["export", "pivotal"],
234            Self::Redmine => &["export", "redmine"],
235            Self::Tasksorg => &["export", "tasksorg"],
236            Self::Monday => &["export", "monday"],
237            Self::Businessmap => &["export", "businessmap"],
238            Self::Superproductivity => &["export", "superproductivity"],
239            Self::Taiga => &["export", "taiga"],
240            Self::Vikunja => &["export", "vikunja"],
241            Self::Wrike => &["export", "wrike"],
242            Self::Wrikeworkflow => &["export", "wrikeworkflow"],
243            Self::Teamwork => &["export", "teamwork"],
244            Self::Quire => &["export", "quire"],
245            Self::Notion => &["export", "notion"],
246            Self::Opml => &["export", "opml"],
247            Self::Orgmode => &["export", "orgmode"],
248        }
249    }
250    fn kind(self) -> ArtifactKind {
251        match self {
252            Self::Json => ArtifactKind::Json,
253            Self::Csv => ArtifactKind::Csv,
254            Self::Tsv => ArtifactKind::Csv,
255            Self::Xlsx => ArtifactKind::Workbook,
256            Self::Pdf => ArtifactKind::Pdf,
257            Self::Zip => ArtifactKind::Zip,
258            Self::Ics => ArtifactKind::Ics,
259            Self::Wekan => ArtifactKind::Json,
260            Self::Excel => ArtifactKind::Workbook,
261            Self::Scsv => ArtifactKind::Csv,
262            Self::Kanboard => ArtifactKind::Json,
263            Self::Deck => ArtifactKind::Json,
264            Self::Openproject => ArtifactKind::Json,
265            Self::Github => ArtifactKind::Json,
266            Self::Gitlab => ArtifactKind::Json,
267            Self::Gitea => ArtifactKind::Json,
268            Self::Forgejo => ArtifactKind::Json,
269            Self::Trello => ArtifactKind::Json,
270            Self::Jira => ArtifactKind::Json,
271            Self::Asana => ArtifactKind::Json,
272            Self::Zenkit => ArtifactKind::Json,
273            Self::Markdown => ArtifactKind::Text,
274            Self::Leo => ArtifactKind::Text,
275            Self::Todotxt => ArtifactKind::Text,
276            Self::Taskwarrior => ArtifactKind::Json,
277            Self::Focalboard => ArtifactKind::Ndjson,
278            Self::Todoist => ArtifactKind::Csv,
279            Self::Planner => ArtifactKind::Workbook,
280            Self::Meistertask => ArtifactKind::Csv,
281            Self::Obsidian => ArtifactKind::Text,
282            Self::Linear => ArtifactKind::Csv,
283            Self::Ticktick => ArtifactKind::Csv,
284            Self::Clickup => ArtifactKind::Csv,
285            Self::Nullboard => ArtifactKind::Json,
286            Self::Kanri => ArtifactKind::Json,
287            Self::Pivotal => ArtifactKind::Csv,
288            Self::Redmine => ArtifactKind::Csv,
289            Self::Tasksorg => ArtifactKind::Json,
290            Self::Monday => ArtifactKind::Workbook,
291            Self::Businessmap => ArtifactKind::Workbook,
292            Self::Superproductivity => ArtifactKind::Json,
293            Self::Taiga => ArtifactKind::Json,
294            Self::Vikunja => ArtifactKind::Zip,
295            Self::Wrike => ArtifactKind::Workbook,
296            Self::Wrikeworkflow => ArtifactKind::Json,
297            Self::Teamwork => ArtifactKind::Workbook,
298            Self::Quire => ArtifactKind::Csv,
299            Self::Notion => ArtifactKind::Csv,
300            Self::Opml => ArtifactKind::Text,
301            Self::Orgmode => ArtifactKind::Text,
302        }
303    }
304    fn media_type_matches(self, value: &str) -> bool {
305        match self {
306            Self::Markdown => value == "text/markdown",
307            Self::Obsidian => value == "text/markdown",
308            Self::Leo => value == "application/xml",
309            Self::Opml => value == "text/x-opml",
310            Self::Orgmode => value == "text/x-org",
311            Self::Todotxt => value == "text/plain",
312            Self::Focalboard => value == "application/x-ndjson",
313            Self::Ics => value == "text/calendar",
314            format => match format.kind() {
315                ArtifactKind::Csv => matches!(value, "text/csv" | "text/tsv"),
316                ArtifactKind::Workbook => {
317                    value == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
318                }
319                ArtifactKind::Pdf => value == "application/pdf",
320                ArtifactKind::Zip => value == "application/zip",
321                ArtifactKind::Json => value == "application/json",
322                _ => false,
323            },
324        }
325    }
326}
327impl std::str::FromStr for ExportFormat {
328    type Err = String;
329    fn from_str(value: &str) -> Result<Self, Self::Err> {
330        Self::ALL
331            .into_iter()
332            .find(|format| format.as_str() == value)
333            .ok_or_else(|| "unsupported export format".into())
334    }
335}
336#[derive(Debug, Eq, PartialEq)]
337pub(crate) enum ExportTarget {
338    Chart {
339        board_id: String,
340        chart_key: String,
341    },
342    AllBoards,
343    Board {
344        board_id: String,
345    },
346    Card {
347        board_id: String,
348        list_id: String,
349        card_id: String,
350    },
351    Attachment {
352        board_id: String,
353        attachment_id: String,
354    },
355}
356#[derive(Debug, Default)]
357pub struct ExportRequest {
358    pub board_ids: Vec<String>,
359    pub chart: Option<ChartExportOptions>,
360    pub fields: Vec<String>,
361    pub swimlane_id: Option<String>,
362    pub list_id: Option<String>,
363    pub card_id: Option<String>,
364    pub checklist_id: Option<String>,
365    pub attachments: Option<bool>,
366    pub delimiter: Option<String>,
367    pub language: Option<String>,
368    pub date_format: Option<String>,
369    pub timezone: Option<String>,
370}
371
372#[derive(Debug, Default, serde::Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct ChartExportOptions {
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub sprint_id: Option<String>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub target_count: Option<u32>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub history_days: Option<u32>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub target_date: Option<String>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub size_field: Option<String>,
385}
386
387pub struct ExportOptions<'a> {
388    pub format: ExportFormat,
389    pub request: ExportRequest,
390    pub token: Option<&'a SecretString>,
391    pub query_token: bool,
392    pub timeout: Option<Duration>,
393}
394#[derive(Debug, Eq, PartialEq)]
395pub struct ExportMetadata {
396    pub bytes_written: u64,
397    pub http_status: u16,
398    pub content_type: Option<String>,
399}
400#[derive(Debug, thiserror::Error)]
401pub enum ExportError {
402    #[error(transparent)]
403    Client(#[from] ClientError),
404    #[error("export staging or validation failed")]
405    Io(#[from] std::io::Error),
406}
407pub(super) fn protocol(message: &str) -> ClientError {
408    ClientError::Protocol {
409        diagnostic: None,
410        message: message.into(),
411        success_status_received: true,
412    }
413}
414
415impl WekanClient {
416    pub async fn export_board_chart(
417        &self,
418        board: &str,
419        chart: &str,
420        options: ExportOptions<'_>,
421        file: &mut File,
422    ) -> Result<ExportMetadata, ExportError> {
423        let endpoint = if options.format == ExportFormat::Pdf {
424            "exportPDF"
425        } else {
426            "exportExcel"
427        };
428        self.export_api_path(&["boards", board, "charts", chart, endpoint], options, file)
429            .await
430    }
431
432    pub async fn export_all_boards(
433        &self,
434        options: ExportOptions<'_>,
435        file: &mut File,
436    ) -> Result<ExportMetadata, ExportError> {
437        let native_format = options.format.as_str();
438        let options = ExportOptions {
439            format: if options.format == ExportFormat::Excel {
440                ExportFormat::Xlsx
441            } else {
442                ExportFormat::Zip
443            },
444            ..options
445        };
446        self.export_api_path(&["export-all-boards", native_format], options, file)
447            .await
448    }
449
450    pub(super) async fn export_resource(
451        &self,
452        segments: &[&str],
453        options: ExportOptions<'_>,
454        file: &mut File,
455        attachment: bool,
456    ) -> Result<ExportMetadata, ExportError> {
457        let mut url = self
458            .server()
459            .join("api/boards/")
460            .map_err(|_| protocol("could not build export endpoint"))?;
461        url.path_segments_mut()
462            .map_err(|_| protocol("could not build export endpoint"))?
463            .pop_if_empty()
464            .extend(segments);
465        self.export_url(url, options, file, attachment).await
466    }
467    async fn export_api_path(
468        &self,
469        segments: &[&str],
470        options: ExportOptions<'_>,
471        file: &mut File,
472    ) -> Result<ExportMetadata, ExportError> {
473        let mut url = self
474            .server()
475            .join("api/")
476            .map_err(|_| protocol("could not build export endpoint"))?;
477        url.path_segments_mut()
478            .map_err(|_| protocol("could not build export endpoint"))?
479            .pop_if_empty()
480            .extend(segments);
481        self.export_url(url, options, file, false).await
482    }
483    async fn export_url(
484        &self,
485        mut url: url::Url,
486        options: ExportOptions<'_>,
487        file: &mut File,
488        attachment: bool,
489    ) -> Result<ExportMetadata, ExportError> {
490        {
491            let mut pairs = url.query_pairs_mut();
492            let request = &options.request;
493            if !request.board_ids.is_empty() {
494                pairs.append_pair("boardIds", &request.board_ids.join(","));
495            }
496            if let Some(chart) = &request.chart {
497                // All keys are statically typed above, never arbitrary query properties.
498                let encoded = serde_json::to_value(chart).expect("chart options serialize");
499                for (key, value) in encoded.as_object().expect("chart options are an object") {
500                    pairs.append_pair(
501                        key,
502                        &value
503                            .as_str()
504                            .map(str::to_owned)
505                            .unwrap_or_else(|| value.to_string()),
506                    );
507                }
508            }
509            if !request.fields.is_empty() {
510                pairs.append_pair("fields", &request.fields.join(","));
511            }
512            for (key, value) in [
513                ("swimlaneId", &request.swimlane_id),
514                ("listId", &request.list_id),
515                ("cardId", &request.card_id),
516                ("checklistId", &request.checklist_id),
517            ] {
518                if let Some(value) = value {
519                    pairs.append_pair(key, value);
520                }
521            }
522            if let Some(value) = request.attachments {
523                pairs.append_pair("attachments", if value { "true" } else { "false" });
524            }
525            for (key, value) in [
526                ("delimiter", &request.delimiter),
527                ("lang", &request.language),
528                ("dateFormat", &request.date_format),
529                ("tz", &request.timezone),
530            ] {
531                if let Some(value) = value {
532                    pairs.append_pair(key, value);
533                }
534            }
535            if options.format == ExportFormat::Scsv {
536                pairs.append_pair("delimiter", ";");
537            }
538            if options.format == ExportFormat::Tsv {
539                pairs.append_pair("delimiter", "\t");
540            }
541            if options.query_token {
542                if let Some(token) = options.token {
543                    pairs.append_pair("authToken", token.expose_secret());
544                }
545            }
546        }
547        let mut request = self.http.get(url);
548        if !options.query_token {
549            if let Some(token) = options.token {
550                request = request.bearer_auth(token.expose_secret());
551            }
552        }
553        if let Some(timeout) = options.timeout {
554            request = request.timeout(timeout);
555        }
556        let mut response = request
557            .send()
558            .await
559            .map_err(|e| ClientError::Transport(e.without_url()))?;
560        let status = response.status();
561        if status.is_redirection() {
562            return Err(ClientError::UnexpectedRedirect { status }.into());
563        }
564        let retry = response
565            .headers()
566            .get(RETRY_AFTER)
567            .and_then(|v| v.to_str().ok())
568            .and_then(|v| v.parse().ok());
569        if status != StatusCode::OK {
570            let body = read_limited_body(response, retry, 1024 * 1024).await?;
571            require_success_status(status, body, retry)?;
572            unreachable!("non-200 status must fail");
573        }
574        let content_type = response
575            .headers()
576            .get(CONTENT_TYPE)
577            .and_then(|v| v.to_str().ok())
578            .map(str::to_owned);
579        let media = content_type
580            .as_deref()
581            .unwrap_or("")
582            .split(';')
583            .next()
584            .unwrap_or("")
585            .trim()
586            .to_ascii_lowercase();
587        if !options.format.media_type_matches(&media) {
588            // These exact pinned routes can send an HTTP-200 plain-text error,
589            // including responses with no Content-Type. Never deliver it as a file.
590            let body = read_limited_body(response, retry, 1024 * 1024).await?;
591            if matches!(options.format, ExportFormat::Xlsx | ExportFormat::Pdf)
592                && !attachment
593                && (media.is_empty() || media == "text/plain")
594            {
595                let text = String::from_utf8_lossy(&body);
596                return Err(ClientError::EmbeddedProtocol {
597                    http_status: status,
598                    server_error: None,
599                    server_reason: None,
600                    server_message: Some(text.chars().take(1024).collect()),
601                    server_error_type: None,
602                    server_is_client_safe: None,
603                }
604                .into());
605            }
606            return Err(protocol("the export response has an unexpected Content-Type").into());
607        }
608        file.set_len(0)?;
609        file.seek(SeekFrom::Start(0))?;
610        let mut bytes_written = 0u64;
611        while let Some(chunk) =
612            response
613                .chunk()
614                .await
615                .map_err(|source| ClientError::ResponseBody {
616                    status,
617                    retry_after_seconds: retry,
618                    source: source.without_url(),
619                })?
620        {
621            file.write_all(&chunk)?;
622            bytes_written = bytes_written
623                .checked_add(chunk.len() as u64)
624                .ok_or_else(|| protocol("export byte count overflow"))?;
625        }
626        file.seek(SeekFrom::Start(0))?;
627        match options.format.kind() {
628            ArtifactKind::Csv | ArtifactKind::Text => {
629                validation::validate_text(file)?;
630            }
631            ArtifactKind::Ics => {
632                validation::validate_calendar(file)?;
633            }
634            ArtifactKind::Ndjson => {
635                validation::validate_focalboard(file)?;
636            }
637            ArtifactKind::Pdf | ArtifactKind::Workbook | ArtifactKind::Zip => {
638                let mut prefix = [0; 5];
639                let count = file.read(&mut prefix)?;
640                let valid = if options.format.kind() == ArtifactKind::Pdf {
641                    count == 5 && &prefix == b"%PDF-"
642                } else {
643                    count >= 4 && (&prefix[..4] == b"PK\x03\x04" || &prefix[..4] == b"PK\x05\x06")
644                };
645                if !valid {
646                    return Err(
647                        protocol("the export response has an invalid file signature").into(),
648                    );
649                }
650            }
651            ArtifactKind::Json => validation::validate(file, options.format, attachment)?,
652        }
653        file.seek(SeekFrom::Start(0))?;
654        Ok(ExportMetadata {
655            bytes_written,
656            http_status: status.as_u16(),
657            content_type,
658        })
659    }
660}