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
+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.