Files
DarkRoom/ui/dr-ui/src/library.rs
T
dtourolleandClaude Opus 5 d49b4b41de Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than
one, and exposed a scrub that landed in the wrong place.

**Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and
`Large` (1024px, ~45 KB), with the class part of the store key so both
coexist. Storing everything large would take the reference library from
~200 MB to ~860 MB, and shards sync, so that is transfer cost on every
device rather than only disk. A store written before the class existed
migrates in place: its entries are all grid-sized, which is what the
column defaults to, so nothing already fetched is discarded.

`forget` now drops every size for an image. Reading a single row left the
other class's bytes on the shard's tally for good, sealing it early on
space nothing occupied.

**Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in
geometric steps, so the gesture feels the same at either end where a fixed
pixel step would be imperceptible at 400px and violent at 90px. Crossing
256px switches to the large class, so a zoomed cell is sharp rather than
upscaled. Columns and window capacity already derived from cell size, so
the grid reflows for free.

**The scrub landed about half a library too high.** It counted only dated
images while the grid shows all of them — 10,733 dated against 19,841
rows — and ignored `shadowed_by`. Verified against the live catalog: the
old formula gave 10,887, the new one 10,732, the true grid position
10,732. The scrub's count and the grid's window must use identical
predicates and ordering; a test now fails if they diverge.

**Timeline gestures are continuous.** Scrub and pan were quantised to
whole buckets, so a slow drag did nothing until it crossed a boundary and
then jumped a month. Both work in fractions of the visible span now, and
pinch-to-zoom arrives for tablet, where there is no wheel to reach the
axis with.

The pinch accumulator was wrong on first writing: it took at most one step
per update, so an 8x spread — three doublings — yielded one zoom level.
`log2().trunc()` now extracts every whole doubling and carries the
remainder. The original test asserted the wrong number and defended it in
a comment, which is worth remembering: a test can entrench a bug as
readily as catch one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:58:32 +02:00

