Keep albums in the catalog: export folders and what went into them
An album is a named export destination. Its folder holds only the exported files; the catalog records, per file, the image it was rendered from, so an album can show the originals behind its JPEGs (FR-EXP-10). The tables are created on first use (CREATE TABLE IF NOT EXISTS), the way dedup_probes is, rather than by a schema migration: a new user_version makes every older build refuse this catalog's snapshot at sync, and the 0.16.0 tablet would stop merging collections, keywords and people for a feature it does not have. Albums merge as collections do: by uuid and revision, tombstones on delete, exports as a set union keyed on the server's file id (content hash for a folder library). A folder on the server lives on the album row and syncs; a folder on this device lives in album_folders, which the merge never reads and the upload snapshot drops, because a path or a SAF grant on one device means nothing on another. Exports are keyed on the file name, not the image: two crops of one photograph are two files and two rows, and an overwrite re-points the name at whatever wrote it last.
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,16 @@ pub enum CatalogError {
|
||||
#[error("no such collection: {0}")]
|
||||
NoSuchCollection(u64),
|
||||
|
||||
/// An album the caller named is gone — deleted here, or by a merge while
|
||||
/// its id sat in a UI model.
|
||||
#[error("no such album: {0}")]
|
||||
NoSuchAlbum(u64),
|
||||
|
||||
/// A name that is empty once trimmed. Refused rather than stored, because
|
||||
/// a row with no name is one the sidebar cannot draw and nobody can pick.
|
||||
#[error("a name is required")]
|
||||
EmptyName,
|
||||
|
||||
/// A keyword the caller named is gone — deleted, or fused into another by a
|
||||
/// merge while its id sat in a UI model.
|
||||
///
|
||||
|
||||
@@ -36,6 +36,7 @@ use std::path::Path;
|
||||
use dr_types::{Availability, ImageId};
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub mod albums;
|
||||
pub mod bursts;
|
||||
pub mod cache;
|
||||
pub mod collections;
|
||||
@@ -57,6 +58,7 @@ pub mod sync;
|
||||
pub mod trash;
|
||||
pub mod walk;
|
||||
|
||||
pub use albums::{Album, AlbumId, Place};
|
||||
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
|
||||
@@ -121,6 +121,15 @@ pub struct MergeReport {
|
||||
pub keywords_assigned: usize,
|
||||
/// Images whose capture metadata was taken from the remote.
|
||||
pub metadata_adopted: usize,
|
||||
|
||||
/// Albums the remote had and this device did not, or had renamed, moved
|
||||
/// or deleted with the higher revision.
|
||||
pub albums_taken: usize,
|
||||
/// Albums where this device's revision was at least as high.
|
||||
pub albums_kept_local: usize,
|
||||
/// Exported files the remote had recorded into an album and this device
|
||||
/// had not.
|
||||
pub album_exports_added: usize,
|
||||
}
|
||||
|
||||
impl MergeReport {
|
||||
@@ -136,12 +145,17 @@ impl MergeReport {
|
||||
|| self.keywords_fused > 0
|
||||
|| self.keywords_assigned > 0
|
||||
|| self.metadata_adopted > 0
|
||||
|| self.albums_taken > 0
|
||||
|| self.album_exports_added > 0
|
||||
}
|
||||
|
||||
/// Whether the local catalog holds anything the remote did not, and so
|
||||
/// must be uploaded even if nothing was taken from the remote.
|
||||
pub fn should_upload(&self) -> bool {
|
||||
self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed()
|
||||
self.kept_local > 0
|
||||
|| self.keywords_kept_local > 0
|
||||
|| self.albums_kept_local > 0
|
||||
|| self.local_changed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +211,134 @@ pub fn merge_all(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||
merge_keywords_within(&tx, &mut report)?;
|
||||
merge_people_within(&tx, &mut report)?;
|
||||
merge_metadata_within(&tx, &mut report)?;
|
||||
merge_albums_within(&tx, &mut report)?;
|
||||
tx.commit()?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Merge albums and what was exported into them, from an attached catalog.
|
||||
pub fn merge_albums(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut report = MergeReport::default();
|
||||
merge_albums_within(&tx, &mut report)?;
|
||||
tx.commit()?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The album half: rows by [`verdict`], exports as a set union.
|
||||
///
|
||||
/// `album_folders` is not read. It is this device's choice of a local folder
|
||||
/// and means nothing on the device that sent the snapshot — which has in any
|
||||
/// case dropped it before uploading (see [`crate::albums`]).
|
||||
fn merge_albums_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
|
||||
// A snapshot from a build before albums, or from a device that never
|
||||
// made one, has no tables to read. Ours are made on demand so the
|
||||
// statements below have somewhere to write.
|
||||
if !remote_has(tx, "albums")? {
|
||||
return Ok(());
|
||||
}
|
||||
crate::albums::ensure_tables(tx)?;
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, i64, i64, i64, bool, MergeVerdict)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT r.uuid, r.name, r.server_path, r.created, r.revision, r.modified,
|
||||
r.deleted, l.revision, l.modified
|
||||
FROM remote_cat.albums r
|
||||
LEFT JOIN main.albums l ON l.uuid = r.uuid",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let revision: i64 = r.get(4)?;
|
||||
let modified: i64 = r.get(5)?;
|
||||
let deleted: bool = r.get::<_, i64>(6)? != 0;
|
||||
let local_rev: Option<i64> = r.get(7)?;
|
||||
let local_mod: Option<i64> = r.get(8)?;
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
revision,
|
||||
modified,
|
||||
deleted,
|
||||
verdict(local_rev.zip(local_mod), (revision, modified), deleted),
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
};
|
||||
|
||||
{
|
||||
// One upsert covers insert, update and tombstone: the verdict has
|
||||
// already decided the remote row wins, so its fields are the answer
|
||||
// whichever of the three it is.
|
||||
let mut take = tx.prepare(
|
||||
"INSERT INTO main.albums
|
||||
(uuid, name, server_path, created, revision, modified, deleted)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
name = excluded.name, server_path = excluded.server_path,
|
||||
revision = excluded.revision, modified = excluded.modified,
|
||||
deleted = excluded.deleted",
|
||||
)?;
|
||||
let mut forget = tx.prepare(
|
||||
"DELETE FROM main.album_exports
|
||||
WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)",
|
||||
)?;
|
||||
let mut unfold = tx.prepare(
|
||||
"DELETE FROM main.album_folders
|
||||
WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)",
|
||||
)?;
|
||||
for (uuid, name, server_path, created, revision, modified, deleted, v) in rows {
|
||||
if v == MergeVerdict::KeptLocal {
|
||||
report.albums_kept_local += 1;
|
||||
continue;
|
||||
}
|
||||
take.execute(rusqlite::params![
|
||||
uuid, name, server_path, created, revision, modified, deleted as i64
|
||||
])?;
|
||||
if deleted {
|
||||
forget.execute([&uuid])?;
|
||||
unfold.execute([&uuid])?;
|
||||
} else if server_path.is_some() {
|
||||
// Another device moved the album to the server with the newer
|
||||
// revision; a local folder chosen here is no longer where it
|
||||
// goes.
|
||||
unfold.execute([&uuid])?;
|
||||
}
|
||||
report.albums_taken += 1;
|
||||
}
|
||||
}
|
||||
|
||||
report.album_exports_added =
|
||||
tx.execute(ALBUM_EXPORTS_BY_FILE_ID, [])? + tx.execute(ALBUM_EXPORTS_BY_CONTENT_HASH, [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exported files, matched to local images by the server's file id — the
|
||||
/// identity [`MEMBERS_BY_FILE_ID`] explains. Tombstoned albums are excluded,
|
||||
/// or a merge would refill an album it had just deleted.
|
||||
const ALBUM_EXPORTS_BY_FILE_ID: &str = "
|
||||
INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at)
|
||||
SELECT la.id, re.file_name, li.id, re.exported_at
|
||||
FROM remote_cat.album_exports re
|
||||
JOIN remote_cat.albums ra ON ra.id = re.album_id
|
||||
JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0
|
||||
JOIN remote_cat.remote rr ON rr.image_id = re.image_id
|
||||
JOIN main.remote lr ON lr.file_id = rr.file_id
|
||||
JOIN main.images li ON li.id = lr.image_id";
|
||||
|
||||
/// The same union by content hash, for a library with no server behind it.
|
||||
const ALBUM_EXPORTS_BY_CONTENT_HASH: &str = "
|
||||
INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at)
|
||||
SELECT la.id, re.file_name, li.id, re.exported_at
|
||||
FROM remote_cat.album_exports re
|
||||
JOIN remote_cat.albums ra ON ra.id = re.album_id
|
||||
JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0
|
||||
JOIN remote_cat.images ri ON ri.id = re.image_id
|
||||
JOIN main.images li ON li.content_hash = ri.content_hash
|
||||
WHERE ri.content_hash IS NOT NULL";
|
||||
|
||||
/// Adopt capture metadata from an attached catalog, on its own.
|
||||
pub fn merge_metadata(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
@@ -103,6 +103,10 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
|
||||
|
||||
let out = Connection::open(dest)?;
|
||||
build_snapshot(conn, &source, &out)?;
|
||||
// This device's local album folders are paths and SAF grants nobody
|
||||
// else can use; the merge never reads them, and the snapshot is what
|
||||
// a fresh device would otherwise adopt whole.
|
||||
out.execute_batch("DROP TABLE IF EXISTS album_folders")?;
|
||||
verify_snapshot(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -443,6 +447,92 @@ mod tests {
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
/// One image, known to the server by `file_id`, in a catalog.
|
||||
fn with_image(c: &Connection, file_id: i64) -> dr_types::ImageId {
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[format!("IMG_{file_id}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
let id = c.last_insert_rowid();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
[id, file_id],
|
||||
)
|
||||
.unwrap();
|
||||
dr_types::ImageId(id as u64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_album_and_its_exports_reach_another_device_but_its_folder_does_not() {
|
||||
use crate::albums::{self, Place};
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
{
|
||||
// The desktop: two albums, one on the server and one on its own
|
||||
// disk, each with an export of the same photograph.
|
||||
let r = seeded(&dir.join("desktop.sqlite"));
|
||||
let img = with_image(&r, 4242);
|
||||
let web = albums::create(&r, "Web", &Place::Server("Shared/Web".into())).unwrap();
|
||||
let print = albums::create(&r, "Print", &Place::Local("/mnt/print".into())).unwrap();
|
||||
albums::record_exports(&r, web, &[(img, "IMG_4242.jpg".into())]).unwrap();
|
||||
albums::record_exports(&r, print, &[(img, "IMG_4242.tif".into())]).unwrap();
|
||||
snapshot_for_upload(&r, &snap).unwrap();
|
||||
std::fs::rename(&snap, &remote_path).unwrap();
|
||||
}
|
||||
|
||||
// The tablet knows the same file under its own image id.
|
||||
let local = seeded(&dir.join("tablet.sqlite"));
|
||||
with_image(&local, 1);
|
||||
let img = with_image(&local, 4242);
|
||||
|
||||
let report = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(report.albums_taken, 2);
|
||||
assert_eq!(report.album_exports_added, 2);
|
||||
assert!(report.local_changed());
|
||||
|
||||
let all = albums::list(&local).unwrap();
|
||||
let print = all.iter().find(|a| a.name == "Print").unwrap();
|
||||
let web = all.iter().find(|a| a.name == "Web").unwrap();
|
||||
assert_eq!(print.place, None, "the desktop's disk is not the tablet's");
|
||||
assert_eq!(web.place, Some(Place::Server("Shared/Web".into())));
|
||||
assert_eq!(albums::sources(&local, web.id).unwrap(), vec![img]);
|
||||
|
||||
// Nothing changed on either side, so a second pass takes nothing.
|
||||
let again = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(again.albums_taken, 0);
|
||||
assert_eq!(again.album_exports_added, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_that_never_made_an_album_still_uploads_this_ones() {
|
||||
use crate::albums::{self, Place};
|
||||
let dir = tempdir();
|
||||
let remote_path = dir.join("remote.sqlite");
|
||||
{
|
||||
// A snapshot from a build that predates albums altogether.
|
||||
let r = seeded(&remote_path);
|
||||
checkpoint(&r).unwrap();
|
||||
}
|
||||
let local = seeded(&dir.join("local.sqlite"));
|
||||
albums::create(&local, "Web", &Place::Server("Web".into())).unwrap();
|
||||
|
||||
let report = merge_remote(&local, &remote_path).unwrap();
|
||||
assert_eq!(report.albums_taken, 0);
|
||||
// Its albums table is absent, so nothing was compared — and the local
|
||||
// album has still to reach the server.
|
||||
assert!(
|
||||
albums::list(&local).unwrap().len() == 1,
|
||||
"the local album survives a merge with a catalog that has none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remote_can_be_merged_twice_without_attach_conflict() {
|
||||
// Detach must happen even on the failure path, or the second attempt
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user