1use std::path::{Path, PathBuf};
2
3use serde::Serialize;
4use serde_json::{json, Map, Value};
5
6use crate::api::{fetch_token, multipart_file_from_path, ApiClient, Transport};
7use crate::config::{
8 load_config, load_session, normalize_url, save_config, save_session, AppConfig, AppPaths,
9 OutputMode, SessionState,
10};
11use crate::error::AppError;
12use crate::security::{AuditState, SecurityFinding};
13
14#[derive(Clone, Debug, Default, Eq, PartialEq)]
15pub struct DocumentQuery {
16 pub query: Option<String>,
17 pub tag: Option<String>,
18 pub tag_id: Option<u64>,
19 pub correspondent: Option<String>,
20 pub correspondent_id: Option<u64>,
21 pub document_type: Option<String>,
22 pub document_type_id: Option<u64>,
23 pub created_after: Option<String>,
24 pub created_before: Option<String>,
25 pub order_by: String,
26 pub page_size: u64,
27 pub page: u64,
28}
29
30impl DocumentQuery {
31 pub fn new() -> Self {
32 Self {
33 order_by: "-created".to_string(),
34 page_size: 25,
35 page: 1,
36 ..Self::default()
37 }
38 }
39
40 pub fn to_query_pairs(&self) -> Vec<(String, String)> {
41 let mut pairs = Vec::new();
42 if let Some(query) = &self.query {
43 pairs.push(("query".to_string(), query.clone()));
44 }
45 if let Some(tag) = &self.tag {
46 pairs.push(("tags__name__icontains".to_string(), tag.clone()));
47 }
48 if let Some(tag_id) = self.tag_id {
49 pairs.push(("tags__id__in".to_string(), tag_id.to_string()));
50 }
51 if let Some(correspondent) = &self.correspondent {
52 pairs.push((
53 "correspondent__name__icontains".to_string(),
54 correspondent.clone(),
55 ));
56 }
57 if let Some(correspondent_id) = self.correspondent_id {
58 pairs.push((
59 "correspondent__id".to_string(),
60 correspondent_id.to_string(),
61 ));
62 }
63 if let Some(document_type) = &self.document_type {
64 pairs.push((
65 "document_type__name__icontains".to_string(),
66 document_type.clone(),
67 ));
68 }
69 if let Some(document_type_id) = self.document_type_id {
70 pairs.push((
71 "document_type__id".to_string(),
72 document_type_id.to_string(),
73 ));
74 }
75 if let Some(created_after) = &self.created_after {
76 pairs.push(("created__date__gt".to_string(), created_after.clone()));
77 }
78 if let Some(created_before) = &self.created_before {
79 pairs.push(("created__date__lt".to_string(), created_before.clone()));
80 }
81 pairs.push(("ordering".to_string(), self.order_by.clone()));
82 pairs.push(("page_size".to_string(), self.page_size.to_string()));
83 pairs.push(("page".to_string(), self.page.to_string()));
84 pairs
85 }
86}
87
88#[derive(Clone, Debug, Default, Eq, PartialEq)]
89pub struct UploadRequest {
90 pub path: PathBuf,
91 pub title: Option<String>,
92 pub correspondent_id: Option<u64>,
93 pub document_type_id: Option<u64>,
94 pub tag_ids: Vec<u64>,
95}
96
97#[derive(Clone, Debug, Default, Eq, PartialEq)]
98pub struct UpdateRequest {
99 pub title: Option<String>,
100 pub correspondent_id: Option<u64>,
101 pub document_type_id: Option<u64>,
102 pub tag_ids: Option<Vec<u64>>,
103 pub created: Option<String>,
104 pub custom_fields: Option<Value>,
105}
106
107#[derive(Clone, Debug, Default, Eq, PartialEq)]
108pub struct TagUpdateRequest {
109 pub name: Option<String>,
110 pub color: Option<String>,
111 pub inbox: Option<bool>,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
115pub struct OutputEnvelope {
116 pub mode: String,
117 pub command: String,
118 pub data: Value,
119 pub security: Vec<SecurityFinding>,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct DashboardSnapshot {
124 pub project: Value,
125 pub documents: Vec<DocumentSummary>,
126 pub latest_task: Option<TaskSummary>,
127 pub security: Vec<SecurityFinding>,
128}
129
130#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
131pub struct DocumentSummary {
132 pub id: u64,
133 pub title: String,
134 pub created: String,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
138pub struct DocumentInspector {
139 pub id: u64,
140 pub title: String,
141 pub text: String,
142 pub metadata: Vec<String>,
143}
144
145#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
146pub struct TaskSummary {
147 pub id: Option<u64>,
148 pub status: String,
149 pub note: String,
150}
151
152pub fn init_connection<T: Transport>(
153 client_factory: impl Fn(AppConfig) -> Result<ApiClient<T>, AppError>,
154 paths: &AppPaths,
155 base_url: &str,
156 token: Option<String>,
157 username: Option<String>,
158 password: Option<String>,
159 preferred_output: OutputMode,
160) -> Result<AppConfig, AppError> {
161 let normalized = normalize_url(base_url)?;
162 let token = match token {
163 Some(token) => token,
164 None => {
165 let username = username.ok_or(AppError::MissingCredentials)?;
166 let password = password.ok_or(AppError::MissingCredentials)?;
167 fetch_token(&normalized, &username, &password)?
168 }
169 };
170
171 let config = AppConfig::new(normalized, token, preferred_output)?;
172 let client = client_factory(config.clone())?;
173 client.ping(&config.base_url)?;
174 save_config(paths, &config)?;
175 Ok(config)
176}
177
178pub fn connection_info<T: Transport>(
179 client: &ApiClient<T>,
180 config: &AppConfig,
181 paths: &AppPaths,
182) -> Result<Value, AppError> {
183 let statistics = client
184 .get_json("statistics/", Vec::new())
185 .unwrap_or_else(|error| {
186 json!({
187 "error": error.to_string(),
188 })
189 });
190
191 Ok(json!({
192 "url": config.base_url,
193 "token": config.masked_token(),
194 "config_path": paths.config_path,
195 "statistics": statistics,
196 }))
197}
198
199pub fn load_runtime(paths: &AppPaths) -> Result<(AppConfig, SessionState), AppError> {
200 let config = load_config(paths)?;
201 let session = load_session(paths);
202 Ok((config, session))
203}
204
205pub fn persist_session(paths: &AppPaths, session: &SessionState) -> Result<(), AppError> {
206 save_session(paths, session)
207}
208
209pub fn ping<T: Transport>(client: &ApiClient<T>, config: &AppConfig) -> Result<Value, AppError> {
210 client.ping(&config.base_url)
211}
212
213pub fn list_documents<T: Transport>(
214 client: &ApiClient<T>,
215 query: &DocumentQuery,
216) -> Result<Value, AppError> {
217 client.get_json("documents/", query.to_query_pairs())
218}
219
220pub fn list_all_document_summaries<T: Transport>(
221 client: &ApiClient<T>,
222 query: &DocumentQuery,
223) -> Result<Vec<DocumentSummary>, AppError> {
224 let items = client.paginate("documents/", query.to_query_pairs())?;
225 Ok(items.iter().map(document_summary_from_value).collect())
226}
227
228pub fn document_summaries_from_response(value: &Value) -> Vec<DocumentSummary> {
229 value
230 .get("results")
231 .and_then(Value::as_array)
232 .map(|items| items.iter().map(document_summary_from_value).collect())
233 .unwrap_or_default()
234}
235
236pub fn next_page_url_from_response(value: &Value) -> Option<String> {
237 value
238 .get("next")
239 .and_then(Value::as_str)
240 .map(str::to_string)
241}
242
243pub fn get_document<T: Transport>(
244 client: &ApiClient<T>,
245 document_id: u64,
246) -> Result<Value, AppError> {
247 client.get_json(&format!("documents/{document_id}/"), Vec::new())
248}
249
250pub fn get_document_content<T: Transport>(
251 client: &ApiClient<T>,
252 document_id: u64,
253) -> Result<Value, AppError> {
254 let document = get_document(client, document_id)?;
255 Ok(json!({
256 "id": document_id,
257 "title": document.get("title").and_then(Value::as_str).unwrap_or("Untitled"),
258 "content": document_text_representation(&document),
259 }))
260}
261
262pub fn get_document_inspector<T: Transport>(
263 client: &ApiClient<T>,
264 document_id: u64,
265) -> Result<DocumentInspector, AppError> {
266 let document = get_document(client, document_id)?;
267 Ok(document_inspector_from_value(&document))
268}
269
270pub fn search_documents<T: Transport>(
271 client: &ApiClient<T>,
272 mut query: DocumentQuery,
273 search: String,
274) -> Result<Value, AppError> {
275 query.query = Some(search);
276 list_documents(client, &query)
277}
278
279pub fn upload_document<T: Transport>(
280 client: &ApiClient<T>,
281 request: &UploadRequest,
282) -> Result<Value, AppError> {
283 let mut fields = Vec::new();
284 if let Some(title) = &request.title {
285 fields.push(("title".to_string(), title.clone()));
286 }
287 if let Some(correspondent_id) = request.correspondent_id {
288 fields.push(("correspondent".to_string(), correspondent_id.to_string()));
289 }
290 if let Some(document_type_id) = request.document_type_id {
291 fields.push(("document_type".to_string(), document_type_id.to_string()));
292 }
293 for tag_id in &request.tag_ids {
294 fields.push(("tags".to_string(), tag_id.to_string()));
295 }
296
297 let file = multipart_file_from_path(&request.path)?;
298 client.post_multipart("documents/post_document/", fields, file)
299}
300
301pub fn update_document<T: Transport>(
302 client: &ApiClient<T>,
303 document_id: u64,
304 request: &UpdateRequest,
305) -> Result<Value, AppError> {
306 let mut patch = Map::new();
307 if let Some(title) = &request.title {
308 patch.insert("title".to_string(), json!(title));
309 }
310 if let Some(correspondent_id) = request.correspondent_id {
311 patch.insert("correspondent".to_string(), json!(correspondent_id));
312 }
313 if let Some(document_type_id) = request.document_type_id {
314 patch.insert("document_type".to_string(), json!(document_type_id));
315 }
316 if let Some(tag_ids) = &request.tag_ids {
317 patch.insert("tags".to_string(), json!(tag_ids));
318 }
319 if let Some(created) = &request.created {
320 patch.insert("created".to_string(), json!(created));
321 }
322 if let Some(custom_fields) = &request.custom_fields {
323 patch.insert("custom_fields".to_string(), custom_fields.clone());
324 }
325
326 if patch.is_empty() {
327 return Err(AppError::NoFieldsToUpdate);
328 }
329
330 client.patch_json(&format!("documents/{document_id}/"), Value::Object(patch))
331}
332
333pub fn edit_document<T: Transport>(
334 client: &ApiClient<T>,
335 document_id: u64,
336 request: &UpdateRequest,
337 add_tags: &[String],
338 remove_tags: &[String],
339) -> Result<Value, AppError> {
340 if add_tags.is_empty() && remove_tags.is_empty() {
341 return update_document(client, document_id, request);
342 }
343
344 let document = get_document(client, document_id)?;
345 let existing_tags = document
346 .get("tags")
347 .and_then(Value::as_array)
348 .map(|tags| tags.iter().filter_map(Value::as_u64).collect::<Vec<_>>())
349 .unwrap_or_default();
350
351 let mut resolved_tags = existing_tags;
352 for tag_id in resolve_tag_inputs(client, add_tags)? {
353 if !resolved_tags.contains(&tag_id) {
354 resolved_tags.push(tag_id);
355 }
356 }
357
358 let removed = resolve_tag_inputs(client, remove_tags)?;
359 resolved_tags.retain(|tag_id| !removed.contains(tag_id));
360
361 let mut merged = request.clone();
362 merged.tag_ids = Some(resolved_tags);
363 update_document(client, document_id, &merged)
364}
365
366pub fn delete_document<T: Transport>(
367 client: &ApiClient<T>,
368 document_id: u64,
369) -> Result<(), AppError> {
370 client.delete(&format!("documents/{document_id}/"))
371}
372
373pub fn download_document<T: Transport>(
374 client: &ApiClient<T>,
375 document_id: u64,
376 output_dir: &Path,
377 original: bool,
378) -> Result<PathBuf, AppError> {
379 let params = if original {
380 vec![("original".to_string(), "true".to_string())]
381 } else {
382 Vec::new()
383 };
384 download_asset(client, document_id, "download", output_dir, params)
385}
386
387pub fn download_preview<T: Transport>(
388 client: &ApiClient<T>,
389 document_id: u64,
390 output_dir: &Path,
391) -> Result<PathBuf, AppError> {
392 download_asset(client, document_id, "preview", output_dir, Vec::new())
393}
394
395pub fn download_thumbnail<T: Transport>(
396 client: &ApiClient<T>,
397 document_id: u64,
398 output_dir: &Path,
399) -> Result<PathBuf, AppError> {
400 download_asset(client, document_id, "thumb", output_dir, Vec::new())
401}
402
403pub fn list_tags<T: Transport>(client: &ApiClient<T>) -> Result<Value, AppError> {
404 Ok(Value::Array(client.paginate("tags/", Vec::new())?))
405}
406
407pub fn get_tag<T: Transport>(client: &ApiClient<T>, tag_id: u64) -> Result<Value, AppError> {
408 client.get_json(&format!("tags/{tag_id}/"), Vec::new())
409}
410
411pub fn create_tag<T: Transport>(
412 client: &ApiClient<T>,
413 name: String,
414 color: String,
415 inbox: bool,
416) -> Result<Value, AppError> {
417 client.post_json(
418 "tags/",
419 json!({
420 "name": name,
421 "color": color,
422 "is_inbox_tag": inbox,
423 }),
424 )
425}
426
427pub fn update_tag<T: Transport>(
428 client: &ApiClient<T>,
429 tag_id: u64,
430 request: &TagUpdateRequest,
431) -> Result<Value, AppError> {
432 let mut patch = Map::new();
433 if let Some(name) = &request.name {
434 patch.insert("name".to_string(), json!(name));
435 }
436 if let Some(color) = &request.color {
437 patch.insert("color".to_string(), json!(color));
438 }
439 if let Some(inbox) = request.inbox {
440 patch.insert("is_inbox_tag".to_string(), json!(inbox));
441 }
442
443 if patch.is_empty() {
444 return Err(AppError::NoFieldsToUpdate);
445 }
446
447 client.patch_json(&format!("tags/{tag_id}/"), Value::Object(patch))
448}
449
450pub fn delete_tag<T: Transport>(client: &ApiClient<T>, tag_id: u64) -> Result<(), AppError> {
451 client.delete(&format!("tags/{tag_id}/"))
452}
453
454pub fn list_correspondents<T: Transport>(client: &ApiClient<T>) -> Result<Value, AppError> {
455 Ok(Value::Array(
456 client.paginate("correspondents/", Vec::new())?,
457 ))
458}
459
460pub fn get_correspondent<T: Transport>(
461 client: &ApiClient<T>,
462 correspondent_id: u64,
463) -> Result<Value, AppError> {
464 client.get_json(&format!("correspondents/{correspondent_id}/"), Vec::new())
465}
466
467pub fn create_correspondent<T: Transport>(
468 client: &ApiClient<T>,
469 name: String,
470 matcher: String,
471) -> Result<Value, AppError> {
472 client.post_json(
473 "correspondents/",
474 json!({
475 "name": name,
476 "match": matcher,
477 "matching_algorithm": 0,
478 }),
479 )
480}
481
482pub fn delete_correspondent<T: Transport>(
483 client: &ApiClient<T>,
484 correspondent_id: u64,
485) -> Result<(), AppError> {
486 client.delete(&format!("correspondents/{correspondent_id}/"))
487}
488
489pub fn list_document_types<T: Transport>(client: &ApiClient<T>) -> Result<Value, AppError> {
490 Ok(Value::Array(
491 client.paginate("document_types/", Vec::new())?,
492 ))
493}
494
495pub fn get_document_type<T: Transport>(
496 client: &ApiClient<T>,
497 document_type_id: u64,
498) -> Result<Value, AppError> {
499 client.get_json(&format!("document_types/{document_type_id}/"), Vec::new())
500}
501
502pub fn create_document_type<T: Transport>(
503 client: &ApiClient<T>,
504 name: String,
505 matcher: String,
506) -> Result<Value, AppError> {
507 client.post_json(
508 "document_types/",
509 json!({
510 "name": name,
511 "match": matcher,
512 "matching_algorithm": 0,
513 }),
514 )
515}
516
517pub fn delete_document_type<T: Transport>(
518 client: &ApiClient<T>,
519 document_type_id: u64,
520) -> Result<(), AppError> {
521 client.delete(&format!("document_types/{document_type_id}/"))
522}
523
524pub fn list_tasks<T: Transport>(client: &ApiClient<T>) -> Result<Value, AppError> {
525 Ok(Value::Array(client.paginate("tasks/", Vec::new())?))
526}
527
528pub fn get_task<T: Transport>(client: &ApiClient<T>, task_id: u64) -> Result<Value, AppError> {
529 client.get_json(&format!("tasks/{task_id}/"), Vec::new())
530}
531
532pub fn query_search<T: Transport>(
533 client: &ApiClient<T>,
534 query: String,
535 page_size: u64,
536 page: u64,
537) -> Result<Value, AppError> {
538 client.get_json(
539 "search/",
540 vec![
541 ("query".to_string(), query),
542 ("page_size".to_string(), page_size.to_string()),
543 ("page".to_string(), page.to_string()),
544 ],
545 )
546}
547
548pub fn autocomplete_search<T: Transport>(
549 client: &ApiClient<T>,
550 term: String,
551 limit: u64,
552) -> Result<Value, AppError> {
553 client.get_json(
554 "search/autocomplete/",
555 vec![
556 ("term".to_string(), term),
557 ("limit".to_string(), limit.to_string()),
558 ],
559 )
560}
561
562pub fn bulk_download<T: Transport>(
563 client: &ApiClient<T>,
564 document_ids: &[u64],
565 output_dir: &Path,
566 original: bool,
567) -> Result<Value, AppError> {
568 let mut results = Vec::new();
569 for document_id in document_ids {
570 match download_document(client, *document_id, output_dir, original) {
571 Ok(path) => results.push(json!({
572 "doc_id": document_id,
573 "status": "ok",
574 "path": path,
575 })),
576 Err(error) => results.push(json!({
577 "doc_id": document_id,
578 "status": "error",
579 "error": error.to_string(),
580 })),
581 }
582 }
583 Ok(Value::Array(results))
584}
585
586pub fn bulk_download_zip<T: Transport>(
587 client: &ApiClient<T>,
588 document_ids: &[u64],
589 output_path: &Path,
590 content: &str,
591) -> Result<PathBuf, AppError> {
592 let response = client.post_json_raw(
593 "documents/bulk_download/",
594 json!({
595 "documents": document_ids,
596 "content": content,
597 }),
598 )?;
599
600 if let Some(parent) = output_path.parent() {
601 std::fs::create_dir_all(parent)?;
602 }
603 std::fs::write(output_path, &response.body)?;
604 Ok(output_path.to_path_buf())
605}
606
607pub fn status<T: Transport>(client: &ApiClient<T>, config: &AppConfig) -> Result<Value, AppError> {
608 let ping = ping(client, config)?;
609 let statistics = client.get_json("statistics/", Vec::new())?;
610 let tasks = list_tasks(client)?;
611 Ok(json!({
612 "project": ping,
613 "statistics": statistics,
614 "tasks": tasks,
615 }))
616}
617
618pub fn dashboard<T: Transport>(
619 client: &ApiClient<T>,
620 config: &AppConfig,
621 security: Vec<SecurityFinding>,
622) -> Result<DashboardSnapshot, AppError> {
623 let project = status(client, config)?;
624 let documents = Vec::new();
625 let latest_task = latest_task_summary(&list_tasks(client)?);
626
627 Ok(DashboardSnapshot {
628 project,
629 documents,
630 latest_task,
631 security,
632 })
633}
634
635pub fn document_summary_from_value(document: &Value) -> DocumentSummary {
636 DocumentSummary {
637 id: document
638 .get("id")
639 .and_then(Value::as_u64)
640 .unwrap_or_default(),
641 title: document
642 .get("title")
643 .and_then(Value::as_str)
644 .unwrap_or("Untitled")
645 .to_string(),
646 created: document
647 .get("created")
648 .or_else(|| document.get("created_date"))
649 .and_then(Value::as_str)
650 .unwrap_or("unknown")
651 .to_string(),
652 }
653}
654
655pub fn document_inspector_from_value(document: &Value) -> DocumentInspector {
656 let id = document
657 .get("id")
658 .and_then(Value::as_u64)
659 .unwrap_or_default();
660 let title = document
661 .get("title")
662 .and_then(Value::as_str)
663 .unwrap_or("Untitled")
664 .to_string();
665 let text = document_text_representation(document);
666 let mut metadata = Vec::new();
667
668 if let Some(created) = document
669 .get("created")
670 .or_else(|| document.get("created_date"))
671 .and_then(Value::as_str)
672 {
673 metadata.push(format!("created {created}"));
674 }
675 if let Some(filename) = document
676 .get("original_file_name")
677 .or_else(|| document.get("archived_file_name"))
678 .and_then(Value::as_str)
679 {
680 metadata.push(format!("file {filename}"));
681 }
682 if let Some(page_count) = document.get("page_count").and_then(Value::as_u64) {
683 metadata.push(format!("pages {page_count}"));
684 }
685 if let Some(mime) = document.get("mime_type").and_then(Value::as_str) {
686 metadata.push(format!("mime {mime}"));
687 }
688 if let Some(tags) = document.get("tags").and_then(Value::as_array) {
689 metadata.push(format!("tags {}", tags.len()));
690 }
691
692 DocumentInspector {
693 id,
694 title,
695 text,
696 metadata,
697 }
698}
699
700pub fn document_text_representation(document: &Value) -> String {
701 for key in ["content", "text", "document_text", "body"] {
702 if let Some(text) = document.get(key).and_then(Value::as_str) {
703 let trimmed = text.trim();
704 if !trimmed.is_empty() {
705 return trimmed.to_string();
706 }
707 }
708 }
709
710 let mut lines = Vec::new();
711 if let Some(title) = document.get("title").and_then(Value::as_str) {
712 lines.push(title.to_string());
713 }
714 if let Some(filename) = document
715 .get("original_file_name")
716 .or_else(|| document.get("archived_file_name"))
717 .and_then(Value::as_str)
718 {
719 lines.push(format!("file: {filename}"));
720 }
721 if let Some(note) = document.get("note").and_then(Value::as_str) {
722 lines.push(note.to_string());
723 }
724
725 if lines.is_empty() {
726 "No text content available for this document.".to_string()
727 } else {
728 lines.join("\n")
729 }
730}
731
732pub fn latest_task_summary(tasks: &Value) -> Option<TaskSummary> {
733 tasks
734 .as_array()
735 .and_then(|items| items.first())
736 .map(|task| TaskSummary {
737 id: task.get("id").and_then(Value::as_u64),
738 status: task
739 .get("status")
740 .and_then(Value::as_str)
741 .unwrap_or("unknown")
742 .to_string(),
743 note: task
744 .get("task_file_name")
745 .or_else(|| task.get("type"))
746 .or_else(|| task.get("result"))
747 .and_then(Value::as_str)
748 .unwrap_or_default()
749 .to_string(),
750 })
751}
752
753fn resolve_tag_inputs<T: Transport>(
754 client: &ApiClient<T>,
755 references: &[String],
756) -> Result<Vec<u64>, AppError> {
757 if references.is_empty() {
758 return Ok(Vec::new());
759 }
760
761 let tags = list_tags(client)?;
762 let tag_array = tags
763 .as_array()
764 .ok_or_else(|| AppError::Message("Expected tag list to be an array".to_string()))?;
765 let mut resolved = Vec::new();
766
767 for reference in references {
768 if let Ok(tag_id) = reference.parse::<u64>() {
769 resolved.push(tag_id);
770 continue;
771 }
772
773 let maybe_id = tag_array.iter().find_map(|tag| {
774 let name = tag.get("name").and_then(Value::as_str)?;
775 if name == reference {
776 tag.get("id").and_then(Value::as_u64)
777 } else {
778 None
779 }
780 });
781
782 match maybe_id {
783 Some(tag_id) => resolved.push(tag_id),
784 None => {
785 return Err(AppError::Message(format!(
786 "Unknown tag reference `{reference}`. Use an exact tag name or numeric ID."
787 )))
788 }
789 }
790 }
791
792 Ok(resolved)
793}
794
795pub fn audit_state(
796 paths: &AppPaths,
797 config: Option<&AppConfig>,
798 last_download: Option<String>,
799) -> AuditState {
800 AuditState::new(
801 config.map(|config| config.base_url.clone()),
802 paths.config_permissions_restricted(),
803 last_download,
804 )
805}
806
807fn download_asset<T: Transport>(
808 client: &ApiClient<T>,
809 document_id: u64,
810 asset: &str,
811 output_dir: &Path,
812 params: Vec<(String, String)>,
813) -> Result<PathBuf, AppError> {
814 let response = client.get_raw(&format!("documents/{document_id}/{asset}/"), params)?;
815 std::fs::create_dir_all(output_dir)?;
816
817 let filename = response
818 .headers
819 .get("content-disposition")
820 .and_then(|header| extract_download_filename(header))
821 .map(|value| sanitize_filename(&value))
822 .unwrap_or_else(|| format!("document_{document_id}_{asset}.bin"));
823 let output_path = output_dir.join(filename);
824 std::fs::write(&output_path, response.body)?;
825 Ok(output_path)
826}
827
828fn extract_download_filename(content_disposition: &str) -> Option<String> {
829 let mut fallback = None;
830
831 for part in content_disposition.split(';') {
832 let trimmed = part.trim();
833 if let Some(value) = trimmed.strip_prefix("filename*=") {
834 if let Some(decoded) = decode_rfc5987_filename(value).filter(|value| !value.is_empty())
835 {
836 return Some(decoded);
837 }
838 } else if fallback.is_none() {
839 fallback = trimmed
840 .strip_prefix("filename=")
841 .map(|value| value.trim_matches('"').to_string())
842 .filter(|value| !value.is_empty());
843 }
844 }
845
846 fallback
847}
848
849fn decode_rfc5987_filename(value: &str) -> Option<String> {
850 let trimmed = value.trim_matches('"');
851 let encoded = trimmed
852 .split_once("''")
853 .map(|(_, encoded)| encoded)
854 .unwrap_or(trimmed);
855 percent_decode(encoded)
856}
857
858fn percent_decode(value: &str) -> Option<String> {
859 let mut decoded = Vec::with_capacity(value.len());
860 let mut bytes = value.as_bytes().iter().copied();
861
862 while let Some(byte) = bytes.next() {
863 if byte == b'%' {
864 let high = bytes.next()?;
865 let low = bytes.next()?;
866 let pair = [high, low];
867 let hex = std::str::from_utf8(&pair).ok()?;
868 decoded.push(u8::from_str_radix(hex, 16).ok()?);
869 } else {
870 decoded.push(byte);
871 }
872 }
873
874 String::from_utf8(decoded).ok()
875}
876
877pub fn sanitize_filename(name: &str) -> String {
878 let leaf = Path::new(name)
879 .file_name()
880 .and_then(|part| part.to_str())
881 .unwrap_or("download.bin");
882
883 let sanitized = leaf
884 .chars()
885 .map(|character| {
886 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
887 character
888 } else {
889 '_'
890 }
891 })
892 .collect::<String>();
893
894 if sanitized.is_empty() {
895 "download.bin".to_string()
896 } else {
897 sanitized
898 }
899}