Merge branch 'local-libraries'

This commit is contained in:
2026-08-17 09:26:03 +02:00
11 changed files with 2442 additions and 33 deletions
+10
View File
@@ -7,9 +7,19 @@ license.workspace = true
[dependencies]
dr-types.workspace = true
# The `Storage` trait, and nothing else from it. A scan has to read a real
# directory, and this is how `core/` reaches the platform without a
# `#[cfg(target_os)]` of its own (ARCH §4.1: calls go downward).
dr-plat.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
log.workspace = true
# `collections.selector_json` — the stored form of a smart collection's
# selector. The column predates this dependency; nothing else here is JSON.
serde_json.workspace = true
# For the `scan_local` example only, which is a diagnostic tool: what it is
# diagnosing is often a folder the scan warned about and skipped, and those
# warnings go to `log`.
[dev-dependencies]
env_logger.workspace = true
+111
View File
@@ -0,0 +1,111 @@
//! Scan a real folder on this machine into a catalog, and say what it cost.
//!
//! cargo run -p dr-catalog --example scan_local -- ~/Pictures [catalog.sqlite]
//!
//! **Run it twice.** The first run is a full walk; the second is the one worth
//! watching, because on an unchanged library it should list no directories at
//! all and take a fraction of the time. That difference is NFR-P1, and a
//! synthetic test cannot show it at the scale a real library does — 121,785
//! files in a synced folder is a different question from twenty in a temporary
//! directory.
//!
//! Writes only to the catalog file, which defaults to a fixed path in the
//! system temporary directory so a second run has something to compare
//! against. Nothing in the scanned folder is touched.
use std::path::PathBuf;
use dr_catalog::walk::{ensure_root, scan_root, RootKind};
use dr_catalog::Catalog;
use dr_plat::LocalStorage;
use dr_types::FormatFilter;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(dir) = args.next().map(PathBuf::from) else {
eprintln!("usage: scan_local <directory> [catalog.sqlite]");
std::process::exit(2);
};
let catalog_path = args
.next()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-scan-local.sqlite"));
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
eprintln!("cannot open {}: {e}", catalog_path.display());
std::process::exit(1);
}
};
println!("catalog: {}", catalog_path.display());
// The label is how the grant is spelled, and the only place a path is
// written down. Everything after this line addresses files by `RootId`.
let label = dir.display().to_string();
let root = match ensure_root(catalog.connection(), RootKind::Local, &label) {
Ok(r) => r,
Err(e) => {
eprintln!("cannot record the root: {e}");
std::process::exit(1);
}
};
let storage = LocalStorage::with_root(root, &dir);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let started = std::time::Instant::now();
let report = match scan_root(
catalog.connection(),
&storage,
root,
&FormatFilter::all(),
now,
|| false,
|p| {
// One line per hundred directories: enough to show it is alive on a
// large library, not enough to be the thing that slows it down.
let visited = p.directories_listed + p.directories_pruned;
if visited % 100 == 0 {
println!(
" … {visited} directories ({} pruned), {} images",
p.directories_pruned, p.images_found
);
}
},
) {
Ok(r) => r,
Err(e) => {
eprintln!("scan failed: {e}");
std::process::exit(1);
}
};
let elapsed = started.elapsed();
let total: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap_or(-1);
println!("\noutcome: {:?}", report.outcome);
println!(
"directories: {} listed, {} pruned",
report.progress.directories_listed, report.progress.directories_pruned
);
println!(
"images: {} new, {} changed, {} unchanged, {} removed",
report.inserted, report.updated, report.unchanged, report.images_removed
);
println!("folders: {} removed", report.folders_removed);
println!("catalogued: {total} in total");
println!("took: {:.2?}", elapsed);
if report.progress.directories_listed == 0 && report.progress.directories_pruned > 0 {
println!("\nnothing had changed: every folder was proven unchanged by one probe");
}
}
+9
View File
@@ -25,6 +25,15 @@ pub enum CatalogError {
#[error("root {0} is unreachable; scan aborted without pruning")]
RootUnreachable(u64),
/// A scan was asked for a root the catalog has no row for.
///
/// A caller's mistake rather than a user's: the row is created when the
/// grant is obtained, because the label — the path, the tree URI — is known
/// only there. Inventing one here would file the library under a name
/// nothing else would look it up by.
#[error("no such root: {0}")]
NoSuchRoot(u64),
/// A smart collection whose selector references itself, directly or via
/// another collection.
#[error("collection {0} would form a cycle")]
+3
View File
@@ -11,6 +11,7 @@
//!
//! - [`schema`] — tables and forward-only migrations
//! - [`scan`] — incremental discovery that prunes unchanged directories
//! - [`walk`] — those decisions driven against real storage, local or SAF
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`jobs`] — the durable background work queue
@@ -41,6 +42,7 @@ pub mod scan;
pub mod schema;
pub mod sync;
pub mod trash;
pub mod walk;
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
pub use collections::{Collection, CollectionKind, TreeRow};
@@ -51,6 +53,7 @@ pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
+4 -1
View File
@@ -318,7 +318,10 @@ fn flag_code(f: FlagState) -> i64 {
}
}
fn availability_code(a: Availability) -> i64 {
/// 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,
+10 -27
View File
@@ -14,35 +14,13 @@
//!
//! This module holds the decision logic and the deletion-sweep rules; walking
//! an actual directory belongs to the platform layer, which supplies
//! [`DirState`] and [`DirEntry`].
//! [`DirState`] and [`DirEntry`]. [`crate::walk`] is what puts the two
//! together.
pub use dr_types::{DirEntry, DirState};
use dr_types::FormatFilter;
/// What a directory looked like when last scanned, and what it looks like now.
///
/// Both fields are cheap to obtain: one `stat` locally, one
/// `DocumentsContract` metadata query on SAF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirState {
pub mtime: i64,
/// Direct children, files and directories alike.
///
/// mtime alone misses a delete-and-create inside one timestamp tick, and
/// coarse-granularity providers widen that window. The count does not
/// close the hole — a paired add and remove moves neither — but a bare add
/// or remove moves the count, and those are far commoner.
pub entry_count: u32,
}
/// One entry from a directory listing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: i64,
}
/// What the scanner should do with a directory, before listing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirAction {
@@ -100,12 +78,17 @@ pub fn classify_entry(
}
/// Outcome of a scan, which decides whether pruning may run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
///
/// `Cancelled` is the default because a scan that has not run has proven
/// nothing absent, and every default in this area must fail towards keeping
/// photographs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScanOutcome {
/// Every reachable folder was visited.
Complete,
/// The user cancelled. Partial state is valid — jobs are resumable — but
/// unvisited folders must not be read as deleted.
#[default]
Cancelled,
/// The root itself could not be opened: drive unplugged, SAF grant
/// revoked, share unmounted.
File diff suppressed because it is too large Load Diff
+63 -5
View File
@@ -63,16 +63,25 @@ pub enum SourceRef {
impl SourceRef {
/// A stable, display-friendly name — the last path component.
pub fn display_name(&self) -> &str {
let full = match self {
SourceRef::Local { relative, .. } => relative.as_str(),
SourceRef::Document { document_id, .. } => document_id.as_str(),
SourceRef::Remote { path, .. } => path.as_str(),
};
let full = self.key();
// SAF document ids use ':' as a separator; paths use '/'. Split on
// whichever appears last so both yield a sensible name.
full.rsplit(['/', ':']).next().unwrap_or(full)
}
/// The stored form — what the catalog holds in `images.source_ref`.
///
/// Which of the three variants a key belongs to is not recorded beside it,
/// because the root already says: `roots.kind` is `'local' | 'saf' |
/// 'remote'`, and every image names its root.
pub fn key(&self) -> &str {
match self {
SourceRef::Local { relative, .. } => relative.as_str(),
SourceRef::Document { document_id, .. } => document_id.as_str(),
SourceRef::Remote { path, .. } => path.as_str(),
}
}
/// Lowercase file extension, if any.
///
/// Looks through a Nextcloud VFS placeholder suffix, so a dehydrated
@@ -251,6 +260,55 @@ impl FormatFilter {
}
}
/// TRACES: FR-CAT-1 | NFR-PORT-1
/// What a directory looked like when last scanned, and what it looks like now.
///
/// Both fields are cheap to obtain: one `stat` plus a name-only directory read
/// locally, one `DocumentsContract` query on SAF. Neither costs a `stat` per
/// child, which is the whole point — see `dr_catalog::scan`.
///
/// Lives here rather than in either crate that uses it because it is the
/// sentence the platform layer says to the catalog: `dr-plat` produces it by
/// probing, `dr-catalog` stores it and compares. A copy on each side would be
/// two types that must agree by convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirState {
/// Milliseconds since the epoch. **Not seconds**, and the difference is not
/// pedantry: change detection asks whether a timestamp *moved*, so the
/// granularity of the unit is the width of the window in which a change is
/// invisible. A second is long enough to copy a card and start a scan, and
/// anything happening inside one tick of the stored value looks exactly
/// like nothing happening — the directory is pruned and the photographs
/// never appear.
///
/// Both platforms can supply it: a filesystem records nanoseconds, and
/// SAF's `COLUMN_LAST_MODIFIED` is already in milliseconds.
pub mtime: i64,
/// Direct children, files and directories alike.
///
/// mtime alone misses a delete-and-create inside one timestamp tick, and
/// coarse-granularity providers widen that window. The count does not
/// close the hole — a paired add and remove moves neither — but a bare add
/// or remove moves the count, and those are far commoner.
pub entry_count: u32,
}
/// TRACES: FR-CAT-1 | NFR-PORT-1
/// One entry from a directory listing, as the scanner classifies it.
///
/// Deliberately carries no handle to the entry itself: this is what the
/// *decision* logic reads, and it must be constructible in a test without a
/// filesystem. The platform pairs it with the reference needed to reach the
/// entry (`dr_plat::Entry`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
/// Milliseconds since the epoch, as in [`DirState::mtime`].
pub mtime: i64,
}
/// How much of an image is available locally (FR-NC-6c).
///
/// Surfaced in the UI so a user always knows what they have — the failure