Merge master into tablet-selection

Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:52:42 +02:00
co-authored by Claude Opus 5
54 changed files with 7566 additions and 166 deletions
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -16,6 +16,7 @@
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`faces`] — detected faces, the people they belong to, and who said so
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
//! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
@@ -33,6 +34,7 @@ use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod bursts;
pub mod cache;
pub mod collections;
pub mod dedup;
@@ -63,7 +65,7 @@ pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
+80 -1
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 = 10;
pub const SCHEMA_VERSION: i64 = 11;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 11 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V11)?;
tx.pragma_update(None, "user_version", 11)?;
tx.commit()?;
}
Ok(from)
}
@@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
/// creating it over there would fail on columns that are not there. Nothing is
/// lost by its absence — it exists to make the *grid* page quickly, and the
/// grid never reads across an attachment.
///
/// V11 is excluded on the same grounds and for the plainer reason that a merge
/// has nothing to do with it: burst grouping is rebuilt locally from local
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
pub fn for_attached(schema_name: &str) -> String {
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
// columns are spelled out. A remote genuinely older than V10 is a real
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
ALTER TABLE faces ADD COLUMN crop BLOB;
"#;
/// TRACES: FR-CULL-5
/// Burst grouping: which frames are one moment, and which one stands for it.
///
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
/// belongs here is why it is stored in three pieces rather than one.
///
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
/// `content_hash` is: it is one number per image, NULL until something has had
/// the pixels in hand, and every query that wants it is already reading the
/// image row. It is local derived state — a rebuilt catalog recomputes it from
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
/// the shadowing and trashing columns V2 through V5 add.
///
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
/// group is named by the image id of its earliest frame, so a burst that has not
/// changed keeps its name across a regroup and the interface can remember that
/// this one is open. There is no `bursts` table to go with it because a group
/// has no properties beyond its members — inventing a row for it would create an
/// identity that survives the grouping being rebuilt, which is precisely what
/// must not happen.
///
/// **`burst_pick` is the one thing here that is not derived**, and it is a
/// separate table so that rewriting the grouping cannot erase it. A
/// representative stored on `burst_members` would be forgotten every time a
/// frame arrived; the user would be asked the same question after every scan.
/// The same argument `people.ignored` makes in V10, one subsystem over.
///
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
/// so what a collapsed burst hides has to be decided by the query, or the row
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
/// is emptied of stale groups by every pass.
const V11: &str = r#"
-- A 64-bit perceptual signature. Local derived state: NULL until something has
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
-- comparable only to signatures produced by the same build (`bursts`).
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
CREATE TABLE burst_members (
-- One burst at most per image: a frame belongs to the moment it was taken
-- in, and nothing else.
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
-- The image id of the burst's earliest frame. Not a foreign key by
-- accident: the leader is itself a member, so this genuinely references
-- images(id), and cascading its deletion is right.
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
-- The frame the group collapses to. Exactly one per burst.
representative INTEGER NOT NULL DEFAULT 0
);
-- Counting a burst's frames and listing them are what the grid asks for, once
-- per window; without this both are a scan of every grouped frame in the
-- library.
CREATE INDEX burst_members_burst ON burst_members(burst_id);
-- The user's own choice of representative. User data, never rewritten by a
-- grouping pass -- see the module doc above.
CREATE TABLE burst_pick (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
-- Bursts the grid is currently showing in full.
CREATE TABLE burst_expanded (
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+61 -3
View File
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
Ok(next)
}
/// TRACES: FR-CAT-9
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Mark every image under a root as unreachable.
///
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
/// the root now claims something about the files that is no longer known to be
/// true, and only reading the directories again can settle it. Pruning would
/// skip them all and leave a plugged-in library showing as offline forever.
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
///
/// # Why the ETag goes with the mtime
///
/// The three columns are the same fact told by three kinds of storage: a local
/// directory proves it is unchanged with its mtime and entry count, and a
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
/// them and leaving the third would disarm the re-listing on exactly the
/// libraries this is most likely to be called for — a remote scan prunes on
/// the ETag alone, so a root that came back would be walked, found unchanged
/// at every level, pruned whole, and left with every row still marked offline
/// and nothing that would ever clear the mark.
///
/// # Public, because losing a root is not only the local walk's business
///
/// This began as the private end of [`scan_root`]'s root-failure branches,
/// which is the only route a library reached through [`Storage`] can take.
/// The application does not currently take that route at all: it opens
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
/// crate that cannot see this one's internals, and the correct response is
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
/// caller that found out — a second copy would be a second thing to remember
/// when the ETag rule below changes.
///
/// [`Storage`]: dr_plat::Storage
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
let root_id = root.0 as i64;
conn.execute(
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
rusqlite::params![availability_code(Availability::Offline), root_id],
)?;
conn.execute(
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
[root_id],
)?;
Ok(())
@@ -995,6 +1019,40 @@ mod tests {
);
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
#[test]
fn marking_a_root_offline_forgets_the_remote_validator_too() {
// The half of the marking that only a remote library can notice, and
// the reason it has to be here rather than beside the connector: a
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
// the local mtime and leave the ETag standing and a library that came
// back would be walked, found unchanged at every level, pruned whole,
// and left with every row still marked offline — with nothing that
// would ever clear the mark, because clearing it is something only a
// listing can do.
//
// Written directly because this module never writes an ETag; it is
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
let lib = Library::new("etag-forgotten");
lib.file("2026/IMG.CR3", b"raw");
lib.scan();
lib.conn()
.execute(
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
[lib.root.0 as i64],
)
.expect("etag");
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
mark_root_offline(lib.conn(), lib.root).expect("mark");
assert_eq!(
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
0,
"an unreachable library must be re-listed, not pruned as unchanged"
);
}
#[test]
fn a_root_that_comes_back_is_available_again() {
// The other half: a drive plugged back in must return the library to