Skip to main content

paperless_cli/
services.rs

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}