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