match_filename_date only gated the unix-timestamp branch to years 1995..=now+1; the five wall patterns returned any NaiveDateTime. Counters and IDs shaped like YYYYMMDD_HHMMSS (an app's VID_<counter>_<HHMMSS>, Facebook's <8 digits>_<id>_o.jpg) became dates of record in years from 267 to 9985: 67 rows on prod, all via screenshot_compact. Both branches now share plausible_filename_year; a wall miss falls through to the next pattern and then the rest of the waterfall. The filename audit no longer proposes these dates. A data migration clears the five date columns on filename-sourced rows dated outside [1995, 2028) UTC, so backfill_missing_date_taken re-resolves them from EXIF / exiftool / fs time on the next ticks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
679 lines
25 KiB
Rust
679 lines
25 KiB
Rust
//! Pre-cutover audit utility for the filename date class (ticket #117).
|
|
//!
|
|
//! A one-shot, operator-run report listing every file that matches a
|
|
//! filename-class pattern: the file's current stored date + source and
|
|
//! the date / source / offset-source the re-based resolver would assign
|
|
//! it. It reads the live database and walks the libraries (honoring the
|
|
//! library exclusions and the online gate — offline libraries are
|
|
//! reported as skipped, never processed). It changes nothing: strictly
|
|
//! read-only, no writes to any table.
|
|
//!
|
|
//! Purpose: the filename class is a documented inversion of EXIF-wins
|
|
//! and depends on the operator's memory of "what these filenames mean".
|
|
//! This report is the human spot-check that gates the production
|
|
//! backfill drain — the operator reviews it, fixes any mis-tagged
|
|
//! pattern or misclassified file, and only then starts the drain.
|
|
//!
|
|
//! The proposed values mirror the drain's inputs: the resolver runs
|
|
//! with empty priors, exactly as the per-tick drain calls
|
|
//! `resolve_dates_batch` today. In-class files never touch exiftool, so
|
|
//! the audit spawns no subprocesses at all.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use diesel::prelude::*;
|
|
use diesel::sqlite::SqliteConnection;
|
|
|
|
use crate::database::schema::image_exif;
|
|
use crate::date_resolver::ResolvedDate;
|
|
use crate::libraries::{Library, LibraryHealth, probe_online};
|
|
use crate::memories::{PathExcluder, match_filename_date};
|
|
|
|
/// The stored date contract for one `image_exif` row: the legacy pair
|
|
/// plus the new-contract audit value (NULL until the drain converts it).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct StoredDate {
|
|
pub date_taken: Option<i64>,
|
|
pub date_taken_source: Option<String>,
|
|
pub date_taken_tz_source: Option<String>,
|
|
}
|
|
|
|
/// One report row: a filename-class file, what the database has for it
|
|
/// now, and what the re-based resolver would assign.
|
|
#[derive(Clone, Debug)]
|
|
pub struct AuditRow {
|
|
pub library_id: i32,
|
|
pub library_name: String,
|
|
/// Root-relative path, slash-separated.
|
|
pub rel_path: String,
|
|
/// Which filename-class pattern matched (`screenshot`, `dash`, …).
|
|
pub pattern: &'static str,
|
|
/// The row's stored contract; `None` when the file is not indexed
|
|
/// yet (no `image_exif` row).
|
|
pub current: Option<StoredDate>,
|
|
/// What the re-based resolver would assign (legacy value, source,
|
|
/// true instant, offset, offset-source).
|
|
pub proposed: ResolvedDate,
|
|
/// True when a stored row exists and its legacy `date_taken`
|
|
/// disagrees with the proposed legacy value — the rows the operator
|
|
/// needs to spot-check hardest.
|
|
pub changed: bool,
|
|
}
|
|
|
|
/// A library the audit did not process, with the reason. Disabled
|
|
/// (operator kill switch) and offline (probe failed) are reported,
|
|
/// never partially processed.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct SkippedLibrary {
|
|
pub library_id: i32,
|
|
pub name: String,
|
|
pub reason: String,
|
|
}
|
|
|
|
/// One `image_exif` row's date contract, as loaded for the audit.
|
|
#[derive(diesel::Queryable)]
|
|
struct StoredDateRow {
|
|
rel_path: String,
|
|
date_taken: Option<i64>,
|
|
date_taken_source: Option<String>,
|
|
date_taken_tz_source: Option<String>,
|
|
}
|
|
|
|
/// Load `rel_path -> stored date fields` for one library. Read-only
|
|
/// (SELECTs only; `&mut` is diesel's connection contract, not a write).
|
|
pub fn stored_dates_for_library(
|
|
conn: &mut SqliteConnection,
|
|
library_id: i32,
|
|
) -> Result<HashMap<String, StoredDate>, diesel::result::Error> {
|
|
let rows: Vec<StoredDateRow> = image_exif::table
|
|
.filter(image_exif::library_id.eq(library_id))
|
|
.select((
|
|
image_exif::rel_path,
|
|
image_exif::date_taken,
|
|
image_exif::date_taken_source,
|
|
image_exif::date_taken_tz_source,
|
|
))
|
|
.load(conn)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(
|
|
|StoredDateRow {
|
|
rel_path,
|
|
date_taken,
|
|
date_taken_source,
|
|
date_taken_tz_source,
|
|
}| {
|
|
(
|
|
rel_path,
|
|
StoredDate {
|
|
date_taken,
|
|
date_taken_source,
|
|
date_taken_tz_source,
|
|
},
|
|
)
|
|
},
|
|
)
|
|
.collect())
|
|
}
|
|
|
|
/// Audit one online library: walk its root (honoring the effective
|
|
/// excludes), match the filename class, and compute the proposed
|
|
/// resolution for every in-class file in a single resolver pass. Rows
|
|
/// are stable-sorted by `rel_path`. `stored` comes from
|
|
/// [`stored_dates_for_library`].
|
|
pub fn audit_library(
|
|
library: &Library,
|
|
global_excludes: &[String],
|
|
stored: &HashMap<String, StoredDate>,
|
|
) -> Vec<AuditRow> {
|
|
let root = PathBuf::from(&library.root_path);
|
|
let excluder = PathExcluder::new(&root, &library.effective_excluded_dirs(global_excludes));
|
|
|
|
let mut matched: Vec<(String, PathBuf, &'static str)> = Vec::new();
|
|
// Depth 0 (the root itself) is always allowed so a pathological
|
|
// exclude matching the root can't silently yield an empty report —
|
|
// same guard as file_scan::walk_library_files.
|
|
for entry in walkdir::WalkDir::new(&root)
|
|
.into_iter()
|
|
.filter_entry(|e| e.depth() == 0 || !excluder.is_excluded(e.path()))
|
|
.flatten()
|
|
{
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let Some(rel) = entry
|
|
.path()
|
|
.strip_prefix(&root)
|
|
.ok()
|
|
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(fm) = match_filename_date(entry.file_name().to_str().unwrap_or("")) else {
|
|
continue;
|
|
};
|
|
matched.push((rel, entry.path().to_path_buf(), fm.pattern));
|
|
}
|
|
matched.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
// One resolver pass for the whole library. In-class files resolve
|
|
// from the name alone, so this spawns no exiftool subprocess.
|
|
let paths: Vec<PathBuf> = matched.iter().map(|(_, p, _)| p.clone()).collect();
|
|
let resolved = crate::date_resolver::resolve_dates_batch(&paths, &HashMap::new());
|
|
|
|
matched
|
|
.into_iter()
|
|
.map(|(rel_path, abs, pattern)| {
|
|
// An in-class name always resolves (the class never fails to
|
|
// date a file it previously dated); `None` would be a bug in
|
|
// the resolver, not a missing file.
|
|
let proposed = resolved
|
|
.get(&abs)
|
|
.copied()
|
|
.expect("in-class filename must resolve without exiftool");
|
|
let current = stored.get(&rel_path).cloned();
|
|
let changed = current
|
|
.as_ref()
|
|
.map(|c| c.date_taken != Some(proposed.timestamp))
|
|
.unwrap_or(false);
|
|
AuditRow {
|
|
library_id: library.id,
|
|
library_name: library.name.clone(),
|
|
rel_path,
|
|
pattern,
|
|
current,
|
|
proposed,
|
|
changed,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Run the audit over every configured library: disabled libraries are
|
|
/// reported as skipped; online libraries are walked; probe-failed
|
|
/// libraries are reported as skipped (never partially processed). The
|
|
/// result is stable-sorted by library id, then `rel_path`, so two runs
|
|
/// diff cleanly.
|
|
pub fn run_audit(
|
|
conn: &mut SqliteConnection,
|
|
libraries: Vec<Library>,
|
|
global_excludes: &[String],
|
|
) -> (Vec<AuditRow>, Vec<SkippedLibrary>) {
|
|
let mut rows: Vec<AuditRow> = Vec::new();
|
|
let mut skipped: Vec<SkippedLibrary> = Vec::new();
|
|
|
|
for lib in libraries {
|
|
if !lib.enabled {
|
|
skipped.push(SkippedLibrary {
|
|
library_id: lib.id,
|
|
name: lib.name.clone(),
|
|
reason: "disabled (enabled=false)".to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
// Same online gate as the watcher: probe with prior knowledge
|
|
// (had_data) from the image_exif row count.
|
|
let had_data: i64 = image_exif::table
|
|
.filter(image_exif::library_id.eq(lib.id))
|
|
.count()
|
|
.get_result(conn)
|
|
.unwrap_or(0);
|
|
match probe_online(&lib, had_data > 0) {
|
|
LibraryHealth::Online => {
|
|
let stored = stored_dates_for_library(conn, lib.id).unwrap_or_default();
|
|
rows.extend(audit_library(&lib, global_excludes, &stored));
|
|
}
|
|
LibraryHealth::Stale { reason, .. } => {
|
|
skipped.push(SkippedLibrary {
|
|
library_id: lib.id,
|
|
name: lib.name.clone(),
|
|
reason: format!("offline ({reason})"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
rows.sort_by(|a, b| {
|
|
(a.library_id, a.rel_path.as_str()).cmp(&(b.library_id, b.rel_path.as_str()))
|
|
});
|
|
(rows, skipped)
|
|
}
|
|
|
|
fn format_epoch_utc(ts: Option<i64>) -> String {
|
|
match ts.and_then(|t| chrono::DateTime::<chrono::Utc>::from_timestamp(t, 0)) {
|
|
Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
None => "-".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Render the report. One entry per in-class file — three lines each:
|
|
/// header, `current:`, `proposed:` — stable-sorted, with a `*` on rows
|
|
/// whose stored legacy date would change; then the skipped libraries
|
|
/// and a summary. Deterministic: two runs diff cleanly.
|
|
pub fn format_report(rows: &[AuditRow], skipped: &[SkippedLibrary]) -> String {
|
|
let mut out = String::new();
|
|
for row in rows {
|
|
let current = match &row.current {
|
|
Some(c) => format!(
|
|
"{} [{}]",
|
|
format_epoch_utc(c.date_taken),
|
|
c.date_taken_source.as_deref().unwrap_or("-")
|
|
),
|
|
None => "unindexed".to_string(),
|
|
};
|
|
let proposed_tz = row
|
|
.proposed
|
|
.tz_source
|
|
.map(|t| t.as_str().to_string())
|
|
.unwrap_or_else(|| "-".into());
|
|
// The numeric offset (minutes ahead of UTC) is the value the
|
|
// operator actually spot-checks: a wall pattern resolved with
|
|
// empty evidence carries the server zone's offset for that date.
|
|
let proposed_offset = row
|
|
.proposed
|
|
.camera_offset_minutes
|
|
.map(|m| format!("{m:+}m"))
|
|
.unwrap_or_else(|| "-".into());
|
|
out.push_str(&format!(
|
|
"{}\t{:<19}\t{:<20}\tlib={} {}\n current: {}\n proposed: {} [{}] tz_source={} offset={}\n",
|
|
if row.changed { "*" } else { " " },
|
|
row.pattern,
|
|
row.rel_path,
|
|
row.library_id,
|
|
row.library_name,
|
|
current,
|
|
format_epoch_utc(Some(row.proposed.timestamp)),
|
|
row.proposed.source.as_str(),
|
|
proposed_tz,
|
|
proposed_offset,
|
|
));
|
|
}
|
|
if !skipped.is_empty() {
|
|
out.push_str("\nskipped libraries:\n");
|
|
for s in skipped {
|
|
out.push_str(&format!(
|
|
" lib={} {} — {}\n",
|
|
s.library_id, s.name, s.reason
|
|
));
|
|
}
|
|
}
|
|
out.push_str(&format!(
|
|
"\n{} file(s) in the filename class, {} would change, {} library(ies) skipped\n",
|
|
rows.len(),
|
|
rows.iter().filter(|r| r.changed).count(),
|
|
skipped.len()
|
|
));
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use diesel::Connection;
|
|
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
|
|
|
fn migrated_conn() -> SqliteConnection {
|
|
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
|
|
conn.run_pending_migrations(DB_MIGRATIONS)
|
|
.expect("migrations");
|
|
conn
|
|
}
|
|
|
|
fn seed_row(
|
|
conn: &mut SqliteConnection,
|
|
library_id: i32,
|
|
rel_path: &str,
|
|
date_taken: Option<i64>,
|
|
source: Option<&str>,
|
|
) {
|
|
diesel::insert_into(image_exif::table)
|
|
.values((
|
|
image_exif::library_id.eq(library_id),
|
|
image_exif::rel_path.eq(rel_path),
|
|
image_exif::date_taken.eq(date_taken),
|
|
image_exif::date_taken_source.eq(source),
|
|
image_exif::created_time.eq(1_700_000_000),
|
|
image_exif::last_modified.eq(1_700_000_000),
|
|
))
|
|
.execute(conn)
|
|
.expect("seed row");
|
|
}
|
|
|
|
fn library(id: i32, name: &str, root: &std::path::Path, enabled: bool) -> Library {
|
|
Library {
|
|
id,
|
|
name: name.to_string(),
|
|
root_path: root.to_string_lossy().to_string(),
|
|
enabled,
|
|
excluded_dirs: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Fixture library seeded with mixed conventions:
|
|
/// - `Screenshot_2014-06-01-20-44-50.png` — in-class wall, stored row
|
|
/// says exif/2021 (the inversion the operator must spot-check).
|
|
/// - `att_1422489664680106.jpeg` — in-class unix timestamp, stored
|
|
/// row already agrees.
|
|
/// - `2015-01-09_02-15-15.jpg` — in-class wall, not indexed yet.
|
|
/// - `plain_photo.jpg` — out of class, must not appear.
|
|
/// - `@eaDir/2016-02-02_03-03-03.jpg` — excluded dir, must not appear.
|
|
fn fixture_library(root: &std::path::Path) {
|
|
for path in [
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
"att_1422489664680106.jpeg",
|
|
"2015-01-09_02-15-15.jpg",
|
|
"plain_photo.jpg",
|
|
"@eaDir/2016-02-02_03-03-03.jpg",
|
|
] {
|
|
let full = root.join(path);
|
|
if let Some(parent) = full.parent() {
|
|
fs::create_dir_all(parent).unwrap();
|
|
}
|
|
fs::write(&full, b"fixture").unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn audit_shows_inversion_timestamp_and_exclusions() {
|
|
let tmp = tempdir().unwrap();
|
|
fixture_library(tmp.path());
|
|
let mut conn = migrated_conn();
|
|
|
|
// 2021-01-15 12:00:00 UTC — a stale EXIF date the filename
|
|
// outranks. att_1422489664680106 -> first 10 digits =
|
|
// 1422489664 = 2015-01-29 07:21:04 UTC.
|
|
let stale_exif: i64 = 1_610_742_400;
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
Some(stale_exif),
|
|
Some("exif"),
|
|
);
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"att_1422489664680106.jpeg",
|
|
Some(1422489664),
|
|
Some("filename"),
|
|
);
|
|
|
|
let lib = library(1, "photos", tmp.path(), true);
|
|
let stored = stored_dates_for_library(&mut conn, 1).unwrap();
|
|
// Global excludes as the EXCLUDED_DIRS env var would provide in
|
|
// production (junk dirs like @eaDir never enter the class walk).
|
|
let rows = audit_library(&lib, &["@eaDir".to_string()], &stored);
|
|
|
|
// Exactly the three in-class files; out-of-class and excluded
|
|
// paths are absent.
|
|
let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect();
|
|
assert_eq!(
|
|
rels,
|
|
vec![
|
|
"2015-01-09_02-15-15.jpg",
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
"att_1422489664680106.jpeg"
|
|
]
|
|
);
|
|
|
|
// The inversion: stored exif/2021, proposed filename/2014 wall
|
|
// (wall-as-UTC legacy), tz_source assumed_server (empty priors,
|
|
// mirroring the drain).
|
|
let inv = rows
|
|
.iter()
|
|
.find(|r| r.rel_path == "Screenshot_2014-06-01-20-44-50.png")
|
|
.unwrap();
|
|
assert_eq!(inv.pattern, "screenshot");
|
|
assert_eq!(inv.current.as_ref().unwrap().date_taken, Some(stale_exif));
|
|
assert_eq!(
|
|
inv.current.as_ref().unwrap().date_taken_source.as_deref(),
|
|
Some("exif")
|
|
);
|
|
assert_eq!(inv.proposed.source.as_str(), "filename");
|
|
assert_eq!(
|
|
inv.proposed.timestamp,
|
|
1401655490 // 2014-06-01 20:44:50 as UTC (wall-as-UTC legacy)
|
|
);
|
|
assert_eq!(
|
|
inv.proposed.tz_source.map(|t| t.as_str()),
|
|
Some("assumed_server")
|
|
);
|
|
// Server zone America/New_York is EDT (UTC-4) in June.
|
|
assert_eq!(inv.proposed.camera_offset_minutes, Some(-240));
|
|
assert!(inv.changed, "stored exif date must flag as changed");
|
|
|
|
// The unix timestamp: utc_direct, no offset, and the stored row
|
|
// already agrees -> not flagged.
|
|
let ts = rows
|
|
.iter()
|
|
.find(|r| r.rel_path == "att_1422489664680106.jpeg")
|
|
.unwrap();
|
|
assert_eq!(ts.pattern, "unix_timestamp");
|
|
assert_eq!(ts.proposed.source.as_str(), "filename");
|
|
assert_eq!(ts.proposed.timestamp, 1422489664);
|
|
assert_eq!(ts.proposed.date_utc, Some(1422489664));
|
|
assert_eq!(ts.proposed.camera_offset_minutes, None);
|
|
assert_eq!(
|
|
ts.proposed.tz_source.map(|t| t.as_str()),
|
|
Some("utc_direct")
|
|
);
|
|
assert!(!ts.changed);
|
|
|
|
// Unindexed in-class file: current is None, not flagged.
|
|
let unidx = rows
|
|
.iter()
|
|
.find(|r| r.rel_path == "2015-01-09_02-15-15.jpg")
|
|
.unwrap();
|
|
assert_eq!(unidx.pattern, "dash");
|
|
assert!(unidx.current.is_none());
|
|
assert!(!unidx.changed);
|
|
}
|
|
|
|
#[test]
|
|
fn audit_leaves_out_counter_and_id_names_with_implausible_years() {
|
|
// #160: these matched screenshot_compact as years 2229 / 8580 /
|
|
// 1053 and the audit proposed them as dates of record.
|
|
let tmp = tempdir().unwrap();
|
|
for path in [
|
|
"VID_22290524_080551_818.mp4",
|
|
"VID_185800302_235056_842.mp4",
|
|
"10530519_10152700326093436_5479865808523667420_o.jpg",
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
] {
|
|
fs::write(tmp.path().join(path), b"fixture").unwrap();
|
|
}
|
|
let mut conn = migrated_conn();
|
|
let lib = library(1, "photos", tmp.path(), true);
|
|
let stored = stored_dates_for_library(&mut conn, 1).unwrap();
|
|
let rows = audit_library(&lib, &[], &stored);
|
|
let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect();
|
|
assert_eq!(rels, vec!["Screenshot_2014-06-01-20-44-50.png"]);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_clears_implausible_filename_dates_for_re_resolution() {
|
|
// Migrate up to just before the #160 data fix, seed rows as the
|
|
// old matcher left them, then apply it.
|
|
const FIX: &str = "reresolve_implausible_filename_dates";
|
|
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
|
|
for m in conn.pending_migrations(DB_MIGRATIONS).expect("pending") {
|
|
if m.name().to_string().contains(FIX) {
|
|
break;
|
|
}
|
|
conn.run_migration(&*m).expect("migration");
|
|
}
|
|
// 2229-05-24 08:05:51 and 1053-05-19 10:15:27 (wall-as-UTC), a
|
|
// real 2014 filename date, and an fs_time row that isn't ours to
|
|
// touch even though its date is odd.
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"VID_22290524_080551_818.mp4",
|
|
Some(8_185_651_551),
|
|
Some("filename"),
|
|
);
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"10530519_1015270_o.jpg",
|
|
Some(-28_925_732_673),
|
|
Some("filename"),
|
|
);
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
Some(1_401_655_490),
|
|
Some("filename"),
|
|
);
|
|
seed_row(&mut conn, 1, "old_scan.jpg", Some(100), Some("fs_time"));
|
|
diesel::update(image_exif::table)
|
|
.set((
|
|
image_exif::date_utc.eq(image_exif::date_taken),
|
|
image_exif::date_taken_tz_source.eq("assumed_server"),
|
|
))
|
|
.execute(&mut conn)
|
|
.unwrap();
|
|
|
|
conn.run_pending_migrations(DB_MIGRATIONS)
|
|
.expect("fix migration");
|
|
|
|
type Dates = (
|
|
String,
|
|
Option<i64>,
|
|
Option<String>,
|
|
Option<i64>,
|
|
Option<String>,
|
|
);
|
|
let rows: Vec<Dates> = image_exif::table
|
|
.select((
|
|
image_exif::rel_path,
|
|
image_exif::date_taken,
|
|
image_exif::date_taken_source,
|
|
image_exif::date_utc,
|
|
image_exif::date_taken_tz_source,
|
|
))
|
|
.order(image_exif::rel_path)
|
|
.load(&mut conn)
|
|
.unwrap();
|
|
let cleared = |r: &Dates| r.1.is_none() && r.2.is_none() && r.3.is_none() && r.4.is_none();
|
|
for r in &rows {
|
|
let expect_cleared = r.0.starts_with("VID_") || r.0.starts_with("1053");
|
|
assert_eq!(cleared(r), expect_cleared, "{:?}", r);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_audit_skips_disabled_and_offline_libraries() {
|
|
let tmp = tempdir().unwrap();
|
|
let online_root = tmp.path().join("online");
|
|
let missing_root = tmp.path().join("missing");
|
|
fs::create_dir_all(&online_root).unwrap();
|
|
fixture_library(&online_root);
|
|
// missing_root is never created -> probe fails.
|
|
|
|
let mut conn = migrated_conn();
|
|
// Migrations only seed library 1; image_exif.library_id is a
|
|
// foreign key, so library 2 must exist before its row does.
|
|
{
|
|
use crate::database::schema::libraries;
|
|
diesel::insert_into(libraries::table)
|
|
.values((
|
|
libraries::id.eq(2),
|
|
libraries::name.eq("offline"),
|
|
libraries::root_path.eq(missing_root.to_string_lossy().to_string()),
|
|
libraries::created_at.eq(1_700_000_000),
|
|
))
|
|
.execute(&mut conn)
|
|
.expect("seed library");
|
|
}
|
|
// Give the offline library a row so had_data=true (the strict
|
|
// probe: empty dir with rows is suspicious).
|
|
seed_row(&mut conn, 2, "whatever.jpg", Some(1), Some("exif"));
|
|
|
|
let libs = vec![
|
|
library(2, "offline", &missing_root, true),
|
|
library(1, "online", &online_root, true),
|
|
library(3, "disabled", &online_root, false),
|
|
];
|
|
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
|
|
|
|
let skipped_ids: Vec<i32> = skipped.iter().map(|s| s.library_id).collect();
|
|
assert_eq!(skipped_ids, vec![2, 3]);
|
|
assert!(skipped[0].reason.starts_with("offline"));
|
|
assert_eq!(skipped[1].reason, "disabled (enabled=false)");
|
|
|
|
// Only the online library was processed.
|
|
let libs_seen: Vec<i32> = rows.iter().map(|r| r.library_id).collect();
|
|
assert!(libs_seen.iter().all(|id| *id == 1));
|
|
assert!(!rows.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn run_audit_sorts_by_library_then_path() {
|
|
let tmp = tempdir().unwrap();
|
|
let a = tmp.path().join("a");
|
|
let b = tmp.path().join("b");
|
|
fs::create_dir_all(&a).unwrap();
|
|
fs::create_dir_all(&b).unwrap();
|
|
fs::write(a.join("2015-02-02_02-02-02.jpg"), b"").unwrap();
|
|
fs::write(a.join("Screenshot_2014-06-01-20-44-50.png"), b"").unwrap();
|
|
fs::write(b.join("1401638400.jpeg"), b"").unwrap();
|
|
// Intentionally pass libraries out of order.
|
|
let libs = vec![library(2, "b", &b, true), library(1, "a", &a, true)];
|
|
let mut conn = migrated_conn();
|
|
let (rows, skipped) = run_audit(&mut conn, libs, &[]);
|
|
assert!(skipped.is_empty());
|
|
let got: Vec<(i32, String)> = rows
|
|
.iter()
|
|
.map(|r| (r.library_id, r.rel_path.clone()))
|
|
.collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
(1, "2015-02-02_02-02-02.jpg".to_string()),
|
|
(1, "Screenshot_2014-06-01-20-44-50.png".to_string()),
|
|
(2, "1401638400.jpeg".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_report_is_deterministic_and_marks_changed() {
|
|
let tmp = tempdir().unwrap();
|
|
fixture_library(tmp.path());
|
|
let mut conn = migrated_conn();
|
|
seed_row(
|
|
&mut conn,
|
|
1,
|
|
"Screenshot_2014-06-01-20-44-50.png",
|
|
Some(1_610_742_400),
|
|
Some("exif"),
|
|
);
|
|
let lib = library(1, "photos", tmp.path(), true);
|
|
let (rows, skipped) = run_audit(&mut conn, vec![lib], &["@eaDir".to_string()]);
|
|
let first = format_report(&rows, &skipped);
|
|
let second = format_report(&rows, &skipped);
|
|
assert_eq!(first, second);
|
|
assert!(first.contains("*\t"), "changed row must be flagged");
|
|
assert!(first.contains("screenshot"));
|
|
assert!(first.contains("utc_direct"));
|
|
// Numeric offset is printed: the wall row carries the server-zone
|
|
// offset for that date, timestamp rows have none.
|
|
assert!(first.contains("offset=-240m"));
|
|
assert!(first.contains("offset=-\n"));
|
|
// The stored source renders without debug quotes (P1 from
|
|
// review): `2021-... [exif]`, never `["exif"]`.
|
|
assert!(first.contains("[exif]"));
|
|
assert!(!first.contains("\"exif\""));
|
|
assert!(first.contains("3 file(s) in the filename class, 1 would change"));
|
|
// Excluded path must not appear anywhere in the report.
|
|
assert!(!first.contains("2016-02-02_03-03-03.jpg"));
|
|
}
|
|
}
|