A queued export could not see the server, so its name check always answered "free" and the upload PUT over whatever was there: Increment and Skip behaved as Overwrite on Nextcloud, and two exports of the same name queued before either uploaded landed on one file. The batch now names around what the album records of earlier exports and what the outbox already holds for that folder. The outbox record carries the policy, and the drain lists each destination folder once and applies it against what the server holds: Increment steps past a taken name and re-points the album's row, Skip drops the entry. A record without a policy (older builds, a merge's composite) is sent as named, as before. The album is recorded before the drain starts so a rename has a row to move.
581 lines
21 KiB
Rust
581 lines
21 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(())
|
|
}
|
|
|
|
/// Every file name an album records, for an export choosing a name to know
|
|
/// what it would land on.
|
|
///
|
|
/// A server album cannot be asked while the export is queued offline, and
|
|
/// the names this app put there are the ones a second export of the same
|
|
/// photographs will collide with. One read of the album's rows, not one per
|
|
/// candidate name.
|
|
pub fn file_names(
|
|
conn: &Connection,
|
|
id: AlbumId,
|
|
) -> Result<std::collections::HashSet<String>, CatalogError> {
|
|
ensure_tables(conn)?;
|
|
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
|
|
let rows = stmt
|
|
.query_map([id.0 as i64], |r| r.get(0))?
|
|
.collect::<Result<_, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// A file the upload had to give another name: the server held one by the
|
|
/// name the export recorded, put there by something this catalog never
|
|
/// saw. The album row follows the file to the name it was given.
|
|
///
|
|
/// By the album's server folder, because that is all an outbox entry knows.
|
|
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
|
|
/// either end.
|
|
pub fn rename_export(
|
|
conn: &Connection,
|
|
folder: &str,
|
|
from: &str,
|
|
to: &str,
|
|
) -> Result<(), CatalogError> {
|
|
ensure_tables(conn)?;
|
|
conn.execute(
|
|
"UPDATE OR REPLACE album_exports SET file_name = ?3
|
|
WHERE file_name = ?2
|
|
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
|
|
rusqlite::params![folder.trim_matches('/'), from, to],
|
|
)?;
|
|
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 a_renamed_upload_moves_the_row_of_the_server_album_only() {
|
|
let cat = catalog();
|
|
let conn = cat.connection();
|
|
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
|
|
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
|
|
let a = image(conn, "a.cr3");
|
|
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
|
|
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
|
|
|
|
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
|
|
|
|
assert_eq!(
|
|
file_names(conn, web).unwrap(),
|
|
["a-1.jpg".to_string()].into()
|
|
);
|
|
assert_eq!(
|
|
file_names(conn, other).unwrap(),
|
|
["a.jpg".to_string()].into()
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|