cadmus_core/library/db/
conversion.rs

1use super::models::{BookRow, ReadingStateRow, TocEntryRow};
2use crate::document::{SimpleTocEntry, TocLocation};
3use crate::helpers::Fp;
4use crate::metadata::{Info, ReaderInfo};
5use anyhow::{Context as AnyhowContext, Error};
6
7/// Convert Info struct to BookRow for database insertion.
8#[cfg_attr(feature = "otel", tracing::instrument(skip(fp, info), fields(fingerprint = %fp), ret(level = tracing::Level::TRACE)))]
9pub fn info_to_book_row(fp: Fp, info: &Info) -> BookRow {
10    BookRow {
11        fingerprint: fp.to_string(),
12        title: info.title.clone(),
13        subtitle: info.subtitle.clone(),
14        year: info.year.clone(),
15        language: info.language.clone(),
16        publisher: info.publisher.clone(),
17        series: info.series.clone(),
18        edition: info.edition.clone(),
19        volume: info.volume.clone(),
20        number: info.number.clone(),
21        identifier: info.identifier.clone(),
22        file_path: info.file.path.display().to_string(),
23        absolute_path: info.file.absolute_path.display().to_string(),
24        file_kind: info.file.kind.clone(),
25        file_size: info.file.size as i64,
26        added_at: info.added.into(),
27    }
28}
29
30/// Extract authors from Info.author (comma-separated string)
31#[cfg_attr(feature = "otel", tracing::instrument(skip(author_str), ret(level = tracing::Level::TRACE)))]
32pub fn extract_authors(author_str: &str) -> Vec<String> {
33    author_str
34        .split(", ")
35        .filter(|s| !s.is_empty())
36        .map(|s| s.to_string())
37        .collect()
38}
39
40/// Convert ReaderInfo to ReadingStateRow for database insertion.
41#[cfg_attr(feature = "otel", tracing::instrument(skip(fp, reader_info), fields(fingerprint = %fp), ret(level = tracing::Level::TRACE)))]
42pub fn reader_info_to_reading_state_row(fp: Fp, reader_info: &ReaderInfo) -> ReadingStateRow {
43    let (page_offset_x, page_offset_y) = if let Some(offset) = reader_info.page_offset {
44        (Some(offset.x as i64), Some(offset.y as i64))
45    } else {
46        (None, None)
47    };
48
49    let cropping_margins_json = reader_info
50        .cropping_margins
51        .as_ref()
52        .and_then(|cm| serde_json::to_string(cm).ok());
53
54    let zoom_mode = reader_info
55        .zoom_mode
56        .as_ref()
57        .and_then(|zm| serde_json::to_string(zm).ok());
58
59    let scroll_mode = reader_info
60        .scroll_mode
61        .as_ref()
62        .and_then(|sm| serde_json::to_string(sm).ok());
63
64    let text_align = reader_info
65        .text_align
66        .as_ref()
67        .and_then(|ta| serde_json::to_string(ta).ok());
68
69    let page_names_json = if !reader_info.page_names.is_empty() {
70        serde_json::to_string(&reader_info.page_names).ok()
71    } else {
72        None
73    };
74
75    let bookmarks_json = if !reader_info.bookmarks.is_empty() {
76        serde_json::to_string(&reader_info.bookmarks).ok()
77    } else {
78        None
79    };
80
81    let annotations_json = if !reader_info.annotations.is_empty() {
82        serde_json::to_string(&reader_info.annotations).ok()
83    } else {
84        None
85    };
86
87    ReadingStateRow {
88        fingerprint: fp.to_string(),
89        opened: reader_info.opened.into(),
90        current_page: reader_info.current_page as i64,
91        pages_count: reader_info.pages_count as i64,
92        finished: if reader_info.finished { 1 } else { 0 },
93        dithered: if reader_info.dithered { 1 } else { 0 },
94        zoom_mode,
95        scroll_mode,
96        page_offset_x,
97        page_offset_y,
98        rotation: reader_info.rotation.map(|r| r as i64),
99        cropping_margins_json,
100        margin_width: reader_info.margin_width.map(|mw| mw as i64),
101        screen_margin_width: reader_info.screen_margin_width.map(|smw| smw as i64),
102        font_family: reader_info.font_family.clone(),
103        font_size: reader_info.font_size.map(|fs| fs as f64),
104        text_align,
105        line_height: reader_info.line_height.map(|lh| lh as f64),
106        contrast_exponent: reader_info.contrast_exponent.map(|ce| ce as f64),
107        contrast_gray: reader_info.contrast_gray.map(|cg| cg as f64),
108        page_names_json,
109        bookmarks_json,
110        annotations_json,
111    }
112}
113
114/// Encode a `TocLocation` into the `(location_kind, location_exact, location_uri)` column triple.
115#[cfg_attr(feature = "otel", tracing::instrument(skip(loc), ret(level = tracing::Level::TRACE)))]
116pub fn encode_location(loc: &TocLocation) -> (&'static str, Option<i64>, Option<String>) {
117    match loc {
118        TocLocation::Exact(n) => ("exact", Some(*n as i64), None),
119        TocLocation::Uri(uri) => ("uri", None, Some(uri.clone())),
120    }
121}
122
123/// Decode the `(location_kind, location_exact, location_uri)` column triple back to a `TocLocation`.
124#[cfg_attr(feature = "otel", tracing::instrument(skip(kind, exact, uri), ret(level = tracing::Level::TRACE)))]
125pub fn decode_location(
126    kind: &str,
127    exact: Option<i64>,
128    uri: Option<&str>,
129) -> Result<TocLocation, Error> {
130    match kind {
131        "exact" => {
132            let n = exact.with_context(|| "location_exact is NULL for kind='exact'")?;
133            Ok(TocLocation::Exact(n as usize))
134        }
135        "uri" => {
136            let s = uri
137                .with_context(|| "location_uri is NULL for kind='uri'")?
138                .to_string();
139            Ok(TocLocation::Uri(s))
140        }
141        other => anyhow::bail!("unknown location_kind: {}", other),
142    }
143}
144
145/// Reconstruct a `Vec<SimpleTocEntry>` tree from a flat list of rows ordered by `id`.
146///
147/// Rows must be ordered such that every parent appears before its children (pre-order),
148/// which is guaranteed by inserting parents first and ordering by `id ASC`.
149#[cfg_attr(feature = "otel", tracing::instrument(skip(rows), fields(entry_count = rows.len()), ret(level = tracing::Level::TRACE)))]
150pub fn rows_to_toc_entries(rows: &[TocEntryRow]) -> Result<Vec<SimpleTocEntry>, Error> {
151    // Build a map from row id → (entry, parent_id, position) so we can reconstruct
152    // the tree in a single pass.
153    use crate::db::types::Uuid7;
154    use std::collections::HashMap;
155
156    struct Node {
157        entry: SimpleTocEntry,
158        parent_id: Option<Uuid7>,
159        position: i64,
160    }
161
162    let mut nodes: Vec<(Uuid7, Node)> = rows
163        .iter()
164        .map(|row| {
165            let location = decode_location(
166                &row.location_kind,
167                row.location_exact,
168                row.location_uri.as_deref(),
169            )?;
170            let entry = SimpleTocEntry::Leaf(row.title.clone(), location);
171            Ok((
172                row.id.clone(),
173                Node {
174                    entry,
175                    parent_id: row.parent_id.0.clone(),
176                    position: row.position,
177                },
178            ))
179        })
180        .collect::<Result<_, Error>>()?;
181
182    // Sort children into their parents. We process in reverse so we can pop from the
183    // end while building child lists.
184    let mut id_to_children: HashMap<Uuid7, Vec<(i64, SimpleTocEntry)>> = HashMap::new();
185    let mut roots: Vec<(i64, SimpleTocEntry)> = Vec::new();
186
187    // Process in reverse pre-order: children come after parents in the flat list,
188    // so we attach in reverse to preserve position ordering after the sort below.
189    for (id, node) in nodes.drain(..).rev() {
190        let children = id_to_children.remove(&id).unwrap_or_default();
191
192        // Promote Leaf to Container if it has children.
193        let entry = if children.is_empty() {
194            node.entry
195        } else {
196            let mut sorted = children;
197            sorted.sort_by_key(|(pos, _)| *pos);
198            let child_entries = sorted.into_iter().map(|(_, e)| e).collect();
199            match node.entry {
200                SimpleTocEntry::Leaf(title, loc) => {
201                    SimpleTocEntry::Container(title, loc, child_entries)
202                }
203                SimpleTocEntry::Container(title, loc, _) => {
204                    SimpleTocEntry::Container(title, loc, child_entries)
205                }
206            }
207        };
208
209        match node.parent_id {
210            Some(pid) => {
211                id_to_children
212                    .entry(pid)
213                    .or_default()
214                    .push((node.position, entry));
215            }
216            None => roots.push((node.position, entry)),
217        }
218    }
219
220    roots.sort_by_key(|(pos, _)| *pos);
221    Ok(roots.into_iter().map(|(_, e)| e).collect())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::db::types::{OptionalUuid7, Uuid7};
228    use crate::document::TocLocation;
229    use crate::metadata::FileInfo;
230    use std::path::PathBuf;
231    use std::str::FromStr;
232
233    #[test]
234    fn test_extract_authors() {
235        assert_eq!(
236            extract_authors("John Doe, Jane Smith"),
237            vec!["John Doe", "Jane Smith"]
238        );
239        assert_eq!(extract_authors("Single Author"), vec!["Single Author"]);
240        assert_eq!(extract_authors(""), Vec::<String>::new());
241    }
242
243    #[test]
244    fn test_info_to_book_row_roundtrip() {
245        let fp = Fp::from_str("0000000000000001").unwrap();
246        let info = Info {
247            title: "Test Book".to_string(),
248            author: "Test Author".to_string(),
249            file: FileInfo {
250                path: PathBuf::from("/tmp/test.pdf"),
251                absolute_path: PathBuf::from("/mnt/onboard/tmp/test.pdf"),
252                kind: "pdf".to_string(),
253                size: 1024,
254            },
255            ..Default::default()
256        };
257
258        let row = info_to_book_row(fp, &info);
259
260        assert_eq!(row.fingerprint, "0000000000000001");
261        assert_eq!(row.title, "Test Book");
262        assert_eq!(row.file_path, "/tmp/test.pdf");
263        assert_eq!(row.absolute_path, "/mnt/onboard/tmp/test.pdf");
264        assert_eq!(row.file_size, 1024);
265    }
266
267    #[test]
268    fn test_encode_decode_exact_location() {
269        let loc = TocLocation::Exact(42);
270        let (kind, exact, uri) = encode_location(&loc);
271        assert_eq!(kind, "exact");
272        assert_eq!(exact, Some(42));
273        assert!(uri.is_none());
274
275        let decoded = decode_location(kind, exact, uri.as_deref()).unwrap();
276        assert!(matches!(decoded, TocLocation::Exact(42)));
277    }
278
279    #[test]
280    fn test_encode_decode_uri_location() {
281        let loc = TocLocation::Uri("chapter1.xhtml".to_string());
282        let (kind, exact, uri) = encode_location(&loc);
283        assert_eq!(kind, "uri");
284        assert!(exact.is_none());
285        assert_eq!(uri.as_deref(), Some("chapter1.xhtml"));
286
287        let decoded = decode_location(kind, exact, uri.as_deref()).unwrap();
288        assert!(matches!(decoded, TocLocation::Uri(ref s) if s == "chapter1.xhtml"));
289    }
290
291    #[test]
292    fn test_rows_to_toc_entries_flat() {
293        let rows = vec![
294            TocEntryRow {
295                book_fingerprint: "fp1".to_string(),
296                id: Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
297                parent_id: OptionalUuid7(None),
298                position: 0,
299                title: "Chapter 1".to_string(),
300                location_kind: "exact".to_string(),
301                location_exact: Some(0),
302                location_uri: None,
303            },
304            TocEntryRow {
305                book_fingerprint: "fp1".to_string(),
306                id: Uuid7::from_str("00000000-0000-7000-8000-000000000002").unwrap(),
307                parent_id: OptionalUuid7(None),
308                position: 1,
309                title: "Chapter 2".to_string(),
310                location_kind: "uri".to_string(),
311                location_exact: None,
312                location_uri: Some("ch2.xhtml".to_string()),
313            },
314        ];
315
316        let entries = rows_to_toc_entries(&rows).unwrap();
317        assert_eq!(entries.len(), 2);
318        assert!(matches!(&entries[0], SimpleTocEntry::Leaf(t, _) if t == "Chapter 1"));
319        assert!(matches!(&entries[1], SimpleTocEntry::Leaf(t, _) if t == "Chapter 2"));
320    }
321
322    #[test]
323    fn test_rows_to_toc_entries_nested() {
324        // Parent at id=1, two children at id=2 and id=3
325        let rows = vec![
326            TocEntryRow {
327                book_fingerprint: "fp1".to_string(),
328                id: Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
329                parent_id: OptionalUuid7(None),
330                position: 0,
331                title: "Part 1".to_string(),
332                location_kind: "exact".to_string(),
333                location_exact: Some(0),
334                location_uri: None,
335            },
336            TocEntryRow {
337                book_fingerprint: "fp1".to_string(),
338                id: Uuid7::from_str("00000000-0000-7000-8000-000000000002").unwrap(),
339                parent_id: OptionalUuid7(Some(
340                    Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
341                )),
342                position: 0,
343                title: "Chapter 1".to_string(),
344                location_kind: "exact".to_string(),
345                location_exact: Some(1),
346                location_uri: None,
347            },
348            TocEntryRow {
349                book_fingerprint: "fp1".to_string(),
350                id: Uuid7::from_str("00000000-0000-7000-8000-000000000003").unwrap(),
351                parent_id: OptionalUuid7(Some(
352                    Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
353                )),
354                position: 1,
355                title: "Chapter 2".to_string(),
356                location_kind: "exact".to_string(),
357                location_exact: Some(2),
358                location_uri: None,
359            },
360        ];
361
362        let entries = rows_to_toc_entries(&rows).unwrap();
363        assert_eq!(entries.len(), 1);
364
365        match &entries[0] {
366            SimpleTocEntry::Container(title, _, children) => {
367                assert_eq!(title, "Part 1");
368                assert_eq!(children.len(), 2);
369                assert!(matches!(&children[0], SimpleTocEntry::Leaf(t, _) if t == "Chapter 1"));
370                assert!(matches!(&children[1], SimpleTocEntry::Leaf(t, _) if t == "Chapter 2"));
371            }
372            _ => panic!("expected Container"),
373        }
374    }
375}