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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user