lib/applebooks/macos/mod.rs
1//! Defines types for interacting with the macOS's Apple Books databases.
2//!
3//! The [`ABMacos`] struct, is used to to directly interact with the Apple ! Books databases while
4//! the [`ABQuery`] trait provides an interface for ! generating types from either of the Apple Books
5//! databases.
6
7pub mod defaults;
8pub mod utils;
9
10use std::path::{Path, PathBuf};
11
12use rusqlite::{Connection, OpenFlags};
13
14use crate::result::{Error, Result};
15
16use self::utils::APPLEBOOKS_VERSION;
17
18/// A struct for interacting with macOS's Apple Books databases.
19///
20/// A directory containing macOS's Apple Books databases should conform to the following structure
21/// as this is how the official directory is structured.
22///
23/// ```plaintext
24/// [databases]
25/// │
26/// ├── AEAnnotation
27/// │ ├── AEAnnotation*.sqlite
28/// │ └── ...
29/// │
30/// ├── BKLibrary
31/// │ ├── BKLibrary*.sqlite
32/// │ └── ...
33/// └── ...
34/// ```
35#[derive(Debug, Clone, Copy)]
36pub struct ABMacos;
37
38impl ABMacos {
39 /// Extracts data from the books database and converts them into `T`.
40 ///
41 /// # Arguments
42 ///
43 /// * `path` - The path to a directory containing macOS's Apple Books databases.
44 ///
45 /// See [`ABMacos`] for more information on how the databases directory should be structured.
46 ///
47 /// # Errors
48 ///
49 /// Will return `Err` if:
50 /// * The database cannot be found/opened.
51 /// * The version of Apple Books is unsupported.
52 pub fn extract_books<T>(path: &Path) -> Result<Vec<T>>
53 where
54 T: ABQuery,
55 {
56 Self::query::<T>(path, ABDatabase::Books)
57 }
58
59 /// Extracts data from the annotations database and converts them into `T`.
60 ///
61 /// # Arguments
62 ///
63 /// * `path` - The path to a directory containing macOS's Apple Books databases.
64 ///
65 /// See [`ABMacos`] for more information on how the databases directory should be structured.
66 ///
67 /// # Errors
68 ///
69 /// Will return `Err` if:
70 /// * The database cannot be found/opened.
71 /// * The version of Apple Books is unsupported.
72 pub fn extract_annotations<T>(path: &Path) -> Result<Vec<T>>
73 where
74 T: ABQuery,
75 {
76 Self::query::<T>(path, ABDatabase::Annotations)
77 }
78
79 /// Queries and extracts data from one of the databases and converts them into `T`.
80 ///
81 /// # Arguments
82 ///
83 /// * `path` - The path to a directory containing macOS's Apple Books databases.
84 /// * `database` - Which database to query.
85 ///
86 /// See [`ABMacos`] for more information on how the databases directory should be structured.
87 ///
88 /// # Errors
89 ///
90 /// Will return `Err` if:
91 /// * The database cannot be found/opened
92 /// * The version of Apple Books is unsupported.
93 #[allow(clippy::missing_panics_doc)]
94 fn query<T>(path: &Path, database: ABDatabase) -> Result<Vec<T>>
95 where
96 T: ABQuery,
97 {
98 // Returns the appropriate database based on its name.
99 let path = Self::get_database(path, database)?;
100
101 let Ok(connection) = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
102 else {
103 return Err(Error::DatabaseConnection {
104 name: database.to_string(),
105 path: path.display().to_string(),
106 });
107 };
108
109 // This will only fail if the database schema has changes. This means that the Apple Books
110 // database schema is different than the one the query has been designed against. In that
111 // case, the currently installed version of Apple Books is unsupported.
112 let mut statement = match connection.prepare(T::QUERY) {
113 Ok(statement) => statement,
114 Err(error) => {
115 return Err(Error::UnsupportedMacosVersion {
116 error: error.to_string(),
117 version: APPLEBOOKS_VERSION.to_owned(),
118 });
119 }
120 };
121
122 let items = statement
123 .query_map([], |row| Ok(T::from_row(row)))
124 // The `rusqlite` documentation for `query_map` states 'Will return Err if binding
125 // parameters fails.' So this should be safe because `query_map` is given no parameters.
126 .unwrap()
127 // Using `filter_map` here because we know from a few lines above that all the items
128 // are wrapped in an `Ok`. At this point the there should be nothing that would fail
129 // in regards to querying and creating an instance of T unless there's an error in the
130 // implementation of the `ABQuery` trait. See `ABQuery` for more information.
131 .filter_map(std::result::Result::ok)
132 .collect();
133
134 Ok(items)
135 }
136
137 /// Returns a [`PathBuf`] to the `AEAnnotation` or `BKLibrary` database.
138 ///
139 /// # Arguments
140 ///
141 /// * `path` - The path to a directory containing macOS's Apple Books databases.
142 /// * `database` - Which database path to get.
143 ///
144 /// See [`ABMacos`] for more information on how the databases directory should be structured.
145 fn get_database(path: &Path, database: ABDatabase) -> Result<PathBuf> {
146 // (a) -> `/path/to/databases/DATABASE_NAME/`
147 let path = path.join(database.to_string());
148
149 // (b) -> `/path/to/databases/DATABASE_NAME/DATABASE_NAME*.sqlite`
150 let pattern = format!("{database}*.sqlite");
151 let pattern = path.join(pattern);
152 let pattern = pattern.to_string_lossy();
153
154 let mut databases: Vec<PathBuf> = glob::glob(&pattern)
155 // This should be safe to unwrap seeing we know the pattern is valid and in production
156 // the path (b) will always be valid UTF-8 as it's a path to a default macOS
157 // application's container.
158 .unwrap()
159 .filter_map(std::result::Result::ok)
160 .collect();
161
162 // macOS's default Apple Books database directory contains only a single database file that
163 // starts with the `DATABASE_NAME` and ends with `.sqlite`. If there are more then we'd
164 // possibly run into unexpected behaviors.
165 match &databases[..] {
166 [_] => Ok(databases.pop().unwrap()),
167 _ => Err(Error::MissingDefaultDatabase),
168 }
169 }
170}
171
172/// A trait for standardizing how types are created from the Apple Books databases.
173///
174/// This trait allows for instances to be created generically over the rows of their respective
175/// databases `BKLibrary*.sqlite` and `AEAnnotation*.sqlite`.
176///
177/// The [`ABQuery::from_row()`] and [`ABQuery::QUERY`] methods are strongly coupled in that the
178/// declared rows in the `SELECT` statement *must* map directly to the `rusqlite`'s `Row::get()`
179/// method e.g. the first row of the `SELECT` statement maps to `row.get(0)` etc. The `unwrap` on
180/// the `Row::get()` methods will panic if the index is out of range or the there's a type mismatch
181/// to the struct field it's been mapped to.
182///
183/// The databases seem to be related via a UUID field.
184///
185/// ```plaintext
186/// Book ZBKLIBRARYASSET.ZASSETID ─────────┐
187/// Annotation ZAEANNOTATION.ZANNOTATIONASSETID ─┘
188/// ```
189pub trait ABQuery {
190 /// The query to retrieve rows from the database. The rows are then passed
191 /// into [`ABQuery::from_row()`] to create instances of the implementing
192 /// type.
193 const QUERY: &'static str;
194
195 /// Constructs an instance of the implementing type from a [`rusqlite::Row`].
196 fn from_row(row: &rusqlite::Row<'_>) -> Self;
197}
198
199/// An enum representing macOS's Apple Books databases.
200#[derive(Debug, Clone, Copy)]
201pub enum ABDatabase {
202 /// The books database.
203 Books,
204
205 /// The annotations database.
206 Annotations,
207}
208
209impl std::fmt::Display for ABDatabase {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 ABDatabase::Books => write!(f, "BKLibrary"),
213 ABDatabase::Annotations => write!(f, "AEAnnotation"),
214 }
215 }
216}