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
+192 -11
View File
@@ -80,7 +80,20 @@ pub enum ScanMessage {
/// amount of string matching on the far side can reliably recover it.
/// Without the flag a dead connection and a bad password produce the same
/// banner, which sends the user to re-enter a credential that was fine.
Failed { message: String, offline: bool },
///
/// `lost_root` is the same idea one step further out, and it is carried
/// separately from `offline` rather than folded into it because the two
/// end differently. An offline library comes back when the network does,
/// with nothing asked of anyone; a library whose root cannot be opened
/// comes back only when someone restores access to it — a share put back
/// on the server, a drive plugged in, and in time a document tree granted
/// again once one can be (FR-PLAT-AND-2). Both show the same grid of what
/// is stored locally, and they must not offer the same explanation.
Failed {
message: String,
offline: bool,
lost_root: bool,
},
}
/// One decoded thumbnail, ready for the grid.
@@ -186,6 +199,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CULL-5
/// The clause that hides the frames a collapsed burst is standing in for.
///
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
/// reason: the header's count, the scrollbar's size, the run a shift-click
/// resolves and the ordinal a scrub lands on are four answers about one list.
/// A burst folded away in the cells but still counted in the total would leave
/// the grid ending in rows that draw nothing, with no clue why.
///
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
/// interface and the pass that writes the table cannot come to disagree about
/// what collapsed means.
///
/// A function rather than a constant because it has to name the image table,
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
/// is a table name from this file and never anything a user supplied.
fn uncollapsed(image: &str) -> String {
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
}
/// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken.
///
@@ -1146,6 +1179,7 @@ pub fn spawn_scan(
let _ = tx.send(ScanMessage::Failed {
message: e.message,
offline: e.offline,
lost_root: e.lost_root,
});
}
});
@@ -1160,6 +1194,7 @@ pub fn spawn_scan(
struct ScanFailure {
message: String,
offline: bool,
lost_root: bool,
}
impl ScanFailure {
@@ -1169,6 +1204,7 @@ impl ScanFailure {
Self {
message: message.to_string(),
offline: false,
lost_root: false,
}
}
}
@@ -1177,11 +1213,48 @@ impl From<dr_sync::RemoteError> for ScanFailure {
fn from(e: dr_sync::RemoteError) -> Self {
Self {
offline: e.indicates_offline(),
lost_root: e.indicates_lost_root(),
message: e.to_string(),
}
}
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Record that a library can no longer be opened, without losing it.
///
/// Called on the worker, before the failure crosses the channel, because this
/// is where the catalog handle is — and because the marking must be durable
/// whether or not anyone is left to draw a banner. A process killed between
/// the failure and the next launch must still come back knowing what it could
/// not reach.
///
/// Nothing is deleted. Every rating, every edit and every row stays exactly
/// where it was; what changes is that the images now say they are offline, so
/// the grid can show them as held-not-here rather than as ordinary
/// photographs whose thumbnails happen to be failing one at a time.
///
/// A root with no row yet is the first scan of a library that has never
/// succeeded, and there is nothing to mark — the failure alone is the whole
/// story, and the launch screen is where it is told.
fn mark_library_offline(catalog: &Catalog, root: &str) {
let conn = catalog.connection();
let root_id: Option<i64> = conn
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok();
let Some(root_id) = root_id else {
log::info!("library {root} has no catalog root yet; nothing to mark offline");
return;
};
match dr_catalog::mark_root_offline(conn, dr_types::RootId(root_id as u64)) {
Ok(()) => log::warn!("library {root} is unreachable; its images are marked offline"),
Err(e) => log::error!("could not mark {root} offline: {e}"),
}
}
fn run_scan(
tx: &Sender<ScanMessage>,
conn: Connection,
@@ -1199,21 +1272,50 @@ fn run_scan(
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Classified rather than flattened to a local failure, because the
// removed-card case never gets as far as a request: the folder
// connector checks its root when it is constructed, so a library on an
// ejected card fails here and not in the walk. Reported as an ordinary
// error it left the grid showing a healthy library of images that
// could no longer be opened, one silent thumbnail failure at a time.
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
// first run this is empty and the walk is complete; on every run after
// it is what keeps cost proportional to what changed (ARCH §8.4).
let known = load_folder_etags(&catalog, &root);
let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
let scanned = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
let _ = tx.send(ScanMessage::Progress {
directories: p.directories_listed,
pruned: p.directories_pruned,
images: p.images_found,
});
})
.await?;
.await;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Written before the failure is reported, not after: the banner is a
// consequence of the catalog state and not the other way round, and a
// process that dies between the two must come back knowing.
let result = match scanned {
Ok(r) => r,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
@@ -1306,13 +1408,27 @@ fn persist(
.ok()
});
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The `availability` arm is what ends an offline library, and it does
// it one photograph at a time. 3 is `Availability::Offline` and 0 is
// `MetadataOnly`, the same code this statement inserts new rows with —
// so a row that was marked offline when the root became unreachable is
// returned to exactly the state a fresh scan would have given it, and
// a row that was never marked is not touched at all.
//
// Conditional rather than a blanket reset for the same reason
// `dr_catalog::walk` restores per file rather than per root: the only
// thing that may clear "I could not reach this" is having reached it,
// and this statement runs precisely once per file the scan listed.
tx.execute(
"INSERT INTO images(root_id, folder_id, source_ref, format, file_size,
availability, metadata_state, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6)
ON CONFLICT(root_id, source_ref) DO UPDATE SET
file_size = excluded.file_size,
folder_id = excluded.folder_id",
folder_id = excluded.folder_id,
availability = CASE WHEN images.availability = 3
THEN 0 ELSE images.availability END",
rusqlite::params![
root_id,
folder_id,
@@ -3816,10 +3932,11 @@ pub fn read_cells_scoped(
.join(",");
let rated = filter.sql();
let (order, order_params) = grid_order_for(catalog, Some(scope));
let folded = uncollapsed("i");
let sql = format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
{order}
@@ -3854,11 +3971,12 @@ fn read_cells_all(
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
@@ -3964,10 +4082,15 @@ pub fn read_ids_span(
// different ORDER BY names a different photograph.
let (order, order_params) = grid_order_for(catalog, scope);
params.extend(order_params);
// And the same folding, for the same reason one step further on: a
// collapsed burst is one cell in the grid, so an ordinal counted over
// a list that still held every frame of it would name a photograph
// several places away from the one the user pointed at.
let folded = uncollapsed("i");
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause}
WHERE {VISIBLE}{rated}{folded}{clause}
{order}
LIMIT ? OFFSET ?"
),
@@ -4096,9 +4219,10 @@ pub fn total_images_scoped(
// Counted through `images` rather than over `collection_members` alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
);
@@ -4409,8 +4533,9 @@ fn total_images_filtered(
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"),
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
[],
|r| r.get(0),
)?;
@@ -4956,12 +5081,16 @@ mod tests {
#[test]
fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20);
// Including the burst clause, because the grid includes it: a
// predicate that quietly cost the ordering index would put the sort
// back and this is the only place that would notice.
let folded = uncollapsed("i");
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE}
WHERE {VISIBLE}{folded}
{GRID_ORDER}
LIMIT 10 OFFSET 5"
))
@@ -5014,6 +5143,58 @@ mod tests {
catalog
}
/// TRACES: FR-CULL-5
/// A folded burst takes rows out of the cells, the count and the range a
/// shift-click resolves — all three, together.
///
/// This is the test that would fail if the clause were added to four of
/// the five queries that need it. That failure has no other symptom: the
/// header claims images the grid will not draw, the scrollbar sizes itself
/// for rows that are not there, and neither number looks wrong on its own.
#[test]
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
use dr_catalog::bursts::{self, Rules, Signature};
let catalog = with_images(4);
// Three of the four are one burst: a second apart, one signature.
let ids = image_ids(&catalog);
for (n, id) in ids.iter().enumerate() {
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
catalog
.connection()
.execute(
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
perceptual_hash = ?3
WHERE id = ?1",
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
)
.unwrap();
}
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
let filter = RatingFilter::default();
// Open, as a new burst is: nothing has been taken away yet.
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
let cells = read_cells(&catalog, 0, 50).unwrap();
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
assert_eq!(
total_images_filtered(&catalog, &filter).unwrap(),
cells.len(),
"the header's count and the cells disagree"
);
assert_eq!(
read_ids_span(&catalog, None, &filter, false, 0, 49)
.unwrap()
.len(),
cells.len(),
"a shift-click over the whole grid would select frames it cannot show"
);
}
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog
.connection()