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:
2026-09-26 14:13:28 -04:00
parent 715fcf8512
commit 94542371f6
6 changed files with 748 additions and 9 deletions
+139 -1
View File
@@ -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()?;