`dr_catalog::scan` has known since it was written what a changed directory means — when to prune, when to list, and the one question that decides whether a deletion sweep is safe. It was fully tested and nothing called it, because walking a real directory "belongs to the platform layer" and the platform layer was eleven lines re-exporting `secrets`. So every photograph in DarkRoom arrived over WebDAV, and a user without a Nextcloud account saw nothing at all. This is the missing half: a `Storage` trait, a filesystem implementation of it, and the driver that pours one into the other. The trait is shaped by the platform it does *not* yet support. Android's SAF gives no filesystem path, which is why `SourceRef` exists; less obviously, it gives no way to *compose* one either — a document id is opaque, and the only way to learn a child's id is the children query that returned it. So a listing hands back the reference to each entry rather than a name for the caller to join onto a parent, and there is deliberately no "path + name" helper anywhere above `LocalStorage`. That single restriction is what makes SAF a second implementation rather than a second set of call sites. A reference is otherwise an opaque `(RootId, key)` pair the catalog stores verbatim and rebuilds later, which a persisted tree grant supports exactly as a relative path does. A `Path` now appears in one place: `LocalStorage::grant`, where the folder the user picked is handed in. Everything above it addresses a `RootId`. `dr_catalog::walk` is the seam. It probes a directory, asks `scan` what that means, lists only when told to, and reconciles what it found against the rows it holds. Two things it does are worth saying out loud, because both are ways to lose a library: Absence only counts where absence was observed. A listed folder proves its missing images are gone; a pruned one proves nothing about its contents, and a scan that was cancelled or that failed part-way proves nothing about folders it never reached. So the file sweep runs per listed folder, the folder sweep runs once at the end and only after a complete scan, and a root that cannot be reached at all marks its images offline and deletes nothing — FR-CAT-9's line between proven-absent and merely-unreachable, which is the difference between unplugging a drive and losing everything on it. A trashed image is absent from its folder on purpose. It is exempt from both sweeps, and detached from a folder about to be deleted rather than cascaded away with it, or a soft delete would come undone the first time the folder it came from was rescanned. Two things the tests taught, both changes to what was there before: Modification times are now milliseconds, not seconds. Change detection asks whether a timestamp moved, so the unit's granularity is the width of the window in which a change is invisible — and a second is long enough to copy a card and start a scan. The test that caught it looked like a test bug; it was not. SAF reports milliseconds natively, so this is also the unit that needs no conversion on the platform with the coarser clock. And an in-place rewrite of an existing file is invisible to directory-level pruning, because writing to a file moves neither its directory's mtime nor its entry count. That is a real limit, now documented and held by a test rather than left to be discovered. It bites less than it reads: an export, a restore, `mv`, and every editor that saves safely write beside the file and rename over it, which does move both. Narrowing the format filter no longer deletes what it stops matching, which fell out of the same principle: unticking JPEG says stop looking for new ones, not discard the hundred already rated. The files are sitting right there. `DirState` and `DirEntry` move to `dr-types`. They are the sentence the platform says to the catalog and both crates need the same one; `scan` re-exports them so nothing that used them has changed. Not done: the UI. The launch screen's "Open library" flow is account-shaped from the first field to the thumbnail worker, and giving it a local branch is its own piece of work rather than a button. `cargo run -p dr-catalog --example scan_local -- ~/Pictures` scans a real folder and reports what it cost; run it twice to see the second run list nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
515 lines
17 KiB
Rust
515 lines
17 KiB
Rust
//! TRACES: FR-CAT-4 | FR-CAT-6
|
|
//! Compiling a [`Selector`] into indexed SQL, and windowing the result.
|
|
//!
|
|
//! The UI never assembles SQL — it hands over a [`Query`] and receives a
|
|
//! window. Two properties matter:
|
|
//!
|
|
//! 1. **Nothing user-supplied is interpolated into SQL text.** Every value
|
|
//! binds as a parameter; `LIKE` patterns have their wildcards escaped.
|
|
//! 2. **Predicates hit indices.** Filtering 50k images must stay interactive
|
|
//! (FR-CAT-6), which means no expression over a column that would defeat
|
|
//! its index.
|
|
|
|
use dr_types::{Availability, ColourLabel, DateSelector, FlagState, Selector};
|
|
use rusqlite::types::Value;
|
|
|
|
/// What to show, and in what order.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Query {
|
|
pub filter: Selector,
|
|
pub sort: Sort,
|
|
pub descending: bool,
|
|
}
|
|
|
|
impl Default for Query {
|
|
fn default() -> Self {
|
|
Query {
|
|
filter: Selector::All,
|
|
sort: Sort::CapturedAt,
|
|
descending: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Sort {
|
|
CapturedAt,
|
|
Added,
|
|
FileName,
|
|
Rating,
|
|
/// Manual order within a collection. Falls back to capture time where the
|
|
/// query is not scoped to one collection, since position is meaningless
|
|
/// outside it.
|
|
CollectionPosition,
|
|
}
|
|
|
|
impl Sort {
|
|
/// The ORDER BY fragment. Fixed strings — never user input.
|
|
///
|
|
/// Capture time sorts NULLs last regardless of direction: an image whose
|
|
/// EXIF has not been read yet (metadata_state 1) should not lead the grid
|
|
/// simply because its timestamp is unknown.
|
|
fn sql(self, descending: bool) -> &'static str {
|
|
match (self, descending) {
|
|
(Sort::CapturedAt, false) => {
|
|
"ORDER BY images.captured_at IS NULL, images.captured_at ASC, images.id ASC"
|
|
}
|
|
(Sort::CapturedAt, true) => {
|
|
"ORDER BY images.captured_at IS NULL, images.captured_at DESC, images.id DESC"
|
|
}
|
|
(Sort::Added, false) => "ORDER BY images.added_at ASC, images.id ASC",
|
|
(Sort::Added, true) => "ORDER BY images.added_at DESC, images.id DESC",
|
|
(Sort::FileName, false) => "ORDER BY images.source_ref ASC, images.id ASC",
|
|
(Sort::FileName, true) => "ORDER BY images.source_ref DESC, images.id DESC",
|
|
(Sort::Rating, false) => "ORDER BY v.rating ASC, images.id ASC",
|
|
(Sort::Rating, true) => "ORDER BY v.rating DESC, images.id DESC",
|
|
(Sort::CollectionPosition, false) => {
|
|
"ORDER BY cm.position IS NULL, cm.position ASC, images.captured_at ASC"
|
|
}
|
|
(Sort::CollectionPosition, true) => {
|
|
"ORDER BY cm.position IS NULL, cm.position DESC, images.captured_at DESC"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether this sort needs the default-version join.
|
|
fn needs_version(self) -> bool {
|
|
matches!(self, Sort::Rating)
|
|
}
|
|
|
|
/// Whether this sort needs a collection-membership join.
|
|
fn needs_membership(self) -> bool {
|
|
matches!(self, Sort::CollectionPosition)
|
|
}
|
|
}
|
|
|
|
/// A compiled WHERE clause plus its bound parameters.
|
|
///
|
|
/// Kept separate from the statement so `count` and `window` can share one
|
|
/// compilation.
|
|
#[derive(Debug, Default)]
|
|
pub struct Compiled {
|
|
pub where_sql: String,
|
|
pub params: Vec<Value>,
|
|
/// True if the filter depends on capture time, and therefore on EXIF that
|
|
/// a freshly scanned library may not have read yet. The UI surfaces this
|
|
/// rather than silently under-reporting.
|
|
pub needs_capture_time: bool,
|
|
}
|
|
|
|
/// Compile a selector to SQL against the `images` table.
|
|
///
|
|
/// `now` is passed rather than read from the clock so a rolling window is
|
|
/// reproducible in tests and consistent across one query.
|
|
pub fn compile(filter: &Selector, now: i64) -> Compiled {
|
|
let mut params = Vec::new();
|
|
let sql = if filter.is_unfiltered() {
|
|
"1".to_string()
|
|
} else {
|
|
emit(filter, now, &mut params)
|
|
};
|
|
Compiled {
|
|
where_sql: sql,
|
|
params,
|
|
needs_capture_time: filter.needs_capture_time(),
|
|
}
|
|
}
|
|
|
|
fn emit(s: &Selector, now: i64, p: &mut Vec<Value>) -> String {
|
|
match s {
|
|
Selector::All => "1".into(),
|
|
|
|
Selector::Collection(id) => {
|
|
p.push(Value::Integer(id.0 as i64));
|
|
format!(
|
|
"EXISTS (SELECT 1 FROM collection_members m
|
|
WHERE m.image_id = images.id AND m.collection_id = ?{})",
|
|
p.len()
|
|
)
|
|
}
|
|
|
|
Selector::Folder {
|
|
root,
|
|
path,
|
|
recursive,
|
|
} => {
|
|
p.push(Value::Integer(root.0 as i64));
|
|
let root_ix = p.len();
|
|
if *recursive {
|
|
// Prefix match on the folder path. `like_prefix` escapes the
|
|
// pattern metacharacters, so a folder literally named "50%"
|
|
// matches itself and not everything.
|
|
p.push(Value::Text(like_prefix(path)));
|
|
format!(
|
|
"images.folder_id IN (
|
|
SELECT id FROM folders
|
|
WHERE root_id = ?{root_ix}
|
|
AND (path = ?{p} OR path LIKE ?{p} || '/%' ESCAPE '\\'))",
|
|
p = p.len()
|
|
)
|
|
} else {
|
|
p.push(Value::Text(path.clone()));
|
|
format!(
|
|
"images.folder_id IN (
|
|
SELECT id FROM folders WHERE root_id = ?{root_ix} AND path = ?{})",
|
|
p.len()
|
|
)
|
|
}
|
|
}
|
|
|
|
Selector::DateRange(d) => emit_date(d, now, p),
|
|
|
|
Selector::Rating { min } => {
|
|
p.push(Value::Integer(*min as i64));
|
|
format!("{} >= ?{}", default_version_scalar("rating"), p.len())
|
|
}
|
|
|
|
Selector::Label(l) => {
|
|
p.push(Value::Integer(label_code(*l)));
|
|
format!("{} = ?{}", default_version_scalar("label"), p.len())
|
|
}
|
|
|
|
Selector::Flag(f) => {
|
|
p.push(Value::Integer(flag_code(*f)));
|
|
format!("{} = ?{}", default_version_scalar("flag"), p.len())
|
|
}
|
|
|
|
Selector::Keyword(k) => {
|
|
p.push(Value::Text(k.clone()));
|
|
format!(
|
|
"EXISTS (SELECT 1 FROM keywords kw
|
|
JOIN versions kv ON kv.id = kw.version_id
|
|
WHERE kv.image_id = images.id AND kw.keyword = ?{})",
|
|
p.len()
|
|
)
|
|
}
|
|
|
|
Selector::Camera(c) => {
|
|
p.push(Value::Text(c.clone()));
|
|
format!("images.camera = ?{}", p.len())
|
|
}
|
|
|
|
Selector::Lens(l) => {
|
|
p.push(Value::Text(l.clone()));
|
|
format!("images.lens = ?{}", p.len())
|
|
}
|
|
|
|
Selector::IsoRange { min, max } => {
|
|
p.push(Value::Integer(*min as i64));
|
|
let lo = p.len();
|
|
p.push(Value::Integer(*max as i64));
|
|
format!("images.iso BETWEEN ?{lo} AND ?{}", p.len())
|
|
}
|
|
|
|
Selector::Availability(a) => {
|
|
p.push(Value::Integer(availability_code(*a)));
|
|
format!("images.availability = ?{}", p.len())
|
|
}
|
|
|
|
Selector::Text(t) => {
|
|
// Substring over filename and keywords. A LIKE scan is adequate at
|
|
// 50k; if free text over title and description becomes a real
|
|
// workflow, FTS5 is the answer and it is additive.
|
|
p.push(Value::Text(format!("%{}%", escape_like(t))));
|
|
let ix = p.len();
|
|
format!(
|
|
"(images.source_ref LIKE ?{ix} ESCAPE '\\'
|
|
OR EXISTS (SELECT 1 FROM keywords kw
|
|
JOIN versions kv ON kv.id = kw.version_id
|
|
WHERE kv.image_id = images.id
|
|
AND kw.keyword LIKE ?{ix} ESCAPE '\\'))"
|
|
)
|
|
}
|
|
|
|
// An empty conjunction is vacuously true; an empty disjunction matches
|
|
// nothing. Both arise from a UI that lets every term be cleared, and
|
|
// conflating them would show the whole library when the user meant the
|
|
// opposite.
|
|
Selector::All_(v) if v.is_empty() => "1".into(),
|
|
Selector::Any(v) if v.is_empty() => "0".into(),
|
|
|
|
Selector::All_(v) => join(v, " AND ", now, p),
|
|
Selector::Any(v) => join(v, " OR ", now, p),
|
|
Selector::Not(inner) => format!("NOT ({})", emit(inner, now, p)),
|
|
}
|
|
}
|
|
|
|
fn join(items: &[Selector], op: &str, now: i64, p: &mut Vec<Value>) -> String {
|
|
let parts: Vec<String> = items.iter().map(|s| emit(s, now, p)).collect();
|
|
format!("({})", parts.join(op))
|
|
}
|
|
|
|
fn emit_date(d: &DateSelector, now: i64, p: &mut Vec<Value>) -> String {
|
|
match d {
|
|
DateSelector::Between { from, to } => {
|
|
p.push(Value::Integer(*from));
|
|
let lo = p.len();
|
|
p.push(Value::Integer(*to));
|
|
// Half-open, so adjacent ranges neither overlap nor gap.
|
|
format!(
|
|
"(images.captured_at >= ?{lo} AND images.captured_at < ?{})",
|
|
p.len()
|
|
)
|
|
}
|
|
DateSelector::Rolling { days } => {
|
|
let from = now - (*days as i64) * 86_400;
|
|
p.push(Value::Integer(from));
|
|
format!("images.captured_at >= ?{}", p.len())
|
|
}
|
|
DateSelector::CollectionSpan(id) => {
|
|
p.push(Value::Integer(id.0 as i64));
|
|
let ix = p.len();
|
|
format!(
|
|
"images.captured_at BETWEEN
|
|
(SELECT min(i2.captured_at) FROM images i2
|
|
JOIN collection_members m2 ON m2.image_id = i2.id
|
|
WHERE m2.collection_id = ?{ix})
|
|
AND (SELECT max(i2.captured_at) FROM images i2
|
|
JOIN collection_members m2 ON m2.image_id = i2.id
|
|
WHERE m2.collection_id = ?{ix})"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rating, label, and flag live on the *default* version, not the image.
|
|
///
|
|
/// A correlated subquery rather than a join, so these compose inside `OR` and
|
|
/// `NOT` without the join multiplying rows.
|
|
fn default_version_scalar(col: &str) -> String {
|
|
format!(
|
|
"(SELECT dv.{col} FROM versions dv
|
|
WHERE dv.image_id = images.id AND dv.is_default = 1 LIMIT 1)"
|
|
)
|
|
}
|
|
|
|
/// Escape LIKE metacharacters so a literal `%` or `_` in user text matches
|
|
/// itself. Paired with `ESCAPE '\'` in every LIKE that uses it.
|
|
fn escape_like(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
if matches!(c, '%' | '_' | '\\') {
|
|
out.push('\\');
|
|
}
|
|
out.push(c);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn like_prefix(path: &str) -> String {
|
|
escape_like(path.trim_end_matches('/'))
|
|
}
|
|
|
|
fn label_code(l: ColourLabel) -> i64 {
|
|
match l {
|
|
ColourLabel::Red => 1,
|
|
ColourLabel::Yellow => 2,
|
|
ColourLabel::Green => 3,
|
|
ColourLabel::Blue => 4,
|
|
ColourLabel::Purple => 5,
|
|
}
|
|
}
|
|
|
|
fn flag_code(f: FlagState) -> i64 {
|
|
match f {
|
|
FlagState::Unflagged => 0,
|
|
FlagState::Pick => 1,
|
|
FlagState::Reject => 2,
|
|
}
|
|
}
|
|
|
|
/// The stored form of an availability. Shared with [`crate::walk`], which
|
|
/// writes the column this reads — two spellings of the same mapping would
|
|
/// filter for a state nothing ever writes.
|
|
pub(crate) fn availability_code(a: Availability) -> i64 {
|
|
match a {
|
|
Availability::MetadataOnly => 0,
|
|
Availability::Preview => 1,
|
|
Availability::Original => 2,
|
|
Availability::Offline => 3,
|
|
}
|
|
}
|
|
|
|
/// Build the full SELECT for a window of results.
|
|
///
|
|
/// Joins are added only where the sort needs them, so an unsorted-by-rating
|
|
/// grid query touches one table.
|
|
pub fn window_sql(q: &Query, compiled: &Compiled) -> String {
|
|
let mut joins = String::new();
|
|
if q.sort.needs_version() {
|
|
joins.push_str(" LEFT JOIN versions v ON v.image_id = images.id AND v.is_default = 1");
|
|
}
|
|
if q.sort.needs_membership() {
|
|
// Only meaningful when the filter scopes to one collection; elsewhere
|
|
// position is NULL and the sort falls through to capture time.
|
|
joins.push_str(" LEFT JOIN collection_members cm ON cm.image_id = images.id");
|
|
}
|
|
format!(
|
|
"SELECT images.id, images.source_ref, images.availability, images.captured_at, \
|
|
images.captured_offset, images.metadata_state \
|
|
FROM images{joins} WHERE {} {} LIMIT ? OFFSET ?",
|
|
compiled.where_sql,
|
|
q.sort.sql(q.descending)
|
|
)
|
|
}
|
|
|
|
/// Build the COUNT for the same filter.
|
|
pub fn count_sql(compiled: &Compiled) -> String {
|
|
format!("SELECT count(*) FROM images WHERE {}", compiled.where_sql)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_types::{CollectionId, RootId};
|
|
|
|
#[test]
|
|
fn unfiltered_compiles_to_a_constant() {
|
|
let c = compile(&Selector::All, 0);
|
|
assert_eq!(c.where_sql, "1");
|
|
assert!(c.params.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_conjunction_and_disjunction_differ() {
|
|
// The distinction that decides whether clearing a filter shows
|
|
// everything or nothing.
|
|
assert_eq!(compile(&Selector::All_(vec![]), 0).where_sql, "1");
|
|
assert_eq!(compile(&Selector::Any(vec![]), 0).where_sql, "0");
|
|
}
|
|
|
|
#[test]
|
|
fn values_bind_rather_than_interpolate() {
|
|
// The injection guard: a hostile keyword must appear in params, never
|
|
// in SQL text.
|
|
let evil = "'; DROP TABLE images; --";
|
|
let c = compile(&Selector::Keyword(evil.into()), 0);
|
|
assert!(!c.where_sql.contains("DROP"));
|
|
assert_eq!(c.params, vec![Value::Text(evil.into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn like_metacharacters_are_escaped() {
|
|
// A search for "50%" must not match everything containing "50".
|
|
let c = compile(&Selector::Text("50%".into()), 0);
|
|
assert_eq!(c.params, vec![Value::Text("%50\\%%".into())]);
|
|
assert!(c.where_sql.contains("ESCAPE"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_backslash_in_search_text_is_itself_escaped() {
|
|
let c = compile(&Selector::Text("a\\b".into()), 0);
|
|
assert_eq!(c.params, vec![Value::Text("%a\\\\b%".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn rolling_window_resolves_against_supplied_now() {
|
|
// Passed in rather than read from the clock, so the window is stable
|
|
// across one query and reproducible in a test.
|
|
let now = 1_000_000i64;
|
|
let c = compile(
|
|
&Selector::DateRange(DateSelector::Rolling { days: 90 }),
|
|
now,
|
|
);
|
|
assert_eq!(c.params, vec![Value::Integer(now - 90 * 86_400)]);
|
|
}
|
|
|
|
#[test]
|
|
fn between_is_half_open() {
|
|
let c = compile(
|
|
&Selector::DateRange(DateSelector::Between { from: 10, to: 20 }),
|
|
0,
|
|
);
|
|
// Half-open so adjacent day buckets neither overlap nor leave a gap.
|
|
assert!(c.where_sql.contains(">= ?1"));
|
|
assert!(c.where_sql.contains("< ?2"));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_composition_numbers_parameters_in_order() {
|
|
let s = Selector::All_(vec![
|
|
Selector::Rating { min: 4 },
|
|
Selector::Any(vec![
|
|
Selector::Camera("X-T5".into()),
|
|
Selector::Not(Box::new(Selector::Lens("XF 35".into()))),
|
|
]),
|
|
]);
|
|
let c = compile(&s, 0);
|
|
assert_eq!(
|
|
c.params,
|
|
vec![
|
|
Value::Integer(4),
|
|
Value::Text("X-T5".into()),
|
|
Value::Text("XF 35".into()),
|
|
]
|
|
);
|
|
assert!(c.where_sql.contains("?1"));
|
|
assert!(c.where_sql.contains("?2"));
|
|
assert!(c.where_sql.contains("?3"));
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_folder_matches_the_folder_itself_and_below() {
|
|
let c = compile(
|
|
&Selector::Folder {
|
|
root: RootId(1),
|
|
path: "2026/08".into(),
|
|
recursive: true,
|
|
},
|
|
0,
|
|
);
|
|
// Both branches: the folder's own images and those in subfolders.
|
|
assert!(c.where_sql.contains("path = ?2"));
|
|
assert!(c.where_sql.contains("|| '/%'"));
|
|
}
|
|
|
|
#[test]
|
|
fn collection_span_binds_its_id_once_and_reuses_it() {
|
|
let c = compile(
|
|
&Selector::DateRange(DateSelector::CollectionSpan(CollectionId(7))),
|
|
0,
|
|
);
|
|
assert_eq!(c.params, vec![Value::Integer(7)]);
|
|
}
|
|
|
|
#[test]
|
|
fn capture_time_dependency_is_reported() {
|
|
let c = compile(&Selector::DateRange(DateSelector::Rolling { days: 7 }), 0);
|
|
assert!(c.needs_capture_time);
|
|
let c = compile(&Selector::Rating { min: 5 }, 0);
|
|
assert!(!c.needs_capture_time);
|
|
}
|
|
|
|
#[test]
|
|
fn capture_sort_puts_unknown_timestamps_last_in_both_directions() {
|
|
// An image whose EXIF has not been read yet must not lead the grid
|
|
// just because its timestamp is NULL.
|
|
assert!(Sort::CapturedAt.sql(true).contains("IS NULL"));
|
|
assert!(Sort::CapturedAt.sql(false).contains("IS NULL"));
|
|
}
|
|
|
|
#[test]
|
|
fn window_sql_joins_only_when_the_sort_needs_it() {
|
|
let c = compile(&Selector::All, 0);
|
|
let plain = window_sql(
|
|
&Query {
|
|
filter: Selector::All,
|
|
sort: Sort::CapturedAt,
|
|
descending: true,
|
|
},
|
|
&c,
|
|
);
|
|
assert!(!plain.contains("JOIN"));
|
|
|
|
let rated = window_sql(
|
|
&Query {
|
|
filter: Selector::All,
|
|
sort: Sort::Rating,
|
|
descending: true,
|
|
},
|
|
&c,
|
|
);
|
|
assert!(rated.contains("JOIN versions"));
|
|
}
|
|
}
|