Skip to main content

paperless_cli/
demo.rs

1use std::collections::BTreeMap;
2
3use serde_json::{json, Value};
4
5use crate::api::{Endpoint, HttpMethod, RequestData, ResponseData, Transport};
6use crate::config::{AppConfig, OutputMode};
7use crate::error::AppError;
8
9const DEMO_BASE_URL: &str = "https://demo.paperless.local";
10
11#[derive(Clone, Default)]
12pub struct DemoTransport;
13
14impl DemoTransport {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20impl Transport for DemoTransport {
21    fn send(&self, request: RequestData) -> Result<ResponseData, AppError> {
22        let (path, query) = match request.endpoint {
23            Endpoint::Relative(path) => (path, request.query),
24            Endpoint::Absolute(url) => {
25                let parsed = reqwest::Url::parse(&url)
26                    .map_err(|_| AppError::Message(format!("Invalid demo URL: {url}")))?;
27                let path = parsed
28                    .path()
29                    .trim_start_matches("/api/")
30                    .trim_start_matches('/')
31                    .to_string();
32                let query = parsed
33                    .query_pairs()
34                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
35                    .collect::<Vec<_>>();
36                (path, query)
37            }
38        };
39
40        dispatch_demo_request(request.method, &path, &query, request.json_body)
41    }
42}
43
44pub fn demo_config(output: OutputMode) -> AppConfig {
45    AppConfig::new(DEMO_BASE_URL, "demo-token-value", output)
46        .expect("demo config should always be valid")
47}
48
49fn dispatch_demo_request(
50    method: HttpMethod,
51    path: &str,
52    query: &[(String, String)],
53    json_body: Option<Value>,
54) -> Result<ResponseData, AppError> {
55    let normalized = path.trim_matches('/');
56    let segments = normalized
57        .split('/')
58        .filter(|segment| !segment.is_empty())
59        .collect::<Vec<_>>();
60
61    match (method, segments.as_slice()) {
62        (HttpMethod::Get, ["status"]) => ok_json(
63            normalized,
64            json!({
65                "version": "demo-2026.4",
66                "demo": true,
67            }),
68        ),
69        (HttpMethod::Get, ["statistics"]) => ok_json(
70            normalized,
71            json!({
72                "documents_total": demo_documents().len(),
73                "documents_inbox": 2,
74                "storage_size": "42 MB",
75                "demo": true,
76            }),
77        ),
78        (HttpMethod::Get, ["documents"]) => paginated_documents(query),
79        (HttpMethod::Get, ["documents", id]) => document_detail(id),
80        (HttpMethod::Get, ["documents", id, "download"]) => {
81            document_binary(id, "download", "application/pdf")
82        }
83        (HttpMethod::Get, ["documents", id, "preview"]) => {
84            document_binary(id, "preview", "application/pdf")
85        }
86        (HttpMethod::Get, ["documents", id, "thumb"]) => document_binary(id, "thumb", "image/png"),
87        (HttpMethod::Post, ["documents", "post_document"]) => ok_json(
88            normalized,
89            json!({
90                "task_id": "demo-task-upload-001",
91                "status": "queued",
92                "demo": true,
93            }),
94        ),
95        (HttpMethod::Patch, ["documents", id]) | (HttpMethod::Put, ["documents", id]) => {
96            patch_document(id, json_body)
97        }
98        (HttpMethod::Delete, ["documents", _id]) => empty_response(normalized),
99        (HttpMethod::Get, ["tags"]) => ok_json(normalized, Value::Array(demo_tags())),
100        (HttpMethod::Get, ["tags", id]) => item_detail(id, demo_tags(), normalized),
101        (HttpMethod::Post, ["tags"]) => create_tag_response(json_body),
102        (HttpMethod::Patch, ["tags", id]) => {
103            patch_named_item(id, demo_tags(), normalized, json_body)
104        }
105        (HttpMethod::Delete, ["tags", _id]) => empty_response(normalized),
106        (HttpMethod::Get, ["correspondents"]) => {
107            ok_json(normalized, Value::Array(demo_correspondents()))
108        }
109        (HttpMethod::Get, ["correspondents", id]) => {
110            item_detail(id, demo_correspondents(), normalized)
111        }
112        (HttpMethod::Post, ["correspondents"]) => create_named_matcher(normalized, json_body),
113        (HttpMethod::Delete, ["correspondents", _id]) => empty_response(normalized),
114        (HttpMethod::Get, ["document_types"]) => {
115            ok_json(normalized, Value::Array(demo_document_types()))
116        }
117        (HttpMethod::Get, ["document_types", id]) => {
118            item_detail(id, demo_document_types(), normalized)
119        }
120        (HttpMethod::Post, ["document_types"]) => create_named_matcher(normalized, json_body),
121        (HttpMethod::Delete, ["document_types", _id]) => empty_response(normalized),
122        (HttpMethod::Get, ["tasks"]) => ok_json(normalized, Value::Array(demo_tasks())),
123        (HttpMethod::Get, ["tasks", id]) => item_detail(id, demo_tasks(), normalized),
124        (HttpMethod::Get, ["search"]) => search_documents(query),
125        (HttpMethod::Get, ["search", "autocomplete"]) => autocomplete(query),
126        (HttpMethod::Post, ["documents", "bulk_download"]) => ok_binary(
127            normalized,
128            vec![0x50, 0x4b, 0x03, 0x04, b'D', b'E', b'M', b'O'],
129            "application/zip",
130            Some("attachment; filename=\"paperless-demo-export.zip\"".to_string()),
131        ),
132        _ => Err(AppError::Http {
133            status: 404,
134            url: demo_url(normalized),
135            message: format!("Demo endpoint not implemented: {normalized}"),
136        }),
137    }
138}
139
140fn paginated_documents(query: &[(String, String)]) -> Result<ResponseData, AppError> {
141    let all = filter_documents(query);
142    let page = query_value(query, "page")
143        .and_then(|value| value.parse::<usize>().ok())
144        .unwrap_or(1)
145        .max(1);
146    let page_size = query_value(query, "page_size")
147        .and_then(|value| value.parse::<usize>().ok())
148        .unwrap_or(25)
149        .max(1);
150    let start = (page - 1) * page_size;
151    let end = (start + page_size).min(all.len());
152    let items = if start < all.len() {
153        all[start..end].to_vec()
154    } else {
155        Vec::new()
156    };
157    let next = (end < all.len()).then(|| {
158        format!(
159            "{}/api/documents/?page={}&page_size={}",
160            DEMO_BASE_URL,
161            page + 1,
162            page_size
163        )
164    });
165
166    ok_json(
167        &format!("documents/?page={page}&page_size={page_size}"),
168        json!({
169            "count": all.len(),
170            "next": next,
171            "previous": (page > 1).then(|| format!("{}/api/documents/?page={}&page_size={}", DEMO_BASE_URL, page - 1, page_size)),
172            "results": items,
173        }),
174    )
175}
176
177fn document_detail(id: &str) -> Result<ResponseData, AppError> {
178    let id = parse_id(id)?;
179    let document = demo_documents()
180        .into_iter()
181        .find(|document| document.get("id").and_then(Value::as_u64) == Some(id))
182        .ok_or_else(|| AppError::Http {
183            status: 404,
184            url: demo_url(&format!("documents/{id}/")),
185            message: "Resource not found.".to_string(),
186        })?;
187    ok_json(&format!("documents/{id}/"), document)
188}
189
190fn document_binary(id: &str, asset: &str, mime: &str) -> Result<ResponseData, AppError> {
191    let id = parse_id(id)?;
192    let document = demo_documents()
193        .into_iter()
194        .find(|document| document.get("id").and_then(Value::as_u64) == Some(id))
195        .ok_or_else(|| AppError::Http {
196            status: 404,
197            url: demo_url(&format!("documents/{id}/{asset}/")),
198            message: "Resource not found.".to_string(),
199        })?;
200    let title = document
201        .get("title")
202        .and_then(Value::as_str)
203        .unwrap_or("demo-document");
204    let filename = if asset == "thumb" {
205        format!("{}.png", slugify(title))
206    } else {
207        format!("{}.pdf", slugify(title))
208    };
209    let bytes = if asset == "thumb" {
210        b"PNG DEMO".to_vec()
211    } else {
212        b"%PDF-1.4\n% DEMO\n".to_vec()
213    };
214    ok_binary(
215        &format!("documents/{id}/{asset}/"),
216        bytes,
217        mime,
218        Some(format!("attachment; filename=\"{filename}\"")),
219    )
220}
221
222fn patch_document(id: &str, json_body: Option<Value>) -> Result<ResponseData, AppError> {
223    let id = parse_id(id)?;
224    let mut document = demo_documents()
225        .into_iter()
226        .find(|document| document.get("id").and_then(Value::as_u64) == Some(id))
227        .ok_or_else(|| AppError::Http {
228            status: 404,
229            url: demo_url(&format!("documents/{id}/")),
230            message: "Resource not found.".to_string(),
231        })?;
232
233    if let Some(Value::Object(patch)) = json_body {
234        for (key, value) in patch {
235            document[key] = value;
236        }
237    }
238
239    ok_json(&format!("documents/{id}/"), document)
240}
241
242fn item_detail(id: &str, items: Vec<Value>, path: &str) -> Result<ResponseData, AppError> {
243    let id = parse_id(id)?;
244    let item = items
245        .into_iter()
246        .find(|item| item.get("id").and_then(Value::as_u64) == Some(id))
247        .ok_or_else(|| AppError::Http {
248            status: 404,
249            url: demo_url(path),
250            message: "Resource not found.".to_string(),
251        })?;
252    ok_json(path, item)
253}
254
255fn create_tag_response(json_body: Option<Value>) -> Result<ResponseData, AppError> {
256    let body = json_body.unwrap_or_else(|| json!({}));
257    ok_json(
258        "tags/",
259        json!({
260            "id": 999,
261            "name": body.get("name").and_then(Value::as_str).unwrap_or("demo-tag"),
262            "color": body.get("color").and_then(Value::as_str).unwrap_or("#7dd3fc"),
263            "is_inbox_tag": body.get("is_inbox_tag").and_then(Value::as_bool).unwrap_or(false),
264            "demo": true,
265        }),
266    )
267}
268
269fn patch_named_item(
270    id: &str,
271    items: Vec<Value>,
272    path: &str,
273    json_body: Option<Value>,
274) -> Result<ResponseData, AppError> {
275    let id = parse_id(id)?;
276    let mut item = items
277        .into_iter()
278        .find(|item| item.get("id").and_then(Value::as_u64) == Some(id))
279        .ok_or_else(|| AppError::Http {
280            status: 404,
281            url: demo_url(path),
282            message: "Resource not found.".to_string(),
283        })?;
284    if let Some(Value::Object(patch)) = json_body {
285        for (key, value) in patch {
286            item[key] = value;
287        }
288    }
289    ok_json(path, item)
290}
291
292fn create_named_matcher(path: &str, json_body: Option<Value>) -> Result<ResponseData, AppError> {
293    let body = json_body.unwrap_or_else(|| json!({}));
294    ok_json(
295        path,
296        json!({
297            "id": 999,
298            "name": body.get("name").and_then(Value::as_str).unwrap_or("demo-item"),
299            "match": body.get("match").and_then(Value::as_str).unwrap_or(""),
300            "matching_algorithm": body.get("matching_algorithm").and_then(Value::as_i64).unwrap_or(0),
301            "demo": true,
302        }),
303    )
304}
305
306fn search_documents(query: &[(String, String)]) -> Result<ResponseData, AppError> {
307    let search = query_value(query, "query")
308        .unwrap_or_default()
309        .to_ascii_lowercase();
310    let results = demo_documents()
311        .into_iter()
312        .filter(|document| matches_query(document, &search))
313        .collect::<Vec<_>>();
314    ok_json(
315        "search/",
316        json!({
317            "count": results.len(),
318            "results": results,
319        }),
320    )
321}
322
323fn autocomplete(query: &[(String, String)]) -> Result<ResponseData, AppError> {
324    let term = query_value(query, "term")
325        .unwrap_or_default()
326        .to_ascii_lowercase();
327    let limit = query_value(query, "limit")
328        .and_then(|value| value.parse::<usize>().ok())
329        .unwrap_or(10);
330    let suggestions = demo_documents()
331        .into_iter()
332        .filter_map(|document| {
333            let title = document.get("title").and_then(Value::as_str)?;
334            title
335                .to_ascii_lowercase()
336                .contains(&term)
337                .then(|| json!(title))
338        })
339        .take(limit)
340        .collect::<Vec<_>>();
341    ok_json("search/autocomplete/", Value::Array(suggestions))
342}
343
344fn ok_json(path: &str, body: Value) -> Result<ResponseData, AppError> {
345    Ok(ResponseData {
346        status: 200,
347        url: demo_url(path),
348        headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
349        body: serde_json::to_vec(&body)?,
350    })
351}
352
353fn ok_binary(
354    path: &str,
355    body: Vec<u8>,
356    mime: &str,
357    content_disposition: Option<String>,
358) -> Result<ResponseData, AppError> {
359    let mut headers = BTreeMap::from([("content-type".to_string(), mime.to_string())]);
360    if let Some(value) = content_disposition {
361        headers.insert("content-disposition".to_string(), value);
362    }
363    Ok(ResponseData {
364        status: 200,
365        url: demo_url(path),
366        headers,
367        body,
368    })
369}
370
371fn empty_response(path: &str) -> Result<ResponseData, AppError> {
372    Ok(ResponseData {
373        status: 204,
374        url: demo_url(path),
375        headers: BTreeMap::new(),
376        body: Vec::new(),
377    })
378}
379
380fn demo_url(path: &str) -> String {
381    format!("{}/api/{}", DEMO_BASE_URL, path.trim_start_matches('/'))
382}
383
384fn query_value(query: &[(String, String)], key: &str) -> Option<String> {
385    query
386        .iter()
387        .find(|(candidate, _)| candidate == key)
388        .map(|(_, value)| value.clone())
389}
390
391fn parse_id(raw: &str) -> Result<u64, AppError> {
392    raw.parse::<u64>()
393        .map_err(|_| AppError::Message(format!("Invalid demo identifier: {raw}")))
394}
395
396fn filter_documents(query: &[(String, String)]) -> Vec<Value> {
397    let search = query_value(query, "query")
398        .unwrap_or_default()
399        .to_ascii_lowercase();
400    demo_documents()
401        .into_iter()
402        .filter(|document| matches_query(document, &search))
403        .collect()
404}
405
406fn matches_query(document: &Value, search: &str) -> bool {
407    if search.is_empty() {
408        return true;
409    }
410
411    [
412        document
413            .get("title")
414            .and_then(Value::as_str)
415            .unwrap_or_default(),
416        document
417            .get("content")
418            .and_then(Value::as_str)
419            .unwrap_or_default(),
420        document
421            .get("original_file_name")
422            .and_then(Value::as_str)
423            .unwrap_or_default(),
424    ]
425    .iter()
426    .any(|field| field.to_ascii_lowercase().contains(search))
427}
428
429fn slugify(value: &str) -> String {
430    value
431        .chars()
432        .map(|char| match char {
433            'a'..='z' | 'A'..='Z' | '0'..='9' => char.to_ascii_lowercase(),
434            _ => '-',
435        })
436        .collect::<String>()
437        .split('-')
438        .filter(|part| !part.is_empty())
439        .collect::<Vec<_>>()
440        .join("-")
441}
442
443fn demo_documents() -> Vec<Value> {
444    vec![
445        json!({
446            "id": 301,
447            "title": "Quarterly Tax Summary Q1 2026",
448            "created": "2026-04-02",
449            "content": "Quarterly tax summary for the period ending March 2026.\nRevenue: £84,210.13\nTax due: £6,418.22",
450            "original_file_name": "quarterly-tax-summary-q1-2026.pdf",
451            "page_count": 3,
452            "mime_type": "application/pdf",
453            "tags": [22, 59],
454            "correspondent": 11,
455            "document_type": 7,
456        }),
457        json!({
458            "id": 302,
459            "title": "Consulting Invoice March 2026",
460            "created": "2026-03-26",
461            "content": "Invoice for March consulting services.\nAmount due: £12,500.00\nDue date: 2026-04-10",
462            "original_file_name": "consulting-invoice-2026-03.pdf",
463            "page_count": 1,
464            "mime_type": "application/pdf",
465            "tags": [22],
466            "correspondent": 12,
467            "document_type": 8,
468        }),
469        json!({
470            "id": 303,
471            "title": "Project Delivery Invoice 25-26-010",
472            "created": "2026-04-10",
473            "content": "Invoice REF-25-26-010\nProject delivery and advisory services.\nTotal: £7,840.00",
474            "original_file_name": "project-delivery-invoice-25-26-010.pdf",
475            "page_count": 2,
476            "mime_type": "application/pdf",
477            "tags": [60, 22],
478            "correspondent": 13,
479            "document_type": 8,
480        }),
481        json!({
482            "id": 304,
483            "title": "Business Account Statement April 2026",
484            "created": "2026-04-14",
485            "content": "Opening balance £18,902.14\nClosing balance £24,411.91\n22 transactions recorded.",
486            "original_file_name": "business-account-statement-april-2026.pdf",
487            "page_count": 5,
488            "mime_type": "application/pdf",
489            "tags": [61],
490            "correspondent": 14,
491            "document_type": 9,
492        }),
493        json!({
494            "id": 305,
495            "title": "Equipment Purchase Receipt",
496            "created": "2026-02-18",
497            "content": "Receipt for office equipment purchase.\nVendor: Hardware Supplier Ltd\nTotal paid: £2,149.00",
498            "original_file_name": "equipment-purchase-receipt.pdf",
499            "page_count": 1,
500            "mime_type": "application/pdf",
501            "tags": [59],
502            "correspondent": 15,
503            "document_type": 10,
504        }),
505        json!({
506            "id": 306,
507            "title": "Office Rent Invoice April 2026",
508            "created": "2026-04-01",
509            "content": "Monthly office rent invoice.\nPeriod: April 2026\nAmount: £1,250.00",
510            "original_file_name": "office-rent-invoice-april-2026.pdf",
511            "page_count": 1,
512            "mime_type": "application/pdf",
513            "tags": [22, 61],
514            "correspondent": 16,
515            "document_type": 8,
516        }),
517    ]
518}
519
520fn demo_tags() -> Vec<Value> {
521    vec![
522        json!({"id": 22, "name": "finance", "color": "#38bdf8", "is_inbox_tag": false}),
523        json!({"id": 59, "name": "tax", "color": "#f59e0b", "is_inbox_tag": false}),
524        json!({"id": 60, "name": "TODO", "color": "#f97316", "is_inbox_tag": true}),
525        json!({"id": 61, "name": "banking", "color": "#10b981", "is_inbox_tag": false}),
526    ]
527}
528
529fn demo_correspondents() -> Vec<Value> {
530    vec![
531        json!({"id": 11, "name": "Revenue Authority", "match": "tax summary", "matching_algorithm": 0}),
532        json!({"id": 12, "name": "Consulting Client Ltd", "match": "consulting invoice", "matching_algorithm": 0}),
533        json!({"id": 13, "name": "Project Customer Ltd", "match": "project delivery", "matching_algorithm": 0}),
534        json!({"id": 14, "name": "Business Bank", "match": "account statement", "matching_algorithm": 0}),
535        json!({"id": 15, "name": "Hardware Supplier Ltd", "match": "equipment purchase", "matching_algorithm": 0}),
536        json!({"id": 16, "name": "Office Landlord Ltd", "match": "office rent", "matching_algorithm": 0}),
537    ]
538}
539
540fn demo_document_types() -> Vec<Value> {
541    vec![
542        json!({"id": 7, "name": "tax", "match": "vat return", "matching_algorithm": 0}),
543        json!({"id": 8, "name": "invoice", "match": "invoice", "matching_algorithm": 0}),
544        json!({"id": 9, "name": "statement", "match": "statement", "matching_algorithm": 0}),
545        json!({"id": 10, "name": "receipt", "match": "receipt", "matching_algorithm": 0}),
546    ]
547}
548
549fn demo_tasks() -> Vec<Value> {
550    vec![
551        json!({
552            "id": 901,
553            "status": "SUCCESS",
554            "task_file_name": "project-delivery-invoice-25-26-010.pdf",
555            "type": "consume_file",
556            "result": "Imported into demo library"
557        }),
558        json!({
559            "id": 902,
560            "status": "PENDING",
561            "task_file_name": "workspace-rent-april-2026.pdf",
562            "type": "consume_file",
563            "result": ""
564        }),
565    ]
566}