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