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}