rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused.
517 lines
19 KiB
Rust
517 lines
19 KiB
Rust
//! 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<Place>,
|
|
/// 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<AlbumId, CatalogError> {
|
|
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<Vec<Album>, 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::<Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// One album, or `None` if it is gone.
|
|
pub fn get(conn: &Connection, id: AlbumId) -> Result<Option<Album>, 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<Option<AlbumId>, 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(())
|
|
}
|
|
|
|
/// 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<Vec<ImageId>, 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::<Result<Vec<_>, _>>()?;
|
|
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<Vec<String>, 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::<Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
fn album_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Album> {
|
|
let server: Option<String> = r.get(3)?;
|
|
let local: Option<String> = 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 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);
|
|
}
|
|
}
|