Add collections, ratings, and soft delete to the catalog
Three features over a shared schema migration. Collections: a tree of manual collections plus smart collections whose membership *is* their stored selector. Dropping images onto a smart collection is refused rather than silently discarded, so the UI can say why the drop did nothing — member rows there would be a second source of truth that nothing reads. Ratings: the star and pick/reject axes, kept independent. Trash: soft delete to a folder, then permanent delete. Catalog::open now backfills after migrating. A migration adds a column but cannot know what the value should be for rows that already existed; backfilling on open is what stops those rows being silently partial. Timeline queries exclude shadowed JPEGs, which would otherwise double every paired shot in the histogram, and gain a range-bounded variant so zooming in returns finer buckets rather than the same coarse ones with the ends cropped. Assisted-by: LLM
This commit is contained in:
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -42,10 +42,64 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.pragma_update(None, "user_version", 1)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 2 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V2)?;
|
||||
tx.pragma_update(None, "user_version", 2)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 3 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V3)?;
|
||||
tx.pragma_update(None, "user_version", 3)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 4 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V4)?;
|
||||
tx.pragma_update(None, "user_version", 4)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
/// Recompute columns a migration added, for rows that predate it.
|
||||
///
|
||||
/// A migration adds a column with a default; it cannot know what the value
|
||||
/// *should* be for the rows already present. Without a backfill those rows are
|
||||
/// silently partial — present, queryable, and wrong — which is worse than
|
||||
/// missing, because nothing signals that they need attention.
|
||||
///
|
||||
/// Cheap enough to run on every open: each pass is one indexed UPDATE, and
|
||||
/// re-running it is a no-op once the values are already right.
|
||||
///
|
||||
/// Returns how many rows each backfill touched, for logging.
|
||||
pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, CatalogError> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// v2: `shadowed_by`. A JPEG sitting beside a RAW of the same name is the
|
||||
// camera's own rendering of that frame, not a second photograph, so it is
|
||||
// hidden from the grid, the timeline and the sweep.
|
||||
let n = pair_raw_and_jpeg(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("shadowed_by", n));
|
||||
}
|
||||
|
||||
// v3: every image needs a default version to carry its rating and flag.
|
||||
// Libraries scanned before ratings existed have images and no versions at
|
||||
// all, so there was nowhere for a judgement to go — see
|
||||
// [`crate::rating`]. Backfilled rather than migrated in SQL because the
|
||||
// UUID per row is the cross-device merge identity and must be generated,
|
||||
// not derived.
|
||||
let n = crate::rating::ensure_default_versions(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("default_versions", n));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Connection setup applied on every open, migration or not.
|
||||
///
|
||||
/// WAL is required by NFR-R1: it survives power loss without corruption, and
|
||||
@@ -85,6 +139,127 @@ pub fn v1_for_attached(schema_name: &str) -> String {
|
||||
// does, and `CREATE INDEX x.name ON table` is the correct form.
|
||||
}
|
||||
|
||||
/// Mark each JPEG that sits beside a RAW of the same name.
|
||||
///
|
||||
/// Matched on folder plus stem, case-insensitively. Same-folder is what makes
|
||||
/// this safe: cameras write the pair side by side, and matching across folders
|
||||
/// would risk pairing unrelated frames, since camera filenames wrap at
|
||||
/// IMG_9999 (FR-CAT-11).
|
||||
///
|
||||
/// Done in Rust rather than SQL because the comparison needs a filename stem,
|
||||
/// and SQLite has no such function without enabling `rusqlite/functions` —
|
||||
/// a dependency feature for one string operation, whose SQL spelling would be
|
||||
/// unreadable and would mishandle names with no extension.
|
||||
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs.
|
||||
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN
|
||||
('cr2','cr3','nef','arw','raf','rw2','orf','dng')",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (id, folder, path) = row?;
|
||||
raws.insert((folder, stem_of(&path).to_ascii_lowercase()), id);
|
||||
}
|
||||
}
|
||||
if raws.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let pairs: Vec<(i64, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, folder_id, source_ref FROM images
|
||||
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.filter_map(|row| {
|
||||
let (id, folder, path) = row.ok()?;
|
||||
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
|
||||
Some((id, *raw))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for (jpeg, raw) in &pairs {
|
||||
tx.execute(
|
||||
"UPDATE images SET shadowed_by = ?2 WHERE id = ?1",
|
||||
[jpeg, raw],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(pairs.len())
|
||||
}
|
||||
|
||||
/// A filename without its extension.
|
||||
///
|
||||
/// Only the final path component, and only its last dot — a directory
|
||||
/// containing a dot must not truncate the name.
|
||||
fn stem_of(path: &str) -> &str {
|
||||
let name = path.rsplit(['/', ':']).next().unwrap_or(path);
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
const V4: &str = r#"
|
||||
-- TRACES: FR-CAT-15
|
||||
-- Soft delete. A trashed image is a real file that has been *moved* to a trash
|
||||
-- folder under the library root, not a row hidden by a flag: the catalog is a
|
||||
-- rebuildable index (ARCH §6.12), so a flag alone would evaporate the moment
|
||||
-- the catalog was deleted and every trashed photograph would return.
|
||||
--
|
||||
-- `source_ref` follows the file to its new path, because that is where the bytes
|
||||
-- now are and every fetch resolves through it. `trashed_from` remembers where it
|
||||
-- came from, which is the only way a restore can put it back — the trash is flat
|
||||
-- and the original folder structure is not recoverable from the trashed path.
|
||||
ALTER TABLE images ADD COLUMN trashed_at INTEGER;
|
||||
ALTER TABLE images ADD COLUMN trashed_from TEXT;
|
||||
|
||||
-- Partial: almost no rows are trashed, and the grid's "not trashed" predicate is
|
||||
-- answered by the absence of an entry rather than by scanning every image.
|
||||
CREATE INDEX images_trashed ON images(trashed_at) WHERE trashed_at IS NOT NULL;
|
||||
"#;
|
||||
|
||||
const V3: &str = r#"
|
||||
-- Ratings and flags are read per grid window and counted for the filter bar's
|
||||
-- histogram, both of which key on the *default* version. Without this the
|
||||
-- histogram is a full scan of `versions` on every judgement.
|
||||
--
|
||||
-- Partial on `is_default`: a virtual copy's rating is real but is never what
|
||||
-- these two queries ask for, and excluding them keeps the index roughly one
|
||||
-- entry per image rather than one per version.
|
||||
CREATE INDEX versions_judgement ON versions(image_id, rating, flag)
|
||||
WHERE is_default = 1;
|
||||
"#;
|
||||
|
||||
const V2: &str = r#"
|
||||
-- A JPEG the camera wrote alongside a RAW of the same name is that RAW's own
|
||||
-- rendering, not a second photograph. Recording *which* RAW shadows it, rather
|
||||
-- than a bare flag, keeps the relationship usable: the JPEG is a ready-made
|
||||
-- preview for its RAW, and the pairing can be undone without a rescan.
|
||||
ALTER TABLE images ADD COLUMN shadowed_by INTEGER REFERENCES images(id) ON DELETE SET NULL;
|
||||
CREATE INDEX images_shadowed ON images(shadowed_by) WHERE shadowed_by IS NOT NULL;
|
||||
"#;
|
||||
|
||||
const V1: &str = r#"
|
||||
-- Roots -------------------------------------------------------------------
|
||||
CREATE TABLE roots (
|
||||
@@ -95,7 +270,12 @@ CREATE TABLE roots (
|
||||
last_seen INTEGER,
|
||||
-- Bumped once per completed scan. Folders record the generation they were
|
||||
-- reached in; anything older was not reached and no longer exists.
|
||||
scan_generation INTEGER NOT NULL DEFAULT 0
|
||||
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||
-- One row per granted location. Without this, a rescan inserts a second
|
||||
-- root for the same folder and the library silently fragments across
|
||||
-- them — images split between roots, and pruning compares against the
|
||||
-- wrong generation.
|
||||
UNIQUE(kind, label)
|
||||
);
|
||||
|
||||
-- Folders: the unit of change detection, local and remote alike -----------
|
||||
@@ -277,6 +457,154 @@ mod tests {
|
||||
c
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// How many rows a named backfill touched, ignoring the others.
|
||||
///
|
||||
/// Asserting on the whole vector would couple every test to which other
|
||||
/// backfills happen to exist.
|
||||
fn backfilled(c: &Connection, what: &str) -> usize {
|
||||
backfill(c)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|(name, _)| *name == what)
|
||||
.map(|(_, n)| n)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Insert an image and return its id.
|
||||
fn image(c: &Connection, folder: Option<i64>, name: &str, format: &str) -> i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, folder_id, source_ref, format, added_at)
|
||||
VALUES (1, ?1, ?2, ?3, 0)",
|
||||
rusqlite::params![folder, name, format],
|
||||
)
|
||||
.unwrap();
|
||||
c.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn with_root() -> Connection {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO folders(id, root_id, path) VALUES (1, 1, 'a'), (2, 1, 'b')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_beside_its_raw_is_shadowed() {
|
||||
// The camera's own rendering of a frame, not a second photograph.
|
||||
let c = with_root();
|
||||
let raw = image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
|
||||
let jpeg = image(&c, Some(1), "a/IMG_1234.JPG", "jpg");
|
||||
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
let got: Option<i64> = c
|
||||
.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [jpeg], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got, Some(raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_case_does_not_matter() {
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1.cr2", "cr2");
|
||||
image(&c, Some(1), "a/img_1.JPG", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standalone_jpeg_is_untouched() {
|
||||
// Scanned film has no RAW sibling and must stay visible — 2,656 of
|
||||
// them in the reference library.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/SCAN_0001.jpg", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_in_a_different_folder_is_not_shadowed() {
|
||||
// Camera filenames wrap at IMG_9999, so the same stem recurs across
|
||||
// shoots (FR-CAT-11). Only a same-folder pair is safe to collapse.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1234.CR2", "cr2");
|
||||
image(&c, Some(2), "b/IMG_1234.JPG", "jpg");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_is_never_shadowed_by_a_jpeg() {
|
||||
// The relationship is one-way: the RAW is the photograph.
|
||||
let c = with_root();
|
||||
let raw = image(&c, Some(1), "a/IMG_1.CR2", "cr2");
|
||||
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
|
||||
backfill(&c).unwrap();
|
||||
|
||||
let got: Option<i64> = c
|
||||
.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [raw], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_is_idempotent() {
|
||||
// It runs on every open, so a second pass must find nothing to do.
|
||||
let c = with_root();
|
||||
image(&c, Some(1), "a/IMG_1.CR2", "cr2");
|
||||
image(&c, Some(1), "a/IMG_1.JPG", "jpg");
|
||||
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
assert_eq!(
|
||||
backfilled(&c, "shadowed_by"),
|
||||
0,
|
||||
"second pass is a no-op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v1_catalog_gains_the_column_and_is_backfilled() {
|
||||
// The migration case that motivated this: rows already present when a
|
||||
// column is added are silently partial until something backfills them.
|
||||
let c = mem();
|
||||
c.execute_batch(V1).unwrap();
|
||||
c.pragma_update(None, "user_version", 1).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, format, added_at)
|
||||
VALUES (1, 'IMG_9.CR2', 'cr2', 0), (1, 'IMG_9.JPG', 'jpg', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(migrate(&c).unwrap(), 1, "migrated from v1");
|
||||
assert_eq!(backfilled(&c, "shadowed_by"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_ignore_directories_containing_dots() {
|
||||
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
||||
assert_eq!(stem_of("IMG_1.CR2"), "IMG_1");
|
||||
assert_eq!(stem_of("noextension"), "noextension");
|
||||
// A dotfile is all stem, not an empty name with an extension.
|
||||
assert_eq!(stem_of(".hidden"), ".hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_creates_schema_at_current_version() {
|
||||
let c = mem();
|
||||
|
||||
Reference in New Issue
Block a user