Skip to main content

verso/store/
books.rs

1use rusqlite::{params, Connection, OptionalExtension};
2
3#[derive(Debug, Clone)]
4pub struct BookRow {
5    pub stable_id: Option<String>,
6    pub file_hash: Option<String>,
7    pub title_norm: String,
8    pub author_norm: Option<String>,
9    pub path: String,
10    pub title: String,
11    pub author: Option<String>,
12    pub language: Option<String>,
13    pub publisher: Option<String>,
14    pub published_at: Option<String>,
15    pub word_count: Option<u64>,
16    pub page_count: Option<u64>,
17    pub parse_error: Option<String>,
18}
19
20impl BookRow {
21    /// Build a fixture row keyed by a short name. For tests only.
22    pub fn new_fixture(name: &str) -> Self {
23        Self {
24            stable_id: Some(format!("urn:fixture:{name}")),
25            file_hash: Some(format!("{name}-hash")),
26            title_norm: format!("fixture {name}"),
27            author_norm: Some("fixture author".into()),
28            path: format!("/tmp/{name}.epub"),
29            title: format!("Fixture {name}"),
30            author: Some("Fixture Author".into()),
31            language: Some("en".into()),
32            publisher: None,
33            published_at: None,
34            word_count: Some(1000),
35            page_count: Some(4),
36            parse_error: None,
37        }
38    }
39}
40
41#[derive(Debug, PartialEq, Eq)]
42pub enum IdentityMatch {
43    ById(i64),
44    ByHash(i64),
45    ByNorm(i64),
46}
47
48pub fn resolve_identity(c: &Connection, row: &BookRow) -> anyhow::Result<Option<IdentityMatch>> {
49    if let Some(sid) = &row.stable_id {
50        if let Some(id) = c
51            .query_row(
52                "SELECT id FROM books WHERE stable_id = ? AND deleted_at IS NULL",
53                params![sid],
54                |r| r.get::<_, i64>(0),
55            )
56            .optional()?
57        {
58            return Ok(Some(IdentityMatch::ById(id)));
59        }
60    }
61    if let Some(fh) = &row.file_hash {
62        if let Some(id) = c
63            .query_row(
64                "SELECT id FROM books WHERE file_hash = ? AND deleted_at IS NULL",
65                params![fh],
66                |r| r.get::<_, i64>(0),
67            )
68            .optional()?
69        {
70            return Ok(Some(IdentityMatch::ByHash(id)));
71        }
72    }
73    if let Some(a) = &row.author_norm {
74        if let Some(id) = c.query_row(
75            "SELECT id FROM books WHERE title_norm = ? AND author_norm = ? AND deleted_at IS NULL",
76            params![row.title_norm, a], |r| r.get::<_, i64>(0),
77        ).optional()? { return Ok(Some(IdentityMatch::ByNorm(id))); }
78    }
79    Ok(None)
80}
81
82/// Upsert a book row. Returns the row id.
83pub fn upsert(c: &mut Connection, row: &BookRow) -> anyhow::Result<i64> {
84    let tx = c.transaction()?;
85    let existing = resolve_identity(&tx, row)?;
86    let id = match existing {
87        Some(IdentityMatch::ById(id) | IdentityMatch::ByHash(id) | IdentityMatch::ByNorm(id)) => {
88            tx.execute(
89                "UPDATE books SET stable_id = COALESCE(?, stable_id),
90                                   file_hash = COALESCE(?, file_hash),
91                                   title_norm = ?, author_norm = ?,
92                                   path = ?, title = ?, author = ?, language = ?,
93                                   publisher = ?, published_at = ?,
94                                   word_count = ?, page_count = ?, parse_error = ?,
95                                   deleted_at = NULL
96                 WHERE id = ?",
97                params![
98                    row.stable_id,
99                    row.file_hash,
100                    row.title_norm,
101                    row.author_norm,
102                    row.path,
103                    row.title,
104                    row.author,
105                    row.language,
106                    row.publisher,
107                    row.published_at,
108                    row.word_count,
109                    row.page_count,
110                    row.parse_error,
111                    id
112                ],
113            )?;
114            id
115        }
116        None => {
117            tx.execute(
118                "INSERT INTO books (stable_id, file_hash, title_norm, author_norm,
119                                    path, title, author, language, publisher, published_at,
120                                    word_count, page_count, parse_error)
121                 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
122                params![
123                    row.stable_id,
124                    row.file_hash,
125                    row.title_norm,
126                    row.author_norm,
127                    row.path,
128                    row.title,
129                    row.author,
130                    row.language,
131                    row.publisher,
132                    row.published_at,
133                    row.word_count,
134                    row.page_count,
135                    row.parse_error
136                ],
137            )?;
138            tx.last_insert_rowid()
139        }
140    };
141    tx.commit()?;
142    Ok(id)
143}