//! TRACES: FR-EXP-10 | FR-EXP-6 | FR-CAT-7 //! Albums: named export folders, and which photographs went into each. //! //! An album is where finished pictures go — a folder of JPEGs somebody else //! looks at — as opposed to a collection, which is a set of originals the //! photographer works on. The folder holds only the exported files. What the //! catalog adds is the link back: each export is recorded against the image //! it was rendered from, so opening an album in the library shows the RAWs //! behind its JPEGs, and re-exporting after an edit is one selection away. //! //! # Where the folder is, and why that is two tables //! //! An album's folder is either on the library's server or on this device. //! //! A **server folder** is one path on the account, the same from every device //! signed in to it, so it lives on the album row and syncs with it. //! //! A **local folder** — a filesystem path on a desktop, a Storage Access //! Framework tree on Android — means nothing on any other device. It lives in //! `album_folders`, which the merge never reads and the upload snapshot drops //! ([`crate::sync::snapshot_for_upload`]). An album made on the desktop with a //! local folder therefore reaches the tablet as an album with no folder there //! yet, which is true, and which the tablet can fix by choosing one. //! //! # Created on first use, not by a migration //! //! A new schema version makes every older build refuse this catalog's //! snapshot at sync (`crate::sync::remote_is_mergeable`), so the tablet would //! stop merging collections, keywords and people until it was updated — for //! a feature it does not have. The tables are created by [`ensure_tables`] //! instead, the way `dedup_probes` is; an older build that meets them ignores //! them, and its merge keeps working. //! //! # Sync //! //! Albums merge by uuid and revision with tombstones, and their exports as a //! set union keyed on the image's server file id — the rules //! [`crate::merge`] applies to collections, for the same reasons. use rusqlite::{Connection, OptionalExtension}; use dr_types::ImageId; use crate::error::CatalogError; /// Identifies an album within one catalog. Local, like every integer id here; /// the uuid is what crosses devices. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AlbumId(pub u64); /// Where an album's files go. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Place { /// A folder on the library's server, relative to the account root, with no /// leading slash. The same on every device. Server(String), /// A folder on this device: a filesystem path, or on Android a SAF tree /// URI. Never synced. Local(String), } /// One album, as the sidebar and the export sheet show it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Album { pub id: AlbumId, pub uuid: String, pub name: String, /// Where exports go from this device, or `None` for an album whose folder /// is local to another device and has not been chosen here. pub place: Option, /// Distinct photographs exported into it — what the grid shows when the /// album is opened. pub sources: usize, } /// Create the album tables if this catalog does not have them yet. /// /// Cheap when they exist: `IF NOT EXISTS` is answered from the schema, and /// every function below calls this first so no caller has to remember to. pub fn ensure_tables(conn: &Connection) -> Result<(), CatalogError> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY, -- The merge identity; the integer id is local. uuid TEXT NOT NULL UNIQUE, name TEXT NOT NULL, -- A folder on the server, relative to the account root. NULL for -- an album whose folder is local to some device. server_path TEXT, created INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1, modified INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0 ); -- One row per file written into an album. Keyed on the file, not the -- image: a photograph exported twice — two crops, or once before an -- edit and once after — is two files in the folder and two rows here. CREATE TABLE IF NOT EXISTS album_exports ( album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, file_name TEXT NOT NULL, image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, exported_at INTEGER NOT NULL, PRIMARY KEY (album_id, file_name) ); CREATE INDEX IF NOT EXISTS album_exports_image ON album_exports(image_id); -- This device's folder for an album. Never merged, never uploaded. CREATE TABLE IF NOT EXISTS album_folders ( album_id INTEGER PRIMARY KEY REFERENCES albums(id) ON DELETE CASCADE, folder TEXT NOT NULL );", )?; Ok(()) } /// Make an album. /// /// The name is trimmed and must not be empty; two albums may share one, as /// two collections may, because the uuid is the identity and refusing a /// duplicate name here would refuse it on one device and not another. pub fn create(conn: &Connection, name: &str, place: &Place) -> Result { ensure_tables(conn)?; let name = name.trim(); if name.is_empty() { return Err(CatalogError::EmptyName); } let now = now_secs(); let tx = conn.unchecked_transaction()?; tx.execute( "INSERT INTO albums(uuid, name, server_path, created, revision, modified) VALUES (?1, ?2, ?3, ?4, 1, ?4)", rusqlite::params![ crate::collections::new_uuid(), name, server_path(place), now ], )?; let id = AlbumId(tx.last_insert_rowid() as u64); if let Place::Local(folder) = place { tx.execute( "INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2)", rusqlite::params![id.0 as i64, folder], )?; } tx.commit()?; Ok(id) } /// Rename an album. The folder keeps its name: the album is what the /// photographer calls it, the folder is what is already out there. pub fn rename(conn: &Connection, id: AlbumId, name: &str) -> Result<(), CatalogError> { ensure_tables(conn)?; let name = name.trim(); if name.is_empty() { return Err(CatalogError::EmptyName); } let n = conn.execute( "UPDATE albums SET name = ?2, revision = revision + 1, modified = ?3 WHERE id = ?1 AND deleted = 0", rusqlite::params![id.0 as i64, name, now_secs()], )?; if n == 0 { return Err(CatalogError::NoSuchAlbum(id.0)); } Ok(()) } /// Point an album at a different folder, from this device. /// /// A server folder replaces the synced path, and bumps the revision so the /// move reaches every device. A local folder is recorded for this device /// only; it also clears a server path, because an album goes to one place and /// the photographer has just said which. pub fn set_place(conn: &Connection, id: AlbumId, place: &Place) -> Result<(), CatalogError> { ensure_tables(conn)?; let tx = conn.unchecked_transaction()?; let n = tx.execute( "UPDATE albums SET server_path = ?2, revision = revision + 1, modified = ?3 WHERE id = ?1 AND deleted = 0", rusqlite::params![id.0 as i64, server_path(place), now_secs()], )?; if n == 0 { return Err(CatalogError::NoSuchAlbum(id.0)); } match place { Place::Local(folder) => tx.execute( "INSERT INTO album_folders(album_id, folder) VALUES (?1, ?2) ON CONFLICT(album_id) DO UPDATE SET folder = excluded.folder", rusqlite::params![id.0 as i64, folder], )?, Place::Server(_) => tx.execute( "DELETE FROM album_folders WHERE album_id = ?1", [id.0 as i64], )?, }; tx.commit()?; Ok(()) } /// Delete an album, leaving a tombstone. The files in its folder are not /// touched: they are finished work somebody may already have been sent a /// link to, and the album was only ever this catalog's note of them. pub fn delete(conn: &Connection, id: AlbumId) -> Result<(), CatalogError> { ensure_tables(conn)?; let tx = conn.unchecked_transaction()?; let n = tx.execute( "UPDATE albums SET deleted = 1, revision = revision + 1, modified = ?2 WHERE id = ?1 AND deleted = 0", rusqlite::params![id.0 as i64, now_secs()], )?; if n == 0 { return Err(CatalogError::NoSuchAlbum(id.0)); } tx.execute( "DELETE FROM album_exports WHERE album_id = ?1", [id.0 as i64], )?; tx.execute( "DELETE FROM album_folders WHERE album_id = ?1", [id.0 as i64], )?; tx.commit()?; Ok(()) } /// Every live album, by name, with how many photographs each holds. /// /// One statement: the counts are aggregated from `album_exports` first and /// joined to the (few) albums, not counted per row. pub fn list(conn: &Connection) -> Result, CatalogError> { ensure_tables(conn)?; let mut stmt = conn.prepare( "SELECT a.id, a.uuid, a.name, a.server_path, f.folder, coalesce(e.n, 0) FROM albums a LEFT JOIN album_folders f ON f.album_id = a.id LEFT JOIN (SELECT album_id, count(DISTINCT image_id) AS n FROM album_exports GROUP BY album_id) e ON e.album_id = a.id WHERE a.deleted = 0 ORDER BY a.name COLLATE NOCASE, a.id", )?; let rows = stmt .query_map([], album_from_row)? .collect::, _>>()?; Ok(rows) } /// One album, or `None` if it is gone. pub fn get(conn: &Connection, id: AlbumId) -> Result, CatalogError> { ensure_tables(conn)?; Ok(conn .query_row( "SELECT a.id, a.uuid, a.name, a.server_path, f.folder, (SELECT count(DISTINCT image_id) FROM album_exports WHERE album_id = a.id) FROM albums a LEFT JOIN album_folders f ON f.album_id = a.id WHERE a.id = ?1 AND a.deleted = 0", [id.0 as i64], album_from_row, ) .optional()?) } /// The album with this uuid, if this catalog holds it live. pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result, CatalogError> { ensure_tables(conn)?; Ok(conn .query_row( "SELECT id FROM albums WHERE uuid = ?1 AND deleted = 0", [uuid], |r| r.get::<_, i64>(0), ) .optional()? .map(|id| AlbumId(id as u64))) } /// Record the files one export wrote into an album, and which image each /// came from. One transaction for the batch, however many files it placed. /// /// A file name already recorded is re-pointed at the image that wrote it /// last: an export that overwrote `IMG_0001.jpg` replaced the picture in the /// folder, and the link must say what is there now. pub fn record_exports( conn: &Connection, id: AlbumId, files: &[(ImageId, String)], ) -> Result<(), CatalogError> { ensure_tables(conn)?; if files.is_empty() { return Ok(()); } let tx = conn.unchecked_transaction()?; let now = now_secs(); { let mut insert = tx.prepare( "INSERT INTO album_exports(album_id, file_name, image_id, exported_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(album_id, file_name) DO UPDATE SET image_id = excluded.image_id, exported_at = excluded.exported_at", )?; for (image, name) in files { insert.execute(rusqlite::params![id.0 as i64, name, image.0 as i64, now])?; } } tx.commit()?; Ok(()) } /// Every file name an album records, for an export choosing a name to know /// what it would land on. /// /// A server album cannot be asked while the export is queued offline, and /// the names this app put there are the ones a second export of the same /// photographs will collide with. One read of the album's rows, not one per /// candidate name. pub fn file_names( conn: &Connection, id: AlbumId, ) -> Result, CatalogError> { ensure_tables(conn)?; let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?; let rows = stmt .query_map([id.0 as i64], |r| r.get(0))? .collect::>()?; Ok(rows) } /// A file the upload had to give another name: the server held one by the /// name the export recorded, put there by something this catalog never /// saw. The album row follows the file to the name it was given. /// /// By the album's server folder, because that is all an outbox entry knows. /// `folder` is spelled as [`Place::Server`] spells it, without slashes at /// either end. pub fn rename_export( conn: &Connection, folder: &str, from: &str, to: &str, ) -> Result<(), CatalogError> { ensure_tables(conn)?; conn.execute( "UPDATE OR REPLACE album_exports SET file_name = ?3 WHERE file_name = ?2 AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)", rusqlite::params![folder.trim_matches('/'), from, to], )?; Ok(()) } /// The photographs behind an album's files, most recently exported first — /// what the grid shows when the album is opened. pub fn sources(conn: &Connection, id: AlbumId) -> Result, CatalogError> { ensure_tables(conn)?; let mut stmt = conn.prepare( "SELECT image_id FROM album_exports WHERE album_id = ?1 GROUP BY image_id ORDER BY max(exported_at) DESC, image_id", )?; let rows = stmt .query_map([id.0 as i64], |r| r.get::<_, i64>(0))? .map(|r| r.map(|i| ImageId(i as u64))) .collect::, _>>()?; Ok(rows) } /// The names of the files an image left in an album — the "which JPEG is /// this" half of the link. pub fn files_of( conn: &Connection, id: AlbumId, image: ImageId, ) -> Result, CatalogError> { ensure_tables(conn)?; let mut stmt = conn.prepare( "SELECT file_name FROM album_exports WHERE album_id = ?1 AND image_id = ?2 ORDER BY exported_at DESC, file_name", )?; let rows = stmt .query_map(rusqlite::params![id.0 as i64, image.0 as i64], |r| r.get(0))? .collect::, _>>()?; Ok(rows) } fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { let server: Option = r.get(3)?; let local: Option = r.get(4)?; Ok(Album { id: AlbumId(r.get::<_, i64>(0)? as u64), uuid: r.get(1)?, name: r.get(2)?, // A server path wins: `set_place` clears the local folder when it // sets one, so both being present means a merge brought a server // path in over a local choice — and the newer revision decided that. place: server.map(Place::Server).or(local.map(Place::Local)), sources: r.get::<_, i64>(5)? as usize, }) } fn server_path(place: &Place) -> Option<&str> { match place { Place::Server(p) => Some(p.trim_matches('/')), Place::Local(_) => None, } } fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; /// A catalog, and the connection to it. The `Catalog` has to outlive the /// connection it hands out, so tests hold both. fn catalog() -> crate::Catalog { let cat = crate::Catalog::in_memory().unwrap(); cat.connection() .execute( "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", [], ) .unwrap(); cat } fn image(conn: &Connection, path: &str) -> ImageId { conn.execute( "INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)", [path], ) .unwrap(); ImageId(conn.last_insert_rowid() as u64) } #[test] fn an_album_lists_with_its_place_and_no_photographs() { let cat = catalog(); let conn = cat.connection(); let web = create(conn, " Web ", &Place::Server("Shared/Web/".into())).unwrap(); let print = create(conn, "Print", &Place::Local("/mnt/print".into())).unwrap(); let all = list(conn).unwrap(); assert_eq!(all.len(), 2); assert_eq!(all[0].id, print, "sorted by name"); assert_eq!(all[0].place, Some(Place::Local("/mnt/print".into()))); assert_eq!(all[1].id, web); assert_eq!(all[1].name, "Web", "trimmed"); assert_eq!(all[1].place, Some(Place::Server("Shared/Web".into()))); assert_eq!(all[1].sources, 0); } #[test] fn an_empty_name_is_refused() { let cat = catalog(); let conn = cat.connection(); assert!(matches!( create(conn, " ", &Place::Local("/x".into())), Err(CatalogError::EmptyName) )); } #[test] fn exports_link_files_back_to_their_images() { let cat = catalog(); let conn = cat.connection(); let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); let a = image(conn, "a.cr3"); let b = image(conn, "b.cr3"); record_exports( conn, album, &[ (a, "a.jpg".into()), (a, "a (1).jpg".into()), (b, "b.jpg".into()), ], ) .unwrap(); let got = get(conn, album).unwrap().unwrap(); assert_eq!(got.sources, 2, "two photographs, three files"); let mut s = sources(conn, album).unwrap(); s.sort(); assert_eq!(s, vec![a, b]); assert_eq!(files_of(conn, album, a).unwrap().len(), 2); } #[test] fn an_overwritten_file_points_at_what_wrote_it_last() { let cat = catalog(); let conn = cat.connection(); let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); let a = image(conn, "a.cr3"); let b = image(conn, "b.cr3"); record_exports(conn, album, &[(a, "x.jpg".into())]).unwrap(); record_exports(conn, album, &[(b, "x.jpg".into())]).unwrap(); assert_eq!(sources(conn, album).unwrap(), vec![b]); } #[test] fn a_renamed_upload_moves_the_row_of_the_server_album_only() { let cat = catalog(); let conn = cat.connection(); let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap(); let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap(); let a = image(conn, "a.cr3"); record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap(); record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap(); rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap(); assert_eq!( file_names(conn, web).unwrap(), ["a-1.jpg".to_string()].into() ); assert_eq!( file_names(conn, other).unwrap(), ["a.jpg".to_string()].into() ); } #[test] fn moving_to_the_server_forgets_the_local_folder() { let cat = catalog(); let conn = cat.connection(); let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); set_place(conn, album, &Place::Server("Web".into())).unwrap(); assert_eq!( get(conn, album).unwrap().unwrap().place, Some(Place::Server("Web".into())) ); set_place(conn, album, &Place::Local("/again".into())).unwrap(); assert_eq!( get(conn, album).unwrap().unwrap().place, Some(Place::Local("/again".into())) ); } #[test] fn a_deleted_album_is_gone_and_its_uuid_no_longer_resolves() { let cat = catalog(); let conn = cat.connection(); let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); let uuid = get(conn, album).unwrap().unwrap().uuid; let a = image(conn, "a.cr3"); record_exports(conn, album, &[(a, "a.jpg".into())]).unwrap(); delete(conn, album).unwrap(); assert!(list(conn).unwrap().is_empty()); assert_eq!(id_for_uuid(conn, &uuid).unwrap(), None); assert!(matches!( rename(conn, album, "Again"), Err(CatalogError::NoSuchAlbum(_)) )); } #[test] fn a_rename_bumps_the_revision_the_merge_compares() { let cat = catalog(); let conn = cat.connection(); let album = create(conn, "Web", &Place::Local("/out".into())).unwrap(); rename(conn, album, "Website").unwrap(); let rev: i64 = conn .query_row( "SELECT revision FROM albums WHERE id = ?1", [album.0 as i64], |r| r.get(0), ) .unwrap(); assert_eq!(rev, 2); } }