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