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#[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#[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#[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#[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#[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#[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 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 let mut id_to_children: HashMap<Uuid7, Vec<(i64, SimpleTocEntry)>> = HashMap::new();
185 let mut roots: Vec<(i64, SimpleTocEntry)> = Vec::new();
186
187 for (id, node) in nodes.drain(..).rev() {
190 let children = id_to_children.remove(&id).unwrap_or_default();
191
192 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 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}