2346 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-CAT-1 | FR-CAT-4 | FR-NC-3 | NFR-P9
//! Opening a remote library: scan → catalog → grid.
//!
//! This is the wire between three pieces that already worked separately —
//! `dr_sync::scan` walks the tree, `dr_catalog` indexes it, and
//! `dr_decode::preview` turns bytes into pixels. Until now the "Open library"
//! button logged its intent and stopped.
//!
//! # Threading
//!
//! Slint's event loop is single-threaded and must never block (NFR-P9), so
//! every network and decode operation runs on a worker thread and results
//! return through an mpsc channel drained by a Slint timer. That is the same
//! shape the login flow uses; it is repeated rather than shared because the
//! message types differ and a generic version would obscure both.
//!
//! # Why thumbnails are fetched, not derived from the scan
//!
//! A scan yields paths and sizes, nothing visual. Each thumbnail costs its own
//! range request, so they are fetched **only for cells the grid actually
//! wants** — never for the whole library up front. On the reference library
//! that is the difference between a few MB and ~370 GB (ARCH §6.7).
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::{Catalog, JobKind, Priority};
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
use dr_thumbs::ThumbStore;
use dr_types::FormatFilter;
/// Largest preview worth fetching whole.
///
/// A located preview above this is skipped rather than transferred: past a few
/// MB the saving over the full file stops justifying the wait, and a 256px
/// thumbnail needs nothing like that much detail.
const MAX_PREVIEW_BYTES: u64 = 8 * 1024 * 1024;
/// Long edge of a grid thumbnail, in pixels.
const THUMBNAIL_EDGE: u32 = 256;
/// Progress and results from the scan worker.
#[derive(Debug)]
pub enum ScanMessage {
/// Directories walked so far, and images found.
Progress {
directories: usize,
pruned: usize,
images: usize,
},
/// The scan finished and the catalog is populated.
///
/// `found` counts what this scan *listed*, which on an incremental rescan
/// is only what changed — pruned directories contribute nothing. `total`
/// is what the catalog actually holds, which is what the grid shows.
/// Conflating them made a successful no-op rescan report "0 images" and
/// blank the library.
Done {
found: usize,
total: usize,
pruned: usize,
elapsed_ms: u64,
},
Failed(String),
}
/// One decoded thumbnail, ready for the grid.
#[derive(Debug)]
pub struct ThumbnailReady {
/// Index into the grid model this belongs to.
pub row: usize,
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// Capture metadata read from the same header the thumbnail needed.
///
/// Free: the header fetch happens either way, so parsing EXIF out of it costs
/// no extra transfer. That is what fills the timeline as the user browses,
/// rather than a separate 6 GB sweep over the library.
#[derive(Debug, Clone)]
pub struct MetadataFound {
pub image_id: i64,
pub captured_at: Option<i64>,
pub captured_offset: Option<i32>,
pub camera: Option<String>,
pub lens: Option<String>,
pub iso: Option<u32>,
}
/// Messages from the thumbnail worker.
#[derive(Debug)]
pub enum ThumbnailMessage {
Ready(Box<ThumbnailReady>),
/// No preview could be extracted. The cell stays a placeholder rather than
/// silently retrying forever.
Unavailable { row: usize, reason: String },
/// How the batch split between the store and the network.
///
/// Sent once, before any fetch. Without it there is no way to tell a
/// working cache from a broken one — both fill the grid, one just costs
/// nothing.
Plan {
cached: usize,
fetching: usize,
dating: usize,
},
/// One header-only date read is starting.
///
/// Reported separately from thumbnail progress: this work produces no
/// visible cell, so without it the window looks idle while it runs.
DateProgress,
/// Capture dates were written to the catalog.
///
/// The timeline is rebuilt on this rather than per image — a histogram
/// that redrew 120 times during a batch would flicker for no benefit.
DatesRecorded(usize),
}
/// TRACES: FR-CAT-15 | FR-CAT-11
/// What it means for an image to be visible in the library.
///
/// Two exclusions, for two different reasons, and both must appear in *every*
/// query that counts or lists cells — the grid, the timeline, the metadata
/// sweep. A predicate present in four of five places is worse than absent: the
/// counts disagree with the cells and neither looks wrong on its own.
///
/// - `shadowed_by IS NULL` — a JPEG the camera wrote beside its RAW is that
/// same frame, not a second photograph.
/// - `trashed_at IS NULL` — a soft-deleted image has been moved to the trash
/// folder and is listed only by the trash view.
const VISIBLE: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NULL";
/// [`VISIBLE`] for queries that do not alias `images`.
const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// TRACES: FR-CAT-15
/// What the *trash view* lists: exactly what [`VISIBLE`] excludes on the second
/// clause, and still excludes on the first.
///
/// The inversion is deliberate and only correct on `trashed_at`. A shadowed JPEG
/// is not a separate photograph in the trash any more than it is in the library
/// — trashing a RAW takes its sibling with it, and listing both would offer to
/// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CAT-6 | FR-CULL-4
/// What the grid is narrowed to by the rating filter bar.
///
/// Applied in **SQL**, not by filtering the rows after reading them. On a
/// remote library a drawn-then-hidden cell has already cost a thumbnail
/// fetch, which is the transfer FR-NC-3 exists to avoid — and the count in the
/// header has to agree with the cells, which it cannot if the two are computed
/// at different stages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RatingFilter {
/// Minimum stars. 0 means no star constraint.
pub min_rating: u8,
/// Only images nothing has judged yet — neither starred nor flagged.
/// This is what lets a culling session resume where it stopped.
pub unjudged: bool,
/// `None` for no flag constraint, otherwise exactly that flag.
pub flag: Option<dr_types::FlagState>,
}
impl RatingFilter {
/// Whether this narrows anything, so the caller can skip the join.
pub fn is_unfiltered(&self) -> bool {
self.min_rating == 0 && !self.unjudged && self.flag.is_none()
}
/// The SQL predicate, against an `images` aliased as `i`.
///
/// Returns a `String` of conditions ANDed together, or an empty string
/// where nothing is constrained. Every branch is built from integers this
/// code owns — no caller text reaches the SQL, so there is nothing to
/// escape.
///
/// A correlated subquery per term rather than a join to `versions`: an
/// image with no version row must still be *findable* as unrated, and an
/// inner join would silently drop exactly those images — the ones a
/// library scanned before ratings existed consists entirely of.
fn sql(&self) -> String {
let mut terms = Vec::new();
if self.min_rating > 0 {
terms.push(format!(
"coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) >= {}",
self.min_rating
));
}
if self.unjudged {
// Both axes: a frame that was picked but never starred has been
// judged, and re-presenting it would undo the user's decision to
// move past it.
terms.push(
"coalesce((SELECT dv.rating FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) = 0
AND coalesce((SELECT dv.flag FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) = 0"
.to_string(),
);
}
if let Some(flag) = self.flag {
terms.push(format!(
"coalesce((SELECT dv.flag FROM versions dv
WHERE dv.image_id = i.id AND dv.is_default = 1
LIMIT 1), 0) = {}",
flag_code(flag)
));
}
if terms.is_empty() {
String::new()
} else {
format!(" AND ({})", terms.join(") AND ("))
}
}
}
/// The stored integer for a flag, matching `dr_catalog::rating`'s encoding.
fn flag_code(f: dr_types::FlagState) -> i64 {
match f {
dr_types::FlagState::Unflagged => 0,
dr_types::FlagState::Pick => 1,
dr_types::FlagState::Reject => 2,
}
}
/// TRACES: FR-CAT-8 | FR-NC-8 | FR-CULL-4
/// One image's judgement, on its way to a sidecar.
#[derive(Debug, Clone)]
pub struct JudgementWrite {
/// Remote path of the *image*. The sidecar sits beside it, with the
/// extension replaced — that adjacency is what makes a sidecar findable
/// without an index (ARCH §6.12).
pub image_path: String,
pub version_uuid: String,
pub rating: u8,
pub flag: u8,
}
/// Where an image's sidecar lives.
///
/// The image's own path with the extension replaced, not appended: `a.CR2`
/// becomes `a.drsc`, so a RAW and the JPEG beside it share one sidecar and
/// therefore one judgement. That is the intended behaviour — they are the same
/// photograph (FR-CAT-11), and the pairing logic in `dr_catalog::schema`
/// already treats them so.
pub fn sidecar_path(image_path: &str) -> String {
let stem = match image_path.rsplit_once('.') {
// Only an extension in the final segment counts; a dot in a directory
// name must not truncate the path.
Some((stem, ext)) if !ext.contains('/') => stem,
_ => image_path,
};
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
}
/// Persist judgements to sidecars beside their images.
///
/// # Why this reads before it writes
///
/// A sidecar is the authoritative store and may already hold an edit made on
/// this or another device. Writing a fresh document containing only a rating
/// would delete that edit — the exact silent data loss the format's
/// unknown-key preservation exists to prevent. So each file is fetched,
/// parsed, amended, and written back; a fetch that 404s simply means there is
/// no sidecar yet and a new one is created.
///
/// # Why failure here is logged rather than surfaced
///
/// The catalog write has already succeeded by the time this runs, so the star
/// is on screen and will survive a restart. A network failure costs
/// *durability across a catalog rebuild*, not the judgement itself, and
/// interrupting a cull with an error dialog per frame would be far worse than
/// the risk. The count of failures is reported once, at the end.
pub fn spawn_sidecar_writes(
creds: AppCredentials,
user_id: String,
writes: Vec<JudgementWrite>,
) -> Receiver<SidecarMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(SidecarMessage::Finished {
written: 0,
failed: writes.len(),
last_error: Some(e.to_string()),
});
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(SidecarMessage::Finished {
written: 0,
failed: writes.len(),
last_error: Some(e.to_string()),
});
return;
}
};
let (mut written, mut failed) = (0usize, 0usize);
let mut last_error = None;
for w in &writes {
match write_one_sidecar(&backend, w).await {
Ok(()) => written += 1,
Err(e) => {
log::debug!("sidecar for {}: {e}", w.image_path);
last_error = Some(e);
failed += 1;
}
}
}
let _ = tx.send(SidecarMessage::Finished {
written,
failed,
last_error,
});
});
});
rx
}
/// The outcome of a batch of sidecar writes.
#[derive(Debug)]
pub enum SidecarMessage {
Finished {
written: usize,
failed: usize,
/// Reported once rather than per file: a network that is down fails
/// every write with the same message, and forty identical lines in the
/// status bar say nothing forty times.
last_error: Option<String>,
},
}
/// Read-modify-write one sidecar.
async fn write_one_sidecar(
backend: &NextcloudBackend,
w: &JudgementWrite,
) -> Result<(), String> {
let path = RemotePath::new(sidecar_path(&w.image_path));
let id = RemoteId::Path(path.clone());
// An existing sidecar may hold an edit. Absent is the normal case on a
// library that has never been edited, and is not an error.
let existing = backend.get(&id, None).await.ok();
let mut sidecar = existing
.as_deref()
.map(|bytes| String::from_utf8_lossy(bytes).into_owned())
.and_then(|text| match dr_pipeline::Sidecar::parse(&text) {
Ok(s) => Some(s),
// A corrupt sidecar is *not* overwritten silently: that would
// destroy an edit this build merely failed to understand. The
// judgement stays in the catalog and the file is left alone.
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e}); not overwriting", path.as_str());
None
}
})
.unwrap_or_default();
// A parse failure above means we must not touch the file at all.
if existing.is_some() && sidecar.versions.is_empty() && existing.as_deref() != Some(b"") {
// Distinguish "empty file" from "unparseable": only the latter is a
// refusal, and it already logged.
let text = existing
.as_deref()
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap_or_default();
if dr_pipeline::Sidecar::parse(&text).is_err() {
return Err("existing sidecar is unreadable".into());
}
}
// Amend the version this judgement belongs to, creating it if the file did
// not have one. The uuid comes from the catalog, so the same photograph
// keeps one identity across devices (FR-NC-8).
let mut version = sidecar
.versions
.get(&w.version_uuid)
.cloned()
.unwrap_or_else(|| dr_pipeline::sidecar::Version {
uuid: w.version_uuid.clone(),
name: "Default".to_string(),
is_default: true,
revision: 0,
..Default::default()
});
version.rating = w.rating;
version.flag = w.flag;
// A judgement is an edit as far as the merge is concerned: without the
// bump, a device that rated the same frame earlier would win on revision
// and this rating would be discarded at the next sync (FR-NC-9).
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
backend
.put(&path, sidecar.to_text().into_bytes(), None)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Where the catalog for an account lives.
///
/// Keyed by server and user so two accounts do not share an index. Under the
/// XDG data directory, not cache: the catalog is rebuildable but rebuilding it
/// costs a full rescan, so it is not something to discard on a cache sweep.
pub fn catalog_path(server: &str, user_id: &str) -> PathBuf {
let slug: String = server
.trim_start_matches("https://")
.trim_start_matches("http://")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.unwrap_or_else(std::env::temp_dir);
base.join("darkroom")
.join(format!("{slug}-{user_id}"))
.join("catalog.sqlite")
}
/// Run a scan on a worker thread, writing results into the catalog.
///
/// Returns the receiver the UI drains. The worker owns its own tokio runtime
/// and backend; nothing here touches the Slint event loop.
pub fn spawn_scan(
creds: AppCredentials,
user_id: String,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
) -> Receiver<ScanMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let started = std::time::Instant::now();
if let Err(e) = run_scan(&tx, creds, user_id, root, filter, catalog_path, started) {
let _ = tx.send(ScanMessage::Failed(e));
}
});
rx
}
fn run_scan(
tx: &Sender<ScanMessage>,
creds: AppCredentials,
user_id: String,
root: String,
filter: FormatFilter,
catalog_path: PathBuf,
started: std::time::Instant,
) -> Result<(), String> {
if let Some(dir) = catalog_path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let backend = NextcloudBackend::new(&creds, &user_id).map_err(|e| e.to_string())?;
// 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 _ = tx.send(ScanMessage::Progress {
directories: p.directories_listed,
pruned: p.directories_pruned,
images: p.images_found,
});
},
)
.await
.map_err(|e| e.to_string())?;
persist(&catalog, &root, &result).map_err(|e| e.to_string())?;
// Report what the catalog holds, not what this pass listed. An
// incremental rescan lists only what changed, so its own count is
// near zero on a healthy library.
let total = total_images(&catalog).unwrap_or(result.images.len());
let _ = tx.send(ScanMessage::Done {
found: result.images.len(),
total,
pruned: result.progress.directories_pruned,
elapsed_ms: started.elapsed().as_millis() as u64,
});
Ok(())
})
}
/// Read back the folder ETags stored by a previous scan.
///
/// A failure here is not fatal — an empty map simply means no pruning, which
/// is correct but slower. Refusing to scan because the last scan's bookkeeping
/// is unreadable would be the worse outcome.
fn load_folder_etags(
catalog: &Catalog,
root: &str,
) -> std::collections::HashMap<RemotePath, dr_sync::Validator> {
let mut out = std::collections::HashMap::new();
let sql = "SELECT f.path, f.etag FROM folders f
JOIN roots r ON r.id = f.root_id
WHERE r.label = ?1 AND f.etag IS NOT NULL";
let Ok(mut stmt) = catalog.connection().prepare(sql) else {
return out;
};
let rows = stmt.query_map([root], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
});
if let Ok(rows) = rows {
for (path, etag) in rows.flatten() {
out.insert(RemotePath::new(path), dr_sync::Validator::new(etag));
}
}
out
}
/// Write a scan's findings into the catalog.
///
/// Images insert at `metadata_state = 1` (stat-only): the scan knows name and
/// size but has read no EXIF, and pretending otherwise would make a date
/// filter silently wrong. A `Thumbnail` job is enqueued per image, coalescing
/// with anything already pending.
fn persist(
catalog: &Catalog,
root: &str,
result: &dr_sync::ScanResult,
) -> Result<(), dr_catalog::CatalogError> {
let conn = catalog.connection();
let tx = conn.unchecked_transaction()?;
// One root row per library folder, reused across scans.
tx.execute(
"INSERT INTO roots(kind, label, last_seen) VALUES ('remote', ?1, ?2)
ON CONFLICT DO NOTHING",
rusqlite::params![root, now_secs()],
)?;
let root_id: i64 = tx.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)?;
// Folder ETags first — without these persisted, the next scan prunes
// nothing and walks the whole tree again (ARCH §6.6).
for (path, validator) in &result.directories {
tx.execute(
"INSERT INTO folders(root_id, path, etag) VALUES (?1, ?2, ?3)
ON CONFLICT(root_id, path) DO UPDATE SET etag = excluded.etag",
rusqlite::params![root_id, path.as_str(), validator.as_str()],
)?;
}
for entry in &result.images {
let folder_id: Option<i64> = entry.path.parent().and_then(|p| {
tx.query_row(
"SELECT id FROM folders WHERE root_id = ?1 AND path = ?2",
rusqlite::params![root_id, p.as_str()],
|r| r.get(0),
)
.ok()
});
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",
rusqlite::params![
root_id,
folder_id,
entry.path.as_str(),
entry
.path
.name()
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase()),
entry.size as i64,
now_secs(),
],
)?;
let image_id: i64 = tx.query_row(
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root_id, entry.path.as_str()],
|r| r.get(0),
)?;
// Remote identity, keyed on oc:fileid so a server-side move is a move
// rather than a re-download (FR-NC-5).
if let RemoteId::Stable(file_id) = entry.id {
tx.execute(
"INSERT INTO remote(image_id, file_id, etag, remote_path)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(image_id) DO UPDATE SET
etag = excluded.etag, remote_path = excluded.remote_path",
rusqlite::params![
image_id,
file_id as i64,
entry.validator.as_str(),
entry.path.as_str()
],
)?;
}
}
tx.commit()?;
// Thumbnail jobs after the commit, so a failure mid-insert does not leave
// jobs pointing at rows that never landed.
for entry in &result.images {
if let Ok(image_id) = conn.query_row(
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root_id, entry.path.as_str()],
|r| r.get::<_, i64>(0),
) {
let _ = dr_catalog::jobs::enqueue(
conn,
JobKind::Thumbnail,
Some(image_id),
Priority::Background,
None,
);
}
}
Ok(())
}
/// What the grid wants a thumbnail for.
///
/// Carries the `oc:fileid` as well as the path, because that is what the
/// shared store keys on — stable across a server-side move, and the same id
/// every other client sees (FR-NC-5).
#[derive(Debug, Clone)]
pub struct ThumbnailRequest {
pub row: usize,
pub path: String,
/// `None` where the scan found no stable id; such an image is fetched but
/// not stored, since there is no durable key to store it under.
pub file_id: Option<u64>,
/// File length, needed to reject a preview range that points past the end
/// of the file (NFR-SEC-1).
pub size: u64,
/// Catalog row, so EXIF read from the header can be written back.
pub image_id: i64,
/// Which resolution this cell needs, from how large it is drawn. A zoomed
/// grid asks for the large class; a wall of small cells does not.
///
/// Named apart from `size`, which is the file's length in bytes — the two
/// are unrelated and confusing them would fetch the wrong thing.
pub thumb_size: dr_thumbs::ThumbSize,
/// Whether this image still needs its EXIF read. Where false the header is
/// still fetched — the preview needs it — but nothing is parsed or written.
pub needs_metadata: bool,
}
/// Fetch one file in full, for opening it in develop.
///
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
/// an embedded JPEG (FR-NC-3); develop needs every byte, because demosaic
/// needs every photosite. On a RAW file that is tens of megabytes, which is
/// why this is a click-triggered download and not something the grid does.
///
/// Returns the bytes on a channel rather than blocking: the download runs on
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
pub fn spawn_full_fetch(
creds: AppCredentials,
user_id: String,
path: String,
) -> Receiver<Result<Vec<u8>, String>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(Err(e.to_string()));
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
let _ = tx.send(Err(e.to_string()));
return;
}
};
let id = RemoteId::Path(RemotePath::new(&path));
let got = backend
.get(&id, None)
.await
.map_err(|e| e.to_string());
let _ = tx.send(got);
});
});
rx
}
/// Serve thumbnails for a set of rows: store first, network second.
///
/// The store is consulted before any request goes out, so a second launch —
/// or a second device that synced the shards — fills the grid with no transfer
/// at all. Only genuine misses reach the network.
pub fn spawn_thumbnails(
creds: AppCredentials,
user_id: String,
wanted: Vec<ThumbnailRequest>,
store_dir: PathBuf,
catalog_path: PathBuf,
) -> Receiver<ThumbnailMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut store = match ThumbStore::open(&store_dir) {
Ok(s) => Some(s),
Err(e) => {
// A broken store costs speed, never correctness — every
// thumbnail can still be fetched.
log::warn!("thumbnail store unavailable, fetching everything: {e}");
None
}
};
// Split the batch before delivering anything, so the plan can be
// reported first and the UI knows the shape of the work up front.
// Decoding happens here rather than in the split, because a corrupt
// blob turns a hit into a miss.
let mut hits = Vec::new();
let mut to_fetch = Vec::new();
// Images whose thumbnail is cached but whose date is still unknown.
//
// These need a header read even though no pixels are wanted. Without
// this pass an image is dated *only* on the one visit that produced
// its thumbnail — so a library browsed once before the EXIF code
// existed, or synced from another device's shards, stays permanently
// undated and never appears on the timeline.
let mut metadata_only = Vec::new();
for req in wanted {
let stored = req
.file_id
.zip(store.as_ref())
.and_then(|(id, s)| s.get(id, req.thumb_size).ok().flatten());
match stored.map(|t| dr_thumbs::decode_rgba(&t.bytes)) {
Some(Ok((width, height, rgba))) => {
if req.needs_metadata {
metadata_only.push(req.clone());
}
hits.push(ThumbnailReady {
row: req.row,
width,
height,
rgba,
});
}
// A corrupt stored blob is a miss, not a failure.
Some(Err(e)) => {
log::debug!("stored thumbnail unreadable, refetching: {e}");
to_fetch.push(req);
}
None => to_fetch.push(req),
}
}
log::info!(
"thumbnails: {} from store, {} to fetch{}",
hits.len(),
to_fetch.len(),
if metadata_only.is_empty() {
String::new()
} else {
format!(" · {} dates to read", metadata_only.len())
}
);
if tx
.send(ThumbnailMessage::Plan {
cached: hits.len(),
fetching: to_fetch.len(),
dating: metadata_only.len(),
})
.is_err()
{
return;
}
for hit in hits {
if tx.send(ThumbnailMessage::Ready(Box::new(hit))).is_err() {
return;
}
}
if to_fetch.is_empty() && metadata_only.is_empty() {
return;
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
for req in &to_fetch {
let _ = tx.send(ThumbnailMessage::Unavailable {
row: req.row,
reason: e.to_string(),
});
}
return;
}
};
rt.block_on(async {
let backend = match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => b,
Err(e) => {
for req in &to_fetch {
let _ = tx.send(ThumbnailMessage::Unavailable {
row: req.row,
reason: e.to_string(),
});
}
return;
}
};
// Batched rather than written per image: one transaction per
// batch instead of 120, and the grid does not need each date the
// instant it is read.
let mut found = Vec::new();
for req in to_fetch {
let msg = fetch_one(&backend, store.as_mut(), &req, &mut found).await;
// A closed channel means the window went away mid-fetch.
if tx.send(msg).is_err() {
break;
}
}
// Dates for images whose pixels were already cached. Header only —
// no preview range, no decode.
//
// Flushed in chunks rather than once at the end: 119 sequential
// header fetches take tens of seconds, and a single write at the
// finish loses every one of them if the window closes first. It
// also lets the timeline appear while the rest are still arriving.
const FLUSH_EVERY: usize = 16;
log::info!("reading dates for {} image(s)", metadata_only.len());
for req in metadata_only {
if tx.send(ThumbnailMessage::DateProgress).is_err() {
break;
}
read_metadata_only(&backend, &req, &mut found).await;
if found.len() >= FLUSH_EVERY {
flush_metadata(&catalog_path, &mut found, &tx);
}
}
flush_metadata(&catalog_path, &mut found, &tx);
});
});
rx
}
/// Fetch a preview in two stages: header, then the exact preview range.
///
/// This is what FR-NC-3 specifies, and the single-stage version it replaces
/// was wrong in a way that looked like corruption: fetching a fixed prefix cut
/// the embedded JPEG partway through, and decoders render a truncated JPEG as
/// the top fraction of the frame rather than reporting an error.
async fn fetch_one(
backend: &NextcloudBackend,
store: Option<&mut ThumbStore>,
req: &ThumbnailRequest,
found_metadata: &mut Vec<MetadataFound>,
) -> ThumbnailMessage {
let id = RemoteId::Path(RemotePath::new(&req.path));
let fail = |reason: String| ThumbnailMessage::Unavailable {
row: req.row,
reason,
};
// Stage one: the header, enough to parse the container's IFDs.
let header = match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
Ok(b) => b,
Err(e) => return fail(e.to_string()),
};
// The same bytes carry EXIF. Reading it here is free — the alternative is
// a second 256 KB fetch per image over the whole library.
if req.needs_metadata {
collect_metadata(&header, req, found_metadata);
}
// A plain JPEG is its own preview; anything else needs locating.
let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
match backend.get(&id, None).await {
Ok(b) => b,
Err(e) => return fail(e.to_string()),
}
} else {
let Some(loc) = dr_decode::locate_preview(&header, req.size) else {
// No locatable preview. Declining beats fetching the whole file:
// that is the 370 GB path FR-NC-3 exists to avoid.
return fail("no locatable embedded preview".into());
};
if loc.len() > MAX_PREVIEW_BYTES {
return fail(format!("preview is {} bytes, too large", loc.len()));
}
// Stage two: exactly the preview's bytes.
match backend.get(&id, Some(loc.range.clone())).await {
Ok(b) => b,
Err(e) => return fail(e.to_string()),
}
};
// Verify before decoding. A truncated JPEG decodes "successfully" into a
// partial frame, so without this the broken result reaches the cache and
// the screen looking like a corrupt file.
if !dr_decode::is_complete_jpeg(&bytes) {
return fail("preview bytes are incomplete".into());
}
// Decode on the worker, never the UI thread.
let mut preview = match dr_decode::decode_jpeg(&bytes) {
Ok(p) => p,
Err(e) => return fail(e.to_string()),
};
preview.downscale_to(req.thumb_size.edge());
// Persist for next time, and for every other client that syncs the shard.
// A store failure is logged and dropped: the pixels are already in hand,
// and refusing to display them because they could not be cached would be
// the wrong trade.
if let (Some(store), Some(file_id)) = (store, req.file_id) {
match dr_thumbs::encode_rgba(preview.width, preview.height, &preview.rgba) {
Ok(encoded) => {
let thumb = dr_thumbs::Thumbnail {
width: preview.width,
height: preview.height,
bytes: encoded,
};
if let Err(e) = store.put(file_id, req.thumb_size, &thumb) {
log::debug!("storing thumbnail {file_id}: {e}");
}
}
Err(e) => log::debug!("encoding thumbnail {file_id}: {e}"),
}
}
ThumbnailMessage::Ready(Box::new(ThumbnailReady {
row: req.row,
width: preview.width,
height: preview.height,
rgba: preview.rgba,
}))
}
/// Parse EXIF out of a header and record it.
///
/// Shared by both paths — the thumbnail fetch, which gets the header anyway,
/// and the header-only pass for images whose pixels were already cached.
fn collect_metadata(header: &[u8], req: &ThumbnailRequest, out: &mut Vec<MetadataFound>) {
let Ok(md) = dr_decode::metadata(header) else {
return;
};
out.push(MetadataFound {
image_id: req.image_id,
captured_at: md.captured_at,
captured_offset: md.captured_offset,
camera: match (&md.make, &md.model) {
// Bodies repeat the make inside the model ("Canon EOS 6D"), so
// joining unconditionally yields "Canon Canon EOS 6D".
(Some(make), Some(model)) if model.starts_with(make.as_str()) => {
Some(model.trim().to_string())
}
(Some(make), Some(model)) => Some(format!("{} {}", make.trim(), model.trim())),
(None, Some(model)) => Some(model.trim().to_string()),
_ => None,
},
lens: md.lens.map(|l| l.trim().to_string()),
iso: md.iso,
});
}
/// Write a batch of dates and tell the UI, draining `found`.
///
/// Separate from the loop so the same path serves both the periodic flush and
/// the final one, and so a write failure is reported once rather than being
/// silently swallowed by the caller.
fn flush_metadata(
catalog_path: &std::path::Path,
found: &mut Vec<MetadataFound>,
tx: &Sender<ThumbnailMessage>,
) {
if found.is_empty() {
return;
}
match Catalog::open(catalog_path) {
Ok(cat) => match write_metadata(&cat, found) {
Ok(n) => {
log::info!("recorded capture dates for {n} of {} image(s)", found.len());
// Tell the UI so the timeline can appear. Without this the
// histogram only shows up on the next window load, which on a
// fully cached library may be never.
let _ = tx.send(ThumbnailMessage::DatesRecorded(n));
}
Err(e) => log::warn!("writing metadata: {e}"),
},
Err(e) => log::warn!("opening catalog to write metadata: {e}"),
}
found.clear();
}
/// Read only the date for an image whose thumbnail is already cached.
///
/// One 256 KB header request, no preview range and no decode. This is what
/// gets a library dated when its thumbnails came from the store — including
/// shards synced from another device, which carry pixels but no metadata.
async fn read_metadata_only(
backend: &NextcloudBackend,
req: &ThumbnailRequest,
found: &mut Vec<MetadataFound>,
) {
let id = RemoteId::Path(RemotePath::new(&req.path));
match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
Ok(header) => collect_metadata(&header, req, found),
// Not worth surfacing: the cell is already showing its thumbnail, and
// a missing date leaves the image off the timeline rather than broken.
Err(e) => log::debug!("reading date for {}: {e}", req.path),
}
}
/// Write capture metadata read during the thumbnail pass.
///
/// Promotes each row from `metadata_state = 1` (stat-only) to 2 (full EXIF),
/// which is what makes it eligible for the timeline. A row whose EXIF was
/// unreadable stays at 1 rather than being marked done with empty fields, so a
/// later attempt can retry it.
///
/// Returns how many rows were promoted.
pub fn write_metadata(
catalog: &Catalog,
found: &[MetadataFound],
) -> Result<usize, dr_catalog::CatalogError> {
let conn = catalog.connection();
let tx = conn.unchecked_transaction()?;
let mut promoted = 0;
for m in found {
// Only a real timestamp counts as fully read. Camera and lens without
// a date leave the image unplaceable on a timeline, which is exactly
// the state the grid needs to distinguish.
let state = if m.captured_at.is_some() { 2 } else { 1 };
tx.execute(
"UPDATE images
SET captured_at = coalesce(?2, captured_at),
captured_offset = coalesce(?3, captured_offset),
camera = coalesce(?4, camera),
lens = coalesce(?5, lens),
iso = coalesce(?6, iso),
metadata_state = max(metadata_state, ?7)
WHERE id = ?1",
rusqlite::params![
m.image_id,
m.captured_at,
m.captured_offset,
m.camera,
m.lens,
m.iso,
state,
],
)?;
if state == 2 {
promoted += 1;
}
}
tx.commit()?;
Ok(promoted)
}
/// Progress from the whole-library sweep.
#[derive(Debug)]
pub enum SweepMessage {
/// How many images still need work, counted once at the start.
Total(usize),
/// Another chunk finished. Carries cumulative counts.
Progress { done: usize, dated: usize },
Finished { dated: usize },
}
/// Await every future concurrently, returning results in order.
///
/// A hand-rolled `join_all` rather than a `futures` dependency for one
/// function. Polling a `Vec` of futures in a loop is exactly what the crate's
/// version does; the ordering guarantee is what lets the caller pair results
/// back to their inputs.
async fn futures_join_all<F>(futures: impl IntoIterator<Item = F>) -> Vec<F::Output>
where
F: std::future::Future,
{
use std::pin::Pin;
use std::task::Poll;
// Boxed so each future has a stable address while it is polled in place.
let mut pending: Vec<Option<Pin<Box<F>>>> =
futures.into_iter().map(|f| Some(Box::pin(f))).collect();
let mut done: Vec<Option<F::Output>> = (0..pending.len()).map(|_| None).collect();
std::future::poll_fn(move |cx| {
let mut all_ready = true;
for (slot, out) in pending.iter_mut().zip(done.iter_mut()) {
let Some(fut) = slot else { continue };
match fut.as_mut().poll(cx) {
Poll::Ready(v) => {
*out = Some(v);
// Dropped as soon as it completes, so a long-running lane
// does not hold a finished one's resources.
*slot = None;
}
Poll::Pending => all_ready = false,
}
}
if all_ready {
Poll::Ready(done.iter_mut().filter_map(Option::take).collect())
} else {
Poll::Pending
}
})
.await
}
/// How many images one sweep chunk handles before committing.
///
/// Small enough that a kill loses little, large enough that the catalog is not
/// reopened per image. A multiple of [`SWEEP_LANES`] so every lane gets equal
/// work and no chunk ends with most lanes idle.
const SWEEP_CHUNK: usize = 96;
/// How many fetches the sweep keeps in flight.
///
/// Each is ~0.6 s of round-trip latency and almost no bandwidth — a 256 KB
/// header — so the sequential version spent essentially all its time waiting.
/// Twelve lanes turn ~3 hours into ~15 minutes on the reference library.
///
/// Deliberately bounded rather than unlimited: the grid's own interactive
/// fetches share this server, and a sweep that saturated the connection would
/// make browsing feel broken while it ran.
const SWEEP_LANES: usize = 12;
/// Date **every** image in the library, not just the ones on screen.
///
/// Thumbnails are deliberately *not* fetched here. A thumbnail needs the
/// mutable store, which cannot be shared across the parallel lanes below, and
/// it costs 1–3 MB against a date's 256 KB. Dating the whole library is what
/// the timeline needs; thumbnails arrive as cells are actually browsed, which
/// is the FR-NC-3 posture anyway.
///
/// The grid's own fetches cover what is on screen; this covers the rest, so the
/// timeline describes the whole library rather than the part that happened to
/// be scrolled past. It is resumable by construction — each pass queries for
/// what is still missing, so a kill mid-sweep costs only the current chunk.
///
/// Runs at the back of the queue by design: it holds no lock the grid needs,
/// and its chunked commits keep write transactions short.
pub fn spawn_sweep(
creds: AppCredentials,
user_id: String,
catalog_path: PathBuf,
) -> Receiver<SweepMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
// Silent failure here left the sweep looking like it had run
// and found nothing: no progress, no error, 17,397 images
// still unindexed.
log::warn!("sweep: cannot open catalog at {}: {e}", catalog_path.display());
let _ = tx.send(SweepMessage::Finished { dated: 0 });
return;
}
};
let outstanding = count_outstanding(&catalog).unwrap_or(0);
if outstanding == 0 {
let _ = tx.send(SweepMessage::Finished { dated: 0 });
return;
}
log::info!("sweep: {outstanding} image(s) need a date or a thumbnail");
if tx.send(SweepMessage::Total(outstanding)).is_err() {
return;
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("sweep: no runtime: {e}");
return;
}
};
rt.block_on(async {
let Ok(backend) = NextcloudBackend::new(&creds, &user_id) else {
return;
};
let (mut done, mut dated) = (0usize, 0usize);
loop {
// Re-queried each pass rather than held as one long list: the
// grid is dating images at the same time, and a stale list
// would refetch what it already covered.
let chunk = match next_outstanding(&catalog, SWEEP_CHUNK) {
Ok(c) if c.is_empty() => break,
Ok(c) => c,
Err(e) => {
log::warn!("sweep: {e}");
break;
}
};
let chunk_started = std::time::Instant::now();
log::debug!(
"sweep: chunk of {} starting at image {}",
chunk.len(),
chunk[0].image_id
);
// Twelve lanes over the chunk. Each lane owns a disjoint slice
// and its own `found` vector, so nothing is shared and no lock
// is needed; the results are concatenated after the join.
//
// The thumbnail store is the exception — it is `&mut` and
// cannot be shared — so lanes only *read* metadata and any
// missing thumbnail is left to the interactive path. Dating the
// library is what the sweep is for; thumbnails arrive as cells
// are browsed.
let lanes: Vec<Vec<&ThumbnailRequest>> = (0..SWEEP_LANES)
.map(|lane| chunk.iter().skip(lane).step_by(SWEEP_LANES).collect())
.collect();
let results = futures_join_all(lanes.into_iter().map(|lane| {
let backend = &backend;
async move {
let mut found = Vec::new();
for req in lane {
read_metadata_only(backend, req, &mut found).await;
}
found
}
}))
.await;
let mut found: Vec<MetadataFound> = results.into_iter().flatten().collect();
done += chunk.len();
dated += found.iter().filter(|m| m.captured_at.is_some()).count();
let read = found.len();
flush_sweep(&catalog, &mut found);
log::info!(
"sweep: {done} done, {dated} dated ({read} read in {:.1}s)",
chunk_started.elapsed().as_secs_f64()
);
if tx.send(SweepMessage::Progress { done, dated }).is_err() {
return;
}
}
log::info!("sweep complete: {dated} date(s) recorded over {done} image(s)");
let _ = tx.send(SweepMessage::Finished { dated });
});
});
rx
}
/// How many images still lack a date or a thumbnail.
fn count_outstanding(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT count(*) FROM images
WHERE metadata_state < 2 AND {VISIBLE_UNALIASED}"
),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
/// The next images needing work.
///
/// Ordered by id so the sweep advances deterministically and a resumed run
/// picks up where it left off rather than revisiting.
fn next_outstanding(
catalog: &Catalog,
limit: usize,
) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id, i.source_ref, r.file_id, i.file_size
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE i.metadata_state < 2 AND {VISIBLE}
ORDER BY i.id
LIMIT ?1"
))?;
let rows = stmt
.query_map([limit as i64], |r| {
Ok(ThumbnailRequest {
// The sweep indexes dates, and reads headers only — the size
// never reaches a fetch, but it must name something.
thumb_size: dr_thumbs::ThumbSize::Grid,
// Row index is meaningless here — the sweep touches no grid
// cell, so nothing consumes it.
row: 0,
image_id: r.get(0)?,
path: r.get(1)?,
file_id: r.get::<_, Option<i64>>(2)?.map(|v| v as u64),
size: r.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
needs_metadata: true,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Commit a sweep chunk.
///
/// An image whose header yielded no date is still marked done, or the sweep
/// would revisit it forever. `write_metadata` records `metadata_state = 1` for
/// those, so this promotes them explicitly.
fn flush_sweep(catalog: &Catalog, found: &mut Vec<MetadataFound>) {
if found.is_empty() {
return;
}
if let Err(e) = write_metadata(catalog, found) {
log::warn!("sweep: writing metadata: {e}");
found.clear();
return;
}
// Mark the dateless as examined. Without this they stay at state 1 and the
// sweep loops over them on every pass, never terminating.
let ids: Vec<i64> = found
.iter()
.filter(|m| m.captured_at.is_none())
.map(|m| m.image_id)
.collect();
for id in ids {
let _ = catalog.connection().execute(
"UPDATE images SET metadata_state = 2 WHERE id = ?1",
[id],
);
}
found.clear();
}
/// Where an account's thumbnail shards live.
///
/// Beside the catalog rather than in the cache directory: these sync to the
/// server and are shared with other clients, so discarding them on a cache
/// sweep would cost a re-download for everyone.
pub fn thumbs_dir(server: &str, user_id: &str) -> PathBuf {
catalog_path(server, user_id)
.parent()
.map(|p| p.join("thumbs"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-thumbs"))
}
/// One grid cell's data, read from the catalog.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryCell {
pub image_id: i64,
pub name: String,
pub remote_path: String,
/// `oc:fileid`, the key the shared thumbnail store uses. `None` for an
/// image the scan found without a stable id.
pub file_id: Option<u64>,
/// File length, for bounds-checking a located preview range.
pub size: u64,
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
pub metadata_state: u8,
/// UTC seconds, once EXIF has been read.
pub captured_at: Option<i64>,
}
/// Read a window of cells out of the catalog.
///
/// Windowed rather than wholesale: a 17k-image library must not become 17k
/// rows in a Slint model (FR-CAT-4).
/// The unscoped form, kept as the name the tests and any future caller reach
/// for. The UI goes through [`read_cells_scoped`], because a collection may be
/// selected.
#[cfg(test)]
pub fn read_cells(
catalog: &Catalog,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
read_cells_scoped(catalog, None, &RatingFilter::default(), offset, limit)
}
/// Read a window of cells, optionally narrowed to one collection.
///
/// A collection *set* shows its descendants' images too — a parent whose
/// children hold everything would otherwise read as empty, which makes nesting
/// look broken. The id list comes from
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
///
/// Ordering matches the unscoped grid (capture time, then name) rather than
/// manual position: position is only meaningful inside one collection and this
/// query also serves sets, where two children's positions are unrelated.
pub fn read_cells_scoped(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let Some(scope) = scope else {
return read_cells_all(catalog, filter, offset, limit);
};
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
// Placeholders are generated from the *count* of ids, never from user text.
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let rated = filter.sql();
let sql = format!(
"SELECT i.id, i.source_ref, r.file_id, i.file_size,
i.metadata_state, i.captured_at
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE {VISIBLE}{rated}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
LIMIT ? OFFSET ?"
);
let mut params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
params.push(rusqlite::types::Value::Integer(limit as i64));
params.push(rusqlite::types::Value::Integer(offset as i64));
let mut stmt = catalog.connection().prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
fn read_cells_all(
catalog: &Catalog,
filter: &RatingFilter,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql();
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id, i.source_ref, r.file_id, i.file_size,
i.metadata_state, i.captured_at
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE {VISIBLE}{rated}
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
LIMIT ?1 OFFSET ?2"
))?;
let rows = stmt
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// TRACES: FR-CAT-15
/// Read a window of *trashed* cells, newest deletion first.
///
/// Ordered by when it was trashed rather than by capture time, which is what
/// every other view sorts by. The question in the trash is "what did I just
/// delete?", not "when was this taken" — a mistaken delete is corrected within
/// seconds, and burying it among photographs from the same afternoon would make
/// the one row the user is looking for the hardest one to find.
///
/// The rating filter is deliberately not applied. It narrows a *culling* pass,
/// and a trash that hid rows because of a filter set elsewhere would look like
/// it had lost them.
pub fn read_trashed_cells(
catalog: &Catalog,
offset: usize,
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id, i.source_ref, r.file_id, i.file_size,
i.metadata_state, i.captured_at
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE {TRASHED}
ORDER BY i.trashed_at DESC, i.source_ref ASC
LIMIT ?1 OFFSET ?2"
))?;
let rows = stmt
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// TRACES: FR-CAT-15
/// How many images the trash view would list.
///
/// Counts exactly what [`read_trashed_cells`] lists — same predicate, no filter
/// — so the scrollbar and the header cannot disagree with the cells.
pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {TRASHED}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
let path: String = r.get(1)?;
Ok(LibraryCell {
image_id: r.get(0)?,
name: path.rsplit(['/', ':']).next().unwrap_or(&path).to_string(),
remote_path: path,
file_id: r.get::<_, Option<i64>>(2)?.map(|v| v as u64),
size: r.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
metadata_state: r.get::<_, i64>(4)? as u8,
captured_at: r.get(5)?,
})
}
/// Total images in the catalog, or in one collection and its descendants.
///
/// Counts exactly what [`read_cells_scoped`] would list, filter included. The
/// two must agree: the header says "412 images" and the grid's scrollbar is
/// sized from the same number, so a count that ignored the filter would leave
/// the user scrolling through empty rows.
pub fn total_images_scoped(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let Some(scope) = scope else {
return total_images_filtered(catalog, filter);
};
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let rated = filter.sql();
// DISTINCT: an image in both a parent and a child is one photograph, and a
// count that disagrees with the number of cells drawn is worse than either
// number alone.
//
// 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 sql = format!(
"SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let n: i64 = catalog.connection().query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)?;
Ok(n as usize)
}
/// Total images in the catalog, honouring the rating filter.
fn total_images_filtered(
catalog: &Catalog,
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql();
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
/// Total images in the catalog, unfiltered.
///
/// What the scan reports and what the sidebar's "all images" row shows — the
/// size of the library itself, not of the current view.
pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog
.connection()
.query_row(
&format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_sync::RemoteEntry;
#[test]
fn join_all_preserves_order_regardless_of_completion() {
// The ordering guarantee is what lets a caller pair results back to
// their inputs; without it a lane's dates could be attributed to the
// wrong images.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let out = rt.block_on(async {
futures_join_all(vec![
Box::pin(async { 1 }) as std::pin::Pin<Box<dyn std::future::Future<Output = i32>>>,
Box::pin(async {
tokio::task::yield_now().await;
tokio::task::yield_now().await;
2
}),
Box::pin(async {
tokio::task::yield_now().await;
3
}),
])
.await
});
assert_eq!(out, vec![1, 2, 3]);
}
#[test]
fn join_all_of_nothing_completes() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let out: Vec<i32> =
rt.block_on(async { futures_join_all(Vec::<std::future::Ready<i32>>::new()).await });
assert!(out.is_empty());
}
#[test]
fn sweep_lanes_divide_a_chunk_without_loss() {
// Every image in a chunk must land in exactly one lane: a striding
// split that dropped or duplicated one would silently under- or
// double-index the library.
let chunk: Vec<usize> = (0..SWEEP_CHUNK).collect();
let lanes: Vec<Vec<usize>> = (0..SWEEP_LANES)
.map(|l| chunk.iter().skip(l).step_by(SWEEP_LANES).copied().collect())
.collect();
let mut seen: Vec<usize> = lanes.iter().flatten().copied().collect();
seen.sort_unstable();
assert_eq!(seen, chunk);
// Evenly divided, so no lane sits idle while another finishes.
assert!(lanes.iter().all(|l| l.len() == SWEEP_CHUNK / SWEEP_LANES));
}
#[test]
fn a_short_chunk_still_covers_every_image() {
// The last chunk of a library is rarely a full multiple of the lanes.
let chunk: Vec<usize> = (0..5).collect();
let lanes: Vec<Vec<usize>> = (0..SWEEP_LANES)
.map(|l| chunk.iter().skip(l).step_by(SWEEP_LANES).copied().collect())
.collect();
let mut seen: Vec<usize> = lanes.iter().flatten().copied().collect();
seen.sort_unstable();
assert_eq!(seen, chunk);
}
#[test]
fn a_scrub_ordinal_matches_the_grid_position() {
// The scrub's count and the grid's window must use *identical*
// predicates and ordering, or the view lands somewhere else. Counting
// only dated images against a grid that also shows undated ones put a
// click near the end of the axis near the top of the library.
let catalog = Catalog::in_memory().unwrap();
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
// A mix: dated, undated, and one shadowed by a RAW sibling.
for (id, name, captured, shadow) in [
(1i64, "a.CR2", Some(100i64), None),
(2, "b.CR2", Some(200), None),
(3, "b.JPG", Some(200), Some(2i64)),
(4, "c.CR2", Some(300), None),
(5, "d.CR2", None, None),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, shadow],
)
.unwrap();
}
// The grid's own window, in its own order.
let cells = read_cells(&catalog, 0, 100).unwrap();
let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["a.CR2", "b.CR2", "c.CR2", "d.CR2"],
"shadowed hidden, undated last"
);
// Scrubbing to each image's instant must give its index in that list.
for (when, expected) in [(100i64, 0usize), (200, 1), (300, 2)] {
let ordinal: i64 = c
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
[when],
|r| r.get(0),
)
.unwrap();
assert_eq!(
ordinal as usize, expected,
"scrubbing to {when} must land on grid row {expected}"
);
}
}
#[test]
fn catalog_paths_separate_accounts() {
// Two accounts on one machine must not share an index, or one
// library's images appear in the other.
let a = catalog_path("https://cloud.example", "duncan");
let b = catalog_path("https://cloud.example", "someone");
let c = catalog_path("https://other.example", "duncan");
assert_ne!(a, b);
assert_ne!(a, c);
}
#[test]
fn catalog_path_is_filesystem_safe() {
let p = catalog_path("https://cloud.example.com:8443/nc", "duncan");
let s = p.to_string_lossy();
assert!(!s.contains("://"));
assert!(!s.contains(':') || cfg!(windows));
}
#[test]
fn persist_inserts_images_and_folder_etags() {
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
assert_eq!(total_images(&catalog).unwrap(), 1);
// Folder ETags must persist or the next scan prunes nothing.
let etag: String = catalog
.connection()
.query_row("SELECT etag FROM folders WHERE path = 'PhotosRaw'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(etag, "e1");
}
#[test]
fn images_land_as_stat_only_not_full_metadata() {
// The scan read no EXIF. Claiming otherwise would make a date filter
// silently wrong on a freshly scanned library.
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let state: i64 = catalog
.connection()
.query_row("SELECT metadata_state FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(state, 1);
}
#[test]
fn rescanning_updates_rather_than_duplicating() {
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 1001, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
persist(&catalog, "PhotosRaw", &result).unwrap();
assert_eq!(total_images(&catalog).unwrap(), 1, "no duplicate rows");
let roots: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM roots", [], |r| r.get(0))
.unwrap();
assert_eq!(roots, 1, "no duplicate roots");
}
#[test]
fn stable_file_ids_are_recorded() {
// FR-NC-5: a server-side move must be a move, not a re-download.
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 4242, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let file_id: i64 = catalog
.connection()
.query_row("SELECT file_id FROM remote", [], |r| r.get(0))
.unwrap();
assert_eq!(file_id, 4242);
}
#[test]
fn a_thumbnail_job_is_queued_per_image() {
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![
entry("PhotosRaw/a.CR2", 1, 30_000_000),
entry("PhotosRaw/b.CR2", 2, 30_000_000),
],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let jobs: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM jobs WHERE kind = 2", [], |r| r.get(0))
.unwrap();
assert_eq!(jobs, 2);
}
#[test]
fn a_second_scan_does_not_multiply_jobs() {
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 1, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
persist(&catalog, "PhotosRaw", &result).unwrap();
let jobs: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM jobs WHERE kind = 2", [], |r| r.get(0))
.unwrap();
assert_eq!(jobs, 1, "coalesced, not queued twice");
}
#[test]
fn folder_etags_round_trip_for_the_next_scan() {
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![],
directories: vec![
(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1")),
(RemotePath::new("PhotosRaw/2026"), dr_sync::Validator::new("e2")),
],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let known = load_folder_etags(&catalog, "PhotosRaw");
assert_eq!(known.len(), 2);
assert_eq!(
known.get(&RemotePath::new("PhotosRaw/2026")).map(|v| v.as_str()),
Some("e2")
);
}
#[test]
fn cells_are_windowed_not_wholesale() {
let catalog = Catalog::in_memory().unwrap();
let images: Vec<RemoteEntry> = (0..50)
.map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000))
.collect();
let result = dr_sync::ScanResult {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let page = read_cells(&catalog, 10, 5).unwrap();
assert_eq!(page.len(), 5);
assert_eq!(page[0].name, "img010.CR2");
}
/// A catalog with `n` images, ready to file into collections.
fn with_images(n: usize) -> Catalog {
let catalog = Catalog::in_memory().unwrap();
let images: Vec<RemoteEntry> = (0..n)
.map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000))
.collect();
let result = dr_sync::ScanResult {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
catalog
}
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog
.connection()
.prepare("SELECT id FROM images ORDER BY source_ref")
.unwrap();
stmt.query_map([], |r| Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn a_scoped_grid_shows_only_that_collections_images() {
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let c = coll::create(catalog.connection(), "Selects", None, CollectionKind::Manual)
.unwrap();
coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap();
let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 3);
// Unscoped is still the whole library.
assert_eq!(total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), 10);
}
#[test]
fn a_collection_set_shows_its_childrens_images() {
// A parent whose children hold everything must not read as empty —
// that is what makes nesting look broken.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let trips =
coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap();
let iceland = coll::create(
catalog.connection(),
"Iceland",
Some(trips),
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap();
// The parent itself has no direct members at all.
let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 4, "the set shows what its children hold");
assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 4);
}
#[test]
fn an_image_in_both_a_parent_and_a_child_is_shown_once() {
// The count and the number of cells drawn must agree, or neither is
// believable.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let trips =
coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap();
let iceland = coll::create(
catalog.connection(),
"Iceland",
Some(trips),
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap();
coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap();
let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3, "images 0..3, each once");
assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 3);
}
#[test]
fn a_scoped_window_still_pages() {
// FR-CAT-4 applies inside a collection too: a 5,000-image collection
// must not become 5,000 rows.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(30);
let ids = image_ids(&catalog);
let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap();
coll::add_images(catalog.connection(), c, &ids).unwrap();
let page = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 10, 5).unwrap();
assert_eq!(page.len(), 5);
assert_eq!(page[0].name, "img010.CR2");
}
#[test]
fn an_empty_collection_reads_as_empty_rather_than_as_the_whole_library() {
// The failure that would make scoping useless: an empty IN-list
// matching everything.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap();
assert!(read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap().is_empty());
assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 0);
}
#[test]
fn cells_carry_the_file_id_the_thumbnail_store_keys_on() {
// Without this the store can never be hit: every launch would refetch
// every thumbnail over the network.
let catalog = Catalog::in_memory().unwrap();
let result = dr_sync::ScanResult {
images: vec![entry("PhotosRaw/a.CR2", 7777, 30_000_000)],
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
progress: Default::default(),
};
persist(&catalog, "PhotosRaw", &result).unwrap();
let cells = read_cells(&catalog, 0, 10).unwrap();
assert_eq!(cells[0].file_id, Some(7777));
}
#[test]
fn a_stored_thumbnail_survives_a_restart() {
// The end-to-end property the store exists for: encode, persist,
// reopen, decode. A second launch must not re-fetch.
let dir = std::env::temp_dir().join(format!("dr-ui-thumbs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 32 * 32)
.flatten()
.collect();
{
let mut store = ThumbStore::open(&dir).unwrap();
let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap();
store
.put(4242, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail {
width: 32,
height: 32,
bytes,
},
)
.unwrap();
}
let store = ThumbStore::open(&dir).unwrap();
let stored = store.get(4242, dr_thumbs::ThumbSize::Grid).unwrap().expect("persisted");
let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
assert_eq!((w, h), (32, 32));
// Lossy, so compare approximately — a blue-ish pixel must stay blue.
assert!(out[2] > out[0], "channel order survived the round trip");
}
#[test]
fn thumbs_live_beside_the_catalog_not_in_the_cache() {
// They sync to the server and are shared with other clients, so a
// cache sweep must not discard them.
let cat = catalog_path("https://cloud.example", "duncan");
let thumbs = thumbs_dir("https://cloud.example", "duncan");
assert_eq!(thumbs.parent(), cat.parent());
}
// --- the trash view (FR-CAT-15) ---------------------------------------
/// A catalog with `n` scanned images, none trashed.
fn scanned(n: u64) -> Catalog {
let catalog = Catalog::in_memory().unwrap();
let images = (1..=n)
.map(|i| entry(&format!("PhotosRaw/IMG_{i:04}.CR2"), 1000 + i, 30_000_000))
.collect();
persist(
&catalog,
"PhotosRaw",
&dr_sync::ScanResult {
images,
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1"))],
progress: Default::default(),
},
)
.unwrap();
catalog
}
/// Mark one image trashed, at a given instant.
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
let n = catalog
.connection()
.execute(
"UPDATE images SET trashed_at = ?1, trashed_from = source_ref
WHERE source_ref = ?2",
rusqlite::params![when, path],
)
.unwrap();
assert_eq!(n, 1, "fixture should have trashed exactly {path}");
}
#[test]
fn the_trash_lists_what_the_library_hides() {
// The whole point of the view: these rows exist and no other query in
// the application will show them.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
assert_eq!(trashed.len(), 1);
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0002.CR2");
// And it has left the library in the same move.
let live = read_cells_all(&catalog, &RatingFilter::default(), 0, 50).unwrap();
assert_eq!(live.len(), 2);
assert!(!live.iter().any(|c| c.remote_path.contains("IMG_0002")));
}
#[test]
fn an_untrashed_library_has_an_empty_trash() {
let catalog = scanned(3);
assert!(read_trashed_cells(&catalog, 0, 50).unwrap().is_empty());
assert_eq!(total_trashed(&catalog).unwrap(), 0);
}
#[test]
fn the_trash_count_agrees_with_the_cells_it_lists() {
// The header and the scrollbar are sized from the count while the grid
// draws the cells. Two predicates that drift leave the user scrolling
// through rows that are not there.
let catalog = scanned(5);
for (i, when) in [(1, 100), (3, 200), (5, 300)] {
trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), when);
}
assert_eq!(total_trashed(&catalog).unwrap(), 3);
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
}
#[test]
fn the_most_recently_trashed_image_is_listed_first() {
// A mistaken delete is corrected within seconds, so the row the user
// wants is the one they just made — not the oldest photograph.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 300);
trash_at(&catalog, "PhotosRaw/IMG_0003.CR2", 200);
let order: Vec<String> = read_trashed_cells(&catalog, 0, 50)
.unwrap()
.into_iter()
.map(|c| c.remote_path)
.collect();
assert_eq!(
order,
vec![
"PhotosRaw/IMG_0002.CR2".to_string(),
"PhotosRaw/IMG_0003.CR2".to_string(),
"PhotosRaw/IMG_0001.CR2".to_string(),
]
);
}
#[test]
fn a_shadowed_jpeg_is_not_listed_beside_the_raw_it_belongs_to() {
// The inversion applies to `trashed_at` only. Trashing a RAW takes its
// sibling JPEG with it, and listing both would offer to restore the
// same frame twice.
let catalog = scanned(2);
let c = catalog.connection();
let raw: i64 = c
.query_row(
"SELECT id FROM images WHERE source_ref = 'PhotosRaw/IMG_0001.CR2'",
[],
|r| r.get(0),
)
.unwrap();
c.execute(
"UPDATE images SET shadowed_by = ?1 WHERE source_ref = 'PhotosRaw/IMG_0002.CR2'",
[raw],
)
.unwrap();
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
assert_eq!(trashed.len(), 1, "one photograph, not two");
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0001.CR2");
assert_eq!(total_trashed(&catalog).unwrap(), 1, "and the count agrees");
}
#[test]
fn the_trash_window_pages_like_the_grid_does() {
// The trash uses the same windowed read as the library, so a large one
// must not try to draw itself in a single query.
let catalog = scanned(6);
for i in 1..=6 {
trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), 100 + i as i64);
}
let first = read_trashed_cells(&catalog, 0, 2).unwrap();
let second = read_trashed_cells(&catalog, 2, 2).unwrap();
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert!(
first.iter().all(|a| !second.iter().any(|b| b.image_id == a.image_id)),
"pages must not overlap"
);
}
#[test]
fn a_rating_filter_does_not_hide_anything_in_the_trash() {
// The filter narrows a culling pass. A trash that dropped rows because
// of a filter set elsewhere would look like it had lost them.
let catalog = scanned(3);
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
// Nothing here is rated, so a four-star library filter would empty any
// view that honoured it.
let strict = RatingFilter {
min_rating: 4,
..Default::default()
};
assert!(read_cells_all(&catalog, &strict, 0, 50).unwrap().is_empty());
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 1);
}
fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry {
RemoteEntry {
id: RemoteId::Stable(file_id),
path: RemotePath::new(path),
kind: dr_sync::EntryKind::File,
validator: dr_sync::Validator::new("v"),
size,
modified: None,
has_preview: false,
}
}
}