Skip to main content

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 = "tracing", 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 = "tracing", 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 = "tracing", 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 = "tracing", 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 = "tracing", 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 = "tracing", 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_u64(1);
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                mtime: None,
255            },
256            ..Default::default()
257        };
258
259        let row = info_to_book_row(fp, &info);
260
261        assert_eq!(row.fingerprint, fp.to_string());
262        assert_eq!(row.title, "Test Book");
263        assert_eq!(row.file_path, "/tmp/test.pdf");
264        assert_eq!(row.absolute_path, "/mnt/onboard/tmp/test.pdf");
265        assert_eq!(row.file_size, 1024);
266    }
267
268    #[test]
269    fn test_encode_decode_exact_location() {
270        let loc = TocLocation::Exact(42);
271        let (kind, exact, uri) = encode_location(&loc);
272        assert_eq!(kind, "exact");
273        assert_eq!(exact, Some(42));
274        assert!(uri.is_none());
275
276        let decoded = decode_location(kind, exact, uri.as_deref()).unwrap();
277        assert!(matches!(decoded, TocLocation::Exact(42)));
278    }
279
280    #[test]
281    fn test_encode_decode_uri_location() {
282        let loc = TocLocation::Uri("chapter1.xhtml".to_string());
283        let (kind, exact, uri) = encode_location(&loc);
284        assert_eq!(kind, "uri");
285        assert!(exact.is_none());
286        assert_eq!(uri.as_deref(), Some("chapter1.xhtml"));
287
288        let decoded = decode_location(kind, exact, uri.as_deref()).unwrap();
289        assert!(matches!(decoded, TocLocation::Uri(ref s) if s == "chapter1.xhtml"));
290    }
291
292    #[test]
293    fn test_rows_to_toc_entries_flat() {
294        let rows = vec![
295            TocEntryRow {
296                book_fingerprint: "fp1".to_string(),
297                id: Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
298                parent_id: OptionalUuid7(None),
299                position: 0,
300                title: "Chapter 1".to_string(),
301                location_kind: "exact".to_string(),
302                location_exact: Some(0),
303                location_uri: None,
304            },
305            TocEntryRow {
306                book_fingerprint: "fp1".to_string(),
307                id: Uuid7::from_str("00000000-0000-7000-8000-000000000002").unwrap(),
308                parent_id: OptionalUuid7(None),
309                position: 1,
310                title: "Chapter 2".to_string(),
311                location_kind: "uri".to_string(),
312                location_exact: None,
313                location_uri: Some("ch2.xhtml".to_string()),
314            },
315        ];
316
317        let entries = rows_to_toc_entries(&rows).unwrap();
318        assert_eq!(entries.len(), 2);
319        assert!(matches!(&entries[0], SimpleTocEntry::Leaf(t, _) if t == "Chapter 1"));
320        assert!(matches!(&entries[1], SimpleTocEntry::Leaf(t, _) if t == "Chapter 2"));
321    }
322
323    #[test]
324    fn test_rows_to_toc_entries_nested() {
325        // Parent at id=1, two children at id=2 and id=3
326        let rows = vec![
327            TocEntryRow {
328                book_fingerprint: "fp1".to_string(),
329                id: Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
330                parent_id: OptionalUuid7(None),
331                position: 0,
332                title: "Part 1".to_string(),
333                location_kind: "exact".to_string(),
334                location_exact: Some(0),
335                location_uri: None,
336            },
337            TocEntryRow {
338                book_fingerprint: "fp1".to_string(),
339                id: Uuid7::from_str("00000000-0000-7000-8000-000000000002").unwrap(),
340                parent_id: OptionalUuid7(Some(
341                    Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
342                )),
343                position: 0,
344                title: "Chapter 1".to_string(),
345                location_kind: "exact".to_string(),
346                location_exact: Some(1),
347                location_uri: None,
348            },
349            TocEntryRow {
350                book_fingerprint: "fp1".to_string(),
351                id: Uuid7::from_str("00000000-0000-7000-8000-000000000003").unwrap(),
352                parent_id: OptionalUuid7(Some(
353                    Uuid7::from_str("00000000-0000-7000-8000-000000000001").unwrap(),
354                )),
355                position: 1,
356                title: "Chapter 2".to_string(),
357                location_kind: "exact".to_string(),
358                location_exact: Some(2),
359                location_uri: None,
360            },
361        ];
362
363        let entries = rows_to_toc_entries(&rows).unwrap();
364        assert_eq!(entries.len(), 1);
365
366        match &entries[0] {
367            SimpleTocEntry::Container(title, _, children) => {
368                assert_eq!(title, "Part 1");
369                assert_eq!(children.len(), 2);
370                assert!(matches!(&children[0], SimpleTocEntry::Leaf(t, _) if t == "Chapter 1"));
371                assert!(matches!(&children[1], SimpleTocEntry::Leaf(t, _) if t == "Chapter 2"));
372            }
373            _ => panic!("expected Container"),
374        }
375    }
376}