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}