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 = "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#[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#[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#[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#[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#[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 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_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 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}