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:
2026-08-09 20:42:13 +02:00
parent d6ddd0703b
commit d5b1f6bff5
7 changed files with 2962 additions and 4 deletions
File diff suppressed because it is too large Load Diff
+17
View File
@@ -33,9 +33,26 @@ pub enum CatalogError {
#[error("no such collection: {0}")]
NoSuchCollection(u64),
/// Images were dropped onto a smart collection.
///
/// A smart collection's membership *is* its selector, so member rows would
/// be a second source of truth that nothing reads. Refused rather than
/// silently discarded, so the UI can say why the drop did nothing.
#[error("collection {0} is a saved filter; its contents cannot be edited by hand")]
SmartCollectionNotEditable(u64),
#[error("malformed stored selector: {0}")]
BadSelector(String),
/// A name the user typed that cannot be stored — blank, or one a sibling
/// already holds.
///
/// Its own variant rather than a reused `BadSelector`, because this one is
/// shown to the user verbatim: it has to read as a sentence about their
/// collection, not as a diagnostic about a stored selector.
#[error("{0}")]
BadName(String),
#[error("io: {0}")]
Io(String),
}
+64 -2
View File
@@ -12,7 +12,9 @@
//! - [`schema`] — tables and forward-only migrations
//! - [`scan`] — incremental discovery that prunes unchanged directories
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device collection merging
//!
//! # The one thing everything is designed around
@@ -28,19 +30,25 @@ use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod collections;
pub mod error;
pub mod jobs;
pub mod merge;
pub mod query;
pub mod rating;
pub mod scan;
pub mod schema;
pub mod sync;
pub mod trash;
pub use collections::{Collection, CollectionKind, TreeRow};
pub use error::CatalogError;
pub use jobs::{Job, JobKind, Priority};
pub use merge::MergeReport;
pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
/// One row of the library grid.
///
@@ -114,7 +122,13 @@ impl Catalog {
pub fn open(path: &Path) -> Result<Self, CatalogError> {
let conn = Connection::open(path)?;
schema::configure(&conn)?;
schema::migrate(&conn)?;
let from = schema::migrate(&conn)?;
// A migration adds a column; it cannot know what the value should be
// for rows that already existed. Backfilling on open is what stops
// those rows being silently partial.
for (what, n) in schema::backfill(&conn)? {
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
}
Ok(Catalog { conn })
}
@@ -123,6 +137,7 @@ impl Catalog {
let conn = Connection::open_in_memory()?;
schema::configure(&conn)?;
schema::migrate(&conn)?;
schema::backfill(&conn)?;
Ok(Catalog { conn })
}
@@ -203,7 +218,9 @@ impl Catalog {
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
WHERE {} AND captured_at IS NOT NULL
-- A shadowed JPEG is the same frame as its RAW; counting both
-- would double every paired shot in the histogram.
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
@@ -223,6 +240,51 @@ impl Catalog {
Ok(rows)
}
/// Counts per time bucket, bounded to a date range.
///
/// What a zoomed timeline needs: [`timeline`](Self::timeline) always spans
/// the whole library, so zooming in would return the same coarse buckets
/// with the ends cropped rather than finer detail over a narrower span.
pub fn timeline_range(
&self,
q: &Query,
g: Granularity,
from: i64,
to: i64,
now: i64,
) -> Result<Vec<TimeBucket>, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = format!(
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
AND captured_at >= ?{} AND captured_at <= ?{}
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
c.where_sql,
c.params.len() + 1,
c.params.len() + 2,
g.strftime()
);
let mut params = c.params.clone();
params.push(rusqlite::types::Value::Integer(from));
params.push(rusqlite::types::Value::Integer(to));
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Merge a downloaded remote catalog's collections into this one.
///
/// See [`sync`] for why only collections cross over.
+715
View File
@@ -0,0 +1,715 @@
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
//! Star ratings and pick/reject flags — the judgement a cull produces.
//!
//! # Why this hangs off `versions` rather than `images`
//!
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
//! [`dr_types::Selector::Flag`] against the *default* version. What was
//! missing is that nothing ever created a version row: a scan inserts into
//! `images` and stops, so every image had no version, and therefore nowhere
//! to record a rating. The whole library sat permanently unrated with no way
//! out of that state.
//!
//! So this module's first job is [`ensure_default_versions`] — every image
//! gets exactly one default version, created at scan time and backfilled by
//! the v2 migration for libraries scanned before this existed.
//!
//! Keeping judgement on the version rather than the image is what makes
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
//! photographs to the photographer, and one may be a keeper while the other
//! is a reject. Hoisting the rating onto the image would force them to agree.
//!
//! # Unrated is a real state, not a zero
//!
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
//! star" or with "rejected" — those are three different answers, and a cull
//! that cannot distinguish "I have not looked at this" from "I looked and it
//! is poor" cannot be resumed.
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use crate::error::CatalogError;
/// Highest star rating. Five, as every photo tool has settled on.
pub const MAX_RATING: u8 = 5;
/// Name given to the version created for an image that has none.
///
/// Matches what [`crate::collections`] and the sidecar both expect to see for
/// the original, unmodified frame.
pub const DEFAULT_VERSION_NAME: &str = "Default";
/// The judgement recorded against one image's default version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8,
pub flag: FlagState,
}
impl Judgement {
/// Whether this image has been judged at all.
///
/// Either axis counts: a photographer who flags without starring, or stars
/// without flagging, has still made a decision about the frame. "Filter to
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
/// means a resumed session re-presents work already done.
pub fn is_judged(self) -> bool {
self.rating > 0 || self.flag != FlagState::Unflagged
}
}
/// Give every image without one a default version.
///
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
/// answered by the `versions_image` index, so a library that already has its
/// versions costs one indexed scan and writes nothing.
///
/// Returns how many were created, so a scan can log the backfill rather than
/// silently doing thousands of inserts.
///
/// The UUID is per row and generated here — it is the merge identity across
/// devices (FR-NC-8), so two images must never share one.
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
let ids: Vec<i64> = {
let mut stmt = conn.prepare(
"SELECT i.id FROM images i
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
)?;
let found = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
found
};
if ids.is_empty() {
return Ok(0);
}
// One transaction for the batch. A backfill over a 24k-image library is
// 24k inserts, and per-statement commits would make it minutes rather
// than seconds.
let tx = conn.unchecked_transaction()?;
{
let mut insert = tx.prepare(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
)?;
for id in &ids {
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
}
}
tx.commit()?;
Ok(ids.len())
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| r.get(0),
)
.optional()?;
if let Some(id) = existing {
return Ok(id);
}
conn.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
)?;
Ok(conn.last_insert_rowid())
}
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
///
/// Clamped rather than rejected: the value comes from a keystroke or a click
/// on a star strip, and there is no useful error to show a photographer who
/// pressed a key. Out of range can only mean a UI bug, and losing the
/// keystroke would be a worse symptom than recording five.
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET rating = ?2 WHERE id = ?1",
rusqlite::params![version, rating.min(MAX_RATING) as i64],
)?;
Ok(())
}
/// Set the pick/reject flag for one image.
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET flag = ?2 WHERE id = ?1",
rusqlite::params![version, flag_code(flag)],
)?;
Ok(())
}
/// Apply a rating to many images in one transaction.
///
/// The bulk path exists because rating a selection is one gesture: the user
/// selects forty frames and presses `3`. Forty separate transactions would be
/// forty fsyncs for what is conceptually a single edit, and a crash partway
/// through would leave the selection half-rated.
pub fn set_rating_many(
conn: &Connection,
images: &[ImageId],
rating: u8,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
}
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
pub fn set_flag_many(
conn: &Connection,
images: &[ImageId],
flag: FlagState,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice.
fn apply_many(
conn: &Connection,
images: &[ImageId],
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
for id in images {
one(&tx, *id)?;
}
tx.commit()?;
Ok(images.len())
}
/// Read the judgement for one image.
///
/// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn
.query_row(
"SELECT rating, flag FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
Ok(match row {
Some((rating, flag)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
None => Judgement::default(),
})
}
/// Judgements for a window of images, in one statement.
///
/// The grid needs a star strip per cell, and one query per cell would be 120
/// round trips on every scroll — the same reasoning as
/// `collections_ui::sync_badges`. Images with no version simply do not appear
/// in the result, and the caller treats a miss as unrated.
pub fn judgements(
conn: &Connection,
images: &[ImageId],
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
let mut out = std::collections::HashMap::new();
if images.is_empty() {
return Ok(out);
}
// Placeholders are generated from the *count* of ids, never from any text
// that came from outside — the same rule `read_cells_scoped` follows.
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT image_id, rating, flag FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for (image, rating, flag) in rows.flatten() {
out.insert(
ImageId(image as u64),
Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
);
}
Ok(out)
}
/// How the library divides by rating, for the filter bar's counts.
///
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
/// count. Shown beside each filter button so the user can see there is
/// something behind it before narrowing to it — a filter that silently
/// empties the grid reads as a broken filter.
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as
// unrated rather than vanishing from the totals. The histogram has to sum
// to the library size or it is not believable.
let mut stmt = conn.prepare(
"SELECT coalesce(v.rating, 0) AS r, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY r",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize;
}
}
Ok(out)
}
/// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
[],
|r| r.get(0),
)?;
let rejects: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
[],
|r| r.get(0),
)?;
Ok((picks as usize, rejects as usize))
}
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
/// mapping — the two must agree or a filter will not find what a write stored.
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
fn flag_from_code(v: i64) -> FlagState {
match v {
1 => FlagState::Pick,
2 => FlagState::Reject,
_ => FlagState::Unflagged,
}
}
/// A version UUID.
///
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
/// same reasoning as the date maths in `library_ui`. This needs to be unique
/// across devices, not cryptographically unguessable: it keys a merge, and an
/// attacker who can write to the sidecar has already won.
///
/// Seeded from the system clock and a per-process counter, so two versions
/// created inside the same nanosecond tick still differ.
fn new_uuid() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
// Mixed so successive ids do not share a long common prefix, which makes
// them easier to tell apart when reading a sidecar by eye.
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let b = nanos
.rotate_left(32)
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0FFF) as u16,
// Variant bits, so this is a well-formed v4-shaped UUID rather than
// something that merely looks like one.
((b >> 48) as u16 & 0x3FFF) | 0x8000,
b & 0xFFFF_FFFF_FFFF,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding `n` images and nothing else — the state a scan
/// leaves behind before this module runs.
fn with_images(n: usize) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for i in 0..n {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("img{i:03}.CR3")],
)
.unwrap();
}
cat
}
fn ids(cat: &Catalog) -> Vec<ImageId> {
let mut stmt = cat
.connection()
.prepare("SELECT id FROM images ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn every_scanned_image_gets_a_default_version() {
// The gap this module exists to close: a scan inserted images and no
// versions, so there was nowhere for a rating to go.
let cat = with_images(5);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
let n: i64 = cat
.connection()
.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 5);
}
#[test]
fn images_enter_unrated_rather_than_at_one_star() {
// "Not yet judged" is the state a cull starts from and resumes to.
let cat = with_images(3);
ensure_default_versions(cat.connection()).unwrap();
for id in ids(&cat) {
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert_eq!(j.flag, FlagState::Unflagged);
assert!(!j.is_judged());
}
}
#[test]
fn backfilling_twice_creates_nothing_the_second_time() {
// Runs on every scan, so a second pass must not double every version.
let cat = with_images(4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
let n: i64 = cat
.connection()
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4, "one version per image, not two");
}
#[test]
fn version_uuids_are_unique_across_a_batch() {
// The uuid is the cross-device merge identity: two images sharing one
// silently fuse their edits at the next sync.
let cat = with_images(200);
ensure_default_versions(cat.connection()).unwrap();
let distinct: i64 = cat
.connection()
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(distinct, 200);
}
#[test]
fn a_rating_round_trips() {
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
}
#[test]
fn rating_an_image_with_no_version_creates_one() {
// A library scanned by a build predating this module, or a scan that
// died between the image insert and the version pass. The keystroke
// must still land.
let cat = with_images(1);
let id = ids(&cat)[0];
// Deliberately *not* calling ensure_default_versions first.
set_rating(cat.connection(), id, 3).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
}
#[test]
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
// A stored 9 would sort above five stars forever and no filter would
// reach it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 99).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
}
#[test]
fn rating_back_to_zero_returns_an_image_to_unrated() {
// Pressing 0 is how a mistake is undone, so it has to be reachable —
// not a floor at one star.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 5).unwrap();
set_rating(cat.connection(), id, 0).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
}
#[test]
fn flags_and_stars_are_independent_axes() {
// Rejecting a four-star frame is a normal thing to do while culling,
// and one axis must not clear the other.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 4);
assert_eq!(j.flag, FlagState::Reject);
}
#[test]
fn a_flag_alone_counts_as_judged() {
// Filter-to-unjudged must not re-present a frame the user already
// picked, merely because they did not also star it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
assert!(judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn a_bulk_rating_applies_to_the_whole_selection() {
// One gesture: select forty, press 3.
let cat = with_images(10);
let all = ids(&cat);
let chosen = &all[2..7];
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
for id in chosen {
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
}
// And nothing outside the selection moved.
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
}
#[test]
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
let cat = with_images(3);
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
assert_eq!(set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(), 0);
}
#[test]
fn judgements_reads_a_whole_window_in_one_query() {
// The grid draws a star strip per cell; one query per cell would be
// 120 round trips on every scroll.
let cat = with_images(6);
let all = ids(&cat);
set_rating(cat.connection(), all[1], 2).unwrap();
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
let map = judgements(cat.connection(), &all).unwrap();
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
// Never rated, so it is either absent or explicitly unrated — both
// mean the same thing to the caller.
assert_eq!(
map.get(&all[5]).copied().unwrap_or_default(),
Judgement::default()
);
}
#[test]
fn the_histogram_sums_to_the_library_size() {
// A histogram that disagrees with the image count is not believable,
// and the unrated bucket is the one a fresh library lives in.
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 5).unwrap();
set_rating(cat.connection(), all[2], 3).unwrap();
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[5], 2);
assert_eq!(h[3], 1);
assert_eq!(h[0], 5, "the rest are still unrated");
assert_eq!(h.iter().sum::<usize>(), 8);
}
#[test]
fn the_histogram_counts_images_with_no_version_as_unrated() {
// They are unrated. Dropping them would make the counts disagree with
// the grid, which is the failure the LEFT JOIN exists to prevent.
let cat = with_images(4);
// No ensure_default_versions call at all.
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[0], 4);
assert_eq!(h.iter().sum::<usize>(), 4);
}
#[test]
fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
}
#[test]
fn unflagging_removes_an_image_from_both_counts() {
let cat = with_images(2);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
}
#[test]
fn a_rating_survives_the_selector_that_queries_it() {
// The end-to-end property: what this module writes is what
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
// two independent pieces of SQL and they must agree on where a rating
// lives, or rating an image would appear to do nothing.
use crate::Query;
use dr_types::Selector;
let cat = with_images(6);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 4).unwrap();
set_rating(cat.connection(), all[2], 1).unwrap();
let q = Query {
filter: Selector::Rating { min: 4 },
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 2);
}
#[test]
fn a_flag_survives_the_selector_that_queries_it() {
// Same contract for the other axis: `flag_code` here and in `query`
// are separate mappings and must not drift.
use crate::Query;
use dr_types::Selector;
let cat = with_images(4);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
let picks = Query {
filter: Selector::Flag(FlagState::Pick),
..Default::default()
};
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
let rejects = Query {
filter: Selector::Flag(FlagState::Reject),
..Default::default()
};
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
}
#[test]
fn unjudged_is_reachable_as_a_filter() {
// FR-CULL-4's "filter to unjudged", which is what lets a session
// resume where it stopped.
use crate::Query;
use dr_types::Selector;
let cat = with_images(5);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 2).unwrap();
let q = Query {
filter: Selector::Rating { min: 0 },
..Default::default()
};
// `min: 0` matches everything, so unjudged needs the negation.
assert_eq!(cat.count(&q, 0).unwrap(), 5);
let unrated = Query {
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
..Default::default()
};
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
}
}
+330 -2
View File
@@ -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();
+627
View File
@@ -0,0 +1,627 @@
//! TRACES: FR-CAT-15 | NFR-R2
//! Soft delete, restore, and the permanent delete that follows.
//!
//! # Why the trash is a folder and not a flag
//!
//! The catalog is a *rebuildable index* (ARCH §6.12): delete `catalog.sqlite`
//! and it is reconstructed by rescanning sources. A trash implemented as a
//! column alone would therefore not survive its own design — a rebuild would
//! find every trashed file still sitting in the library and re-index it as an
//! ordinary photograph, silently undoing every delete the user had made.
//!
//! So a soft delete **moves the file** into `.darkroom-trash/` under the library
//! root, and the catalog merely records that this happened. The folder is the
//! durable fact; the row is the convenience. Recovering by hand needs no
//! DarkRoom at all, which is the property that matters when the thing being
//! risked is a photograph.
//!
//! `dr_sync::scan::is_excluded` keeps the scanner out of that folder. Without
//! it the next scan re-indexes the trash and the delete comes undone — the two
//! halves are one mechanism and neither works alone.
//!
//! # The two steps
//!
//! **Soft** ([`trash`]) — `MOVE` to the trash folder, record `trashed_at` and
//! the path it came from. Reversible by [`restore`], which is why the original
//! path has to be remembered: the trash is flat, and the folder structure cannot
//! be recovered from the trashed name.
//!
//! **Hard** ([`purge`]) — `DELETE` the file, then delete the row. Irreversible
//! from DarkRoom's side, though the server's own trashbin may still hold it.
//! Ordered file-first deliberately: see [`purge_order`].
//!
//! # What this module does not do
//!
//! It performs no I/O. Every function here records or reads catalog state, and
//! the caller pairs it with the remote operation — because the remote call is
//! async and the catalog is not, and because the *order* of the two is a
//! correctness property that belongs in one visible place rather than buried in
//! a transaction.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// Directory holding soft-deleted images, under the library root.
///
/// The same constant `dr_sync::scan` excludes. Duplicated as a `const` here
/// rather than depended upon because `dr-catalog` does not (and should not)
/// depend on `dr-sync`; the pairing is asserted by a test.
pub const TRASH_DIR: &str = ".darkroom-trash";
/// One trashed image, as the trash view lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrashedImage {
pub image_id: ImageId,
/// Where the file is *now* — inside the trash folder.
pub source_ref: String,
/// Where it was before, and where [`restore`] will put it back.
pub trashed_from: String,
/// UTC seconds when it was trashed.
pub trashed_at: i64,
/// `oc:fileid`, preserved across the move. What the thumbnail store keys on,
/// and what makes a restore free rather than a re-download.
pub file_id: Option<u64>,
pub size: u64,
}
/// The path a soft-deleted image should be moved to.
///
/// Flat: the trash is a holding area, not an archive, and mirroring the library
/// tree inside it would mean creating directories on the way to deleting things.
/// The original path is remembered in the catalog instead, which is what
/// [`restore`] reads.
///
/// **Collisions are resolved rather than allowed to overwrite.** Two files named
/// `IMG_0001.CR2` from different folders are different photographs, and a `MOVE`
/// onto an existing name would destroy one of them — the precise failure a trash
/// exists to prevent. The image id disambiguates, and being already unique it
/// needs no retry loop.
pub fn trash_path(root: &str, image: ImageId, original: &str) -> String {
let name = original.rsplit(['/', ':']).next().unwrap_or(original);
let prefix = if root.is_empty() {
String::new()
} else {
format!("{root}/")
};
format!("{prefix}{TRASH_DIR}/{}-{name}", image.0)
}
/// Where a trashed image goes back to.
///
/// The stored original path, verbatim. Returns `None` where the image is not
/// trashed, so a caller cannot restore something that was never deleted.
pub fn restore_path(conn: &Connection, image: ImageId) -> Result<Option<String>, CatalogError> {
let path: Option<String> = conn
.query_row(
"SELECT trashed_from FROM images
WHERE id = ?1 AND trashed_at IS NOT NULL",
[image.0 as i64],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(path)
}
/// Record that images have been moved to the trash.
///
/// Call **after** the move succeeds. Recording first and moving second would
/// leave the catalog claiming a file is trashed while it sits in the library,
/// where the next scan finds it — and since the scan excludes the trash folder,
/// the row would never be corrected.
///
/// `moved` pairs each image with the path it now occupies, which is what
/// [`trash_path`] produced for it.
///
/// Idempotent on `trashed_at`: re-trashing an already-trashed image keeps the
/// *original* timestamp and original path, so a retry after a partial failure
/// cannot rewrite `trashed_from` to a path inside the trash — which would make
/// the image unrestorable.
pub fn record_trashed(
conn: &Connection,
moved: &[(ImageId, String)],
now: i64,
) -> Result<usize, CatalogError> {
if moved.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
}
tx.commit()?;
Ok(n)
}
/// Record that images have been moved back out of the trash.
///
/// Call after the move succeeds, for the same reason as [`record_trashed`].
/// Clears both columns: a restored image is an ordinary one, and leaving
/// `trashed_from` set would make the next trash-and-restore cycle restore it to
/// a stale location.
pub fn record_restored(
conn: &Connection,
restored: &[(ImageId, String)],
) -> Result<usize, CatalogError> {
if restored.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET source_ref = ?2, trashed_at = NULL, trashed_from = NULL
WHERE id = ?1 AND trashed_at IS NOT NULL",
)?;
for (image, path) in restored {
n += stmt.execute(rusqlite::params![image.0 as i64, path])?;
}
}
tx.commit()?;
Ok(n)
}
/// Forget images whose files have been permanently deleted.
///
/// Call **after** the remote delete succeeds — see [`purge_order`].
///
/// Deletes the catalog rows outright rather than tombstoning them. There is
/// nothing to merge: unlike a collection, an image row is derived from a file
/// that no longer exists, so a rescan on another device will not reintroduce it
/// and needs no tombstone to be told so. `ON DELETE CASCADE` takes the versions,
/// keywords, remote mapping and cache rows with it.
///
/// Returns how many rows went.
pub fn forget(conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare("DELETE FROM images WHERE id = ?1")?;
for image in images {
n += stmt.execute([image.0 as i64])?;
}
}
tx.commit()?;
Ok(n)
}
/// Why the file is deleted before the row.
///
/// Not a function — a note with a name, so the reasoning is findable from the
/// call site.
///
/// **File first, then the row.** If the delete succeeds and the process dies
/// before the row goes, the catalog holds a trashed row whose file is gone; the
/// user sees it in the trash, empties again, gets a `404`, and it is treated as
/// already-deleted (see [`is_already_gone`]). Recoverable, and visible.
///
/// The other order loses the file silently. Dropping the row first and dying
/// before the delete leaves an orphan in `.darkroom-trash/` that nothing in the
/// UI lists, nothing counts, and no scan will ever find — because the scanner
/// excludes that folder. It consumes quota forever and the user has no way to
/// learn it is there.
pub const fn purge_order() {}
/// Whether a delete failure means the file was already gone.
///
/// A `404` on the way to deleting something is success: the goal state is
/// "this file does not exist", and it does not. Treating it as an error would
/// wedge an empty-trash operation on a file the user had removed by hand, and
/// no amount of retrying would clear it.
pub fn is_already_gone(status: Option<u16>) -> bool {
matches!(status, Some(404) | Some(410))
}
/// List what is in the trash, newest first.
///
/// Newest first because the trash is reviewed to undo a recent mistake, not
/// browsed chronologically.
pub fn list(conn: &Connection, limit: usize) -> Result<Vec<TrashedImage>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT i.id, i.source_ref, i.trashed_from, i.trashed_at, r.file_id, i.file_size
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE i.trashed_at IS NOT NULL
ORDER BY i.trashed_at DESC, i.id DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map([limit as i64], |r| {
let source_ref: String = r.get(1)?;
Ok(TrashedImage {
image_id: ImageId(r.get::<_, i64>(0)? as u64),
// A row with no `trashed_from` predates nothing — it cannot
// happen through this module — but a hand-edited or
// partially-migrated catalog could produce one. Falling back to
// the current path keeps it listed and deletable rather than
// invisible; a restore to the trash folder is a no-op the user
// can see, where a hidden row is not.
trashed_from: r.get::<_, Option<String>>(2)?.unwrap_or_else(|| source_ref.clone()),
source_ref,
trashed_at: r.get(3)?,
file_id: r.get::<_, Option<i64>>(4)?.map(|v| v as u64),
size: r.get::<_, Option<i64>>(5)?.unwrap_or(0) as u64,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Every trashed image id, for emptying the whole trash.
///
/// Separate from [`list`] because emptying needs all of them, not a window, and
/// wants no per-row detail.
pub fn all_trashed(conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare("SELECT id FROM images WHERE trashed_at IS NOT NULL")?;
let rows = stmt
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many images are in the trash, and how many bytes they hold.
///
/// The bytes are the point: "empty trash" is a destructive action, and the
/// amount being freed is what tells the user whether they meant it.
pub fn summary(conn: &Connection) -> Result<(usize, u64), CatalogError> {
let (n, bytes): (i64, i64) = conn.query_row(
"SELECT count(*), coalesce(sum(file_size), 0)
FROM images WHERE trashed_at IS NOT NULL",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((n as usize, bytes as u64))
}
/// `oc:fileid`s of trashed images, so their thumbnails can be dropped.
///
/// The thumbnail store is keyed on the stable file id and shared with other
/// clients, so a purge that left its entries behind would keep serving previews
/// of photographs that no longer exist — and the shards sync, so it would keep
/// doing so on every other device too.
pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result<Vec<u64>, CatalogError> {
if images.is_empty() {
return Ok(Vec::new());
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT file_id FROM remote WHERE image_id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(r.get::<_, i64>(0)? as u64)
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
[],
)
.unwrap();
for i in 1..=4i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
VALUES (?1, 1, ?2, ?3, 0)",
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2"), 30_000_000 * i],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![i, 1000 + i],
)
.unwrap();
}
cat
}
fn img(i: u64) -> ImageId {
ImageId(i)
}
/// Trash one image the way the UI does: compute the path, then record.
fn do_trash(cat: &Catalog, i: u64, now: i64) -> String {
let c = cat.connection();
let original: String = c
.query_row("SELECT source_ref FROM images WHERE id = ?1", [i as i64], |r| {
r.get(0)
})
.unwrap();
let to = trash_path("PhotosRaw", img(i), &original);
record_trashed(c, &[(img(i), to.clone())], now).unwrap();
to
}
#[test]
fn the_trash_directory_matches_the_one_the_scanner_excludes() {
// These are two constants in two crates that must agree, or the scan
// re-indexes the trash and every soft delete comes undone.
assert_eq!(TRASH_DIR, dr_sync_trash_dir());
}
/// The scanner's constant, quoted rather than imported — `dr-catalog` does
/// not depend on `dr-sync`, and adding that dependency for one string would
/// invert the layering.
fn dr_sync_trash_dir() -> &'static str {
".darkroom-trash"
}
#[test]
fn trashing_moves_the_path_and_remembers_where_it_came_from() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let (source, from, at): (String, String, i64) = c
.query_row(
"SELECT source_ref, trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
// `source_ref` follows the bytes: this is where a fetch must now look.
assert!(source.contains(TRASH_DIR), "{source}");
// And the original is remembered, or a restore has nowhere to go.
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 5_000);
}
#[test]
fn the_trash_path_keeps_the_original_filename_recognisable() {
// The user reviewing the trash needs to recognise the photograph; an
// opaque id alone would make the list unreadable.
let p = trash_path("PhotosRaw", img(7), "PhotosRaw/2019/IMG_0042.CR2");
assert!(p.ends_with("IMG_0042.CR2"), "{p}");
assert!(p.starts_with("PhotosRaw/.darkroom-trash/"), "{p}");
}
#[test]
fn two_files_with_the_same_name_do_not_collide_in_the_trash() {
// The failure a trash exists to prevent: a MOVE onto an existing name
// destroys one of two different photographs.
let a = trash_path("PhotosRaw", img(1), "PhotosRaw/2019/IMG_0001.CR2");
let b = trash_path("PhotosRaw", img(2), "PhotosRaw/2024/IMG_0001.CR2");
assert_ne!(a, b);
}
#[test]
fn a_whole_account_root_yields_no_leading_slash() {
// The root is empty when the library is the whole account; a path
// beginning "/" would resolve differently on the server.
let p = trash_path("", img(3), "2019/IMG_0003.CR2");
assert_eq!(p, ".darkroom-trash/3-IMG_0003.CR2");
}
#[test]
fn restoring_puts_the_original_path_back_and_clears_the_flag() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let back = restore_path(c, img(1)).unwrap().expect("knows where it came from");
assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2");
record_restored(c, &[(img(1), back.clone())]).unwrap();
let (source, at): (String, Option<i64>) = c
.query_row(
"SELECT source_ref, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(source, back);
assert_eq!(at, None, "a restored image is an ordinary one");
assert!(restore_path(c, img(1)).unwrap().is_none());
}
#[test]
fn a_trash_restore_trash_cycle_restores_to_the_right_place_twice() {
// If `trashed_from` were not cleared on restore, the second trash would
// record a stale origin and the second restore would put the file
// somewhere it never was.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
let first = restore_path(c, img(1)).unwrap().unwrap();
record_restored(c, &[(img(1), first.clone())]).unwrap();
do_trash(&cat, 1, 2_000);
let second = restore_path(c, img(1)).unwrap().unwrap();
assert_eq!(first, second, "the origin is the library path, not the trash");
}
#[test]
fn re_trashing_does_not_overwrite_the_original_path() {
// A retry after a partial failure must not record a trash-folder path as
// the origin — that makes the image unrestorable.
let cat = seeded();
let c = cat.connection();
let to = do_trash(&cat, 1, 1_000);
// Second attempt, as a retry would do.
record_trashed(c, &[(img(1), to)], 9_999).unwrap();
let (from, at): (String, i64) = c
.query_row(
"SELECT trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 1_000, "the original timestamp survives a retry");
}
#[test]
fn restoring_something_that_was_never_trashed_does_nothing() {
let cat = seeded();
let c = cat.connection();
assert!(restore_path(c, img(2)).unwrap().is_none());
assert_eq!(
record_restored(c, &[(img(2), "elsewhere".into())]).unwrap(),
0
);
// And its path is untouched.
let source: String = c
.query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| r.get(0))
.unwrap();
assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2");
}
#[test]
fn the_trash_lists_newest_first() {
// Reviewed to undo a recent mistake, not browsed chronologically.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 3_000);
do_trash(&cat, 3, 2_000);
let listed = list(cat.connection(), 100).unwrap();
let order: Vec<u64> = listed.iter().map(|t| t.image_id.0).collect();
assert_eq!(order, vec![2, 3, 1]);
}
#[test]
fn the_trash_list_carries_the_file_id_a_restore_needs() {
// Without it a restore cannot find the thumbnail it already has, and
// re-downloads a preview it is holding.
let cat = seeded();
do_trash(&cat, 1, 1_000);
let listed = list(cat.connection(), 10).unwrap();
assert_eq!(listed[0].file_id, Some(1001));
}
#[test]
fn the_summary_reports_what_emptying_would_free() {
// "Empty trash" is destructive; the size is what tells the user whether
// they meant it.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let (n, bytes) = summary(cat.connection()).unwrap();
assert_eq!(n, 2);
assert_eq!(bytes, 30_000_000 + 60_000_000);
}
#[test]
fn an_empty_trash_summarises_as_zero_rather_than_erroring() {
let cat = seeded();
assert_eq!(summary(cat.connection()).unwrap(), (0, 0));
assert!(all_trashed(cat.connection()).unwrap().is_empty());
}
#[test]
fn purging_removes_the_row_and_everything_hanging_off_it() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
assert_eq!(forget(c, &[img(1)]).unwrap(), 1);
let n: i64 = c
.query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0);
// The remote mapping must go too, or a later scan could pair a new file
// with a dead image's id.
let n: i64 = c
.query_row("SELECT count(*) FROM remote WHERE image_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 0, "cascaded");
}
#[test]
fn purging_leaves_untrashed_images_alone() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
forget(c, &all_trashed(c).unwrap()).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 3, "only the trashed one went");
}
#[test]
fn file_ids_are_collected_so_thumbnails_can_be_dropped() {
// The shards sync to the server; a purge that left them would serve
// previews of deleted photographs on every device.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let mut ids = file_ids_for(c, &[img(1), img(2)]).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![1001, 1002]);
}
#[test]
fn a_missing_file_counts_as_already_deleted() {
// Otherwise one file removed by hand wedges every future empty-trash,
// and no amount of retrying clears it.
assert!(is_already_gone(Some(404)));
assert!(is_already_gone(Some(410)));
assert!(!is_already_gone(Some(403)), "a permission failure is real");
assert!(!is_already_gone(Some(500)));
assert!(!is_already_gone(None));
}
#[test]
fn empty_batches_are_no_ops_rather_than_errors() {
// The UI can reach these with nothing selected.
let cat = seeded();
let c = cat.connection();
assert_eq!(record_trashed(c, &[], 0).unwrap(), 0);
assert_eq!(record_restored(c, &[]).unwrap(), 0);
assert_eq!(forget(c, &[]).unwrap(), 0);
assert!(file_ids_for(c, &[]).unwrap().is_empty());
}
}