mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
LR-IMP-AUTO: Auto Import can include subfolders of the watched folder (#1109)
Settings > Import > Auto Import gains Include subfolders (off by default: the watched folder's top level only, as before). On, the scan also lists its subfolders' photos: hidden entries, links to folders and the library's own folder (where copy-mode arrivals go) are skipped; at most 16 levels and 200,000 entries, with a warning when a bound is hit. An unreadable subfolder or a non-Unicode name is skipped with a warning. library.autoImport takes subfolders; library.autoImportScan reports it and listingWarnings.
This commit is contained in:
@@ -24,15 +24,157 @@ fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
||||
Ok(rd
|
||||
.flatten()
|
||||
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|e| {
|
||||
let m = std::fs::metadata(e.path()).ok()?;
|
||||
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
||||
})
|
||||
.collect())
|
||||
AutoImportWalk::new(folder, false, Vec::new()).list().map(|l| l.files)
|
||||
}
|
||||
|
||||
/// Folder levels an auto import with subfolders lists (the watched folder's own entries are
|
||||
/// level 1).
|
||||
pub const AUTO_IMPORT_MAX_DEPTH: usize = 16;
|
||||
/// Directory entries an auto import with subfolders looks at per scan, at most.
|
||||
pub const AUTO_IMPORT_MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
/// How the watched folder is listed (`library.autoImportScan`): built from the session on the UI
|
||||
/// thread ([`AutoImportWalk::of`]), listed on a worker thread ([`AutoImportWalk::list`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutoImportWalk {
|
||||
pub folder: String,
|
||||
/// Also list subfolders (photos only, by extension), up to [`AUTO_IMPORT_MAX_DEPTH`] levels and
|
||||
/// [`AUTO_IMPORT_MAX_ENTRIES`] entries. Hidden folders and folder links are never entered.
|
||||
pub subfolders: bool,
|
||||
/// Folders never entered: the library's own (where auto import's copies go).
|
||||
pub skip: Vec<std::path::PathBuf>,
|
||||
/// Bounds of a listing with subfolders.
|
||||
pub max_depth: usize,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
/// What [`AutoImportWalk::list`] found.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AutoImportListing {
|
||||
/// (path, size) of each file.
|
||||
pub files: Vec<(String, u64)>,
|
||||
/// What was left out and why (an unreadable subfolder, a bound reached).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl AutoImportWalk {
|
||||
/// The session's auto-import listing, if a watched folder is set.
|
||||
pub fn of(s: &Session) -> Option<Self> {
|
||||
let d = &s.import_defaults;
|
||||
let folder = d.auto_folder.clone()?;
|
||||
// auto import copies into the library's Originals, so skipping the library also skips
|
||||
// the destination when it sits inside the watched folder
|
||||
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| vec![l.dir.clone()]).unwrap_or_default();
|
||||
Some(AutoImportWalk::new(&folder, d.auto_subfolders, skip))
|
||||
}
|
||||
|
||||
/// A listing of `folder` with the default bounds.
|
||||
pub fn new(folder: &str, subfolders: bool, skip: Vec<std::path::PathBuf>) -> Self {
|
||||
AutoImportWalk { folder: folder.to_string(), subfolders, skip, max_depth: AUTO_IMPORT_MAX_DEPTH, max_entries: AUTO_IMPORT_MAX_ENTRIES }
|
||||
}
|
||||
|
||||
/// List the watched folder (an error only when the folder itself can't be read).
|
||||
pub fn list(&self) -> std::result::Result<AutoImportListing, String> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let folder = &self.folder;
|
||||
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
||||
let mut out = AutoImportListing::default();
|
||||
if !self.subfolders {
|
||||
out.files = rd
|
||||
.flatten()
|
||||
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|e| {
|
||||
let m = std::fs::metadata(e.path()).ok()?;
|
||||
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
||||
})
|
||||
.collect();
|
||||
return Ok(out);
|
||||
}
|
||||
// skipped folders compared by their canonical paths: a folder's canonical path is the
|
||||
// canonical root joined with its path below the root (folder links aren't followed)
|
||||
let canon = |p: &Path| std::fs::canonicalize(p).ok();
|
||||
let skip: Vec<PathBuf> = self.skip.iter().filter_map(|p| canon(p)).collect();
|
||||
let root = canon(Path::new(folder)).unwrap_or_else(|| PathBuf::from(folder));
|
||||
if skip.contains(&root) {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut entries = 0usize;
|
||||
// stopped at the entry bound / left folders below the depth bound unlisted
|
||||
let (mut full, mut deep) = (false, false);
|
||||
let mut bad_names = 0usize;
|
||||
// (folder, its canonical path, its level: the watched folder's entries are level 1)
|
||||
let mut stack: Vec<(PathBuf, PathBuf, usize, Option<std::fs::ReadDir>)> = vec![(PathBuf::from(folder), root, 1, Some(rd))];
|
||||
while let Some((dir, canon_dir, level, rd)) = stack.pop() {
|
||||
let rd = match rd {
|
||||
Some(rd) => rd,
|
||||
None => match std::fs::read_dir(&dir) {
|
||||
Ok(rd) => rd,
|
||||
// gone since it was listed (moved or deleted while scanning): nothing to say
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => {
|
||||
out.warnings.push(format!("Auto Import skipped {}: {e}", dir.display()));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut below = Vec::new();
|
||||
for e in rd.flatten() {
|
||||
if entries >= self.max_entries {
|
||||
full = true;
|
||||
break;
|
||||
}
|
||||
entries = entries.saturating_add(1);
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
bad_names = bad_names.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
let path = e.path();
|
||||
if ft.is_dir() {
|
||||
let c = canon_dir.join(name);
|
||||
if skip.contains(&c) {
|
||||
continue;
|
||||
}
|
||||
if level >= self.max_depth {
|
||||
deep = true;
|
||||
continue;
|
||||
}
|
||||
below.push((path, c, level.saturating_add(1), None));
|
||||
continue;
|
||||
}
|
||||
// a link to a file is a file; a link to a folder isn't followed (no loops)
|
||||
if level > 1 && !crate::import::is_supported(&path) {
|
||||
continue;
|
||||
}
|
||||
let Ok(m) = std::fs::metadata(&path) else { continue };
|
||||
if m.is_file() {
|
||||
out.files.push((path.to_string_lossy().to_string(), m.len()));
|
||||
}
|
||||
}
|
||||
if full {
|
||||
break;
|
||||
}
|
||||
below.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
stack.extend(below);
|
||||
}
|
||||
if bad_names > 0 {
|
||||
out.warnings.push(format!("Auto Import skipped {bad_names} file or folder names that aren't valid Unicode in {folder}"));
|
||||
}
|
||||
if full {
|
||||
out.warnings.push(format!(
|
||||
"Auto Import stopped looking in {folder} after {} files and folders: photos beyond them aren't imported",
|
||||
self.max_entries
|
||||
));
|
||||
}
|
||||
if deep {
|
||||
out.warnings.push(format!("Auto Import looks {} folder levels deep in {folder}: photos further down aren't imported", self.max_depth));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// `library.import`'s params, checked (no file-system calls).
|
||||
@@ -1221,7 +1363,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Settings",
|
||||
[],
|
||||
None,
|
||||
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
|
||||
"{folder?: path | null (off), copy?: bool (copy into the library's Originals, else add in place), album?: name | null, subfolders?: bool (also its subfolders: photos only, hidden folders, folder links and the library's own folder skipped, 16 levels / 200,000 entries at most; default false)} — a watched folder whose new photos are added as they arrive (library.autoImportScan; the app scans every few seconds) → the settings",
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(f) = p.get("folder") {
|
||||
@@ -1234,12 +1376,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(c) = p.get("copy").and_then(Value::as_bool) {
|
||||
s.import_defaults.auto_copy = c;
|
||||
}
|
||||
if let Some(b) = p.get("subfolders").and_then(Value::as_bool) {
|
||||
s.import_defaults.auto_subfolders = b;
|
||||
}
|
||||
if let Some(a) = p.get("album") {
|
||||
s.import_defaults.auto_album = a.as_str().map(str::trim).filter(|a| !a.is_empty()).map(str::to_string);
|
||||
}
|
||||
s.save_prefs()?;
|
||||
let d = &s.import_defaults;
|
||||
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album}))
|
||||
Ok(json!({"folder": d.auto_folder, "copy": d.auto_copy, "album": d.auto_album, "subfolders": d.auto_subfolders}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -1247,10 +1392,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, warnings?: [{path, code, profile?, mode?, fields?}], import?}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), listingWarnings?: [text] (what that listing left out), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan; with subfolders on, its subfolders' photos too) → {imported, folder, subfolders, warnings?: [{path, code, profile?, mode?, fields?}], listingWarnings?: [text] (an unreadable subfolder, a listing bound reached), import?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
let Some(walk) = AutoImportWalk::of(s) else {
|
||||
return Ok(json!({"imported": [], "folder": null, "subfolders": s.import_defaults.auto_subfolders}));
|
||||
};
|
||||
let (folder, subfolders) = (walk.folder.clone(), walk.subfolders);
|
||||
// only files that stopped growing: a file still being written is left for later
|
||||
let known: std::collections::HashSet<String> = s
|
||||
.catalog
|
||||
@@ -1261,9 +1409,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.collect();
|
||||
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
|
||||
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
|
||||
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
|
||||
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
|
||||
let (listing, list_warnings): (Vec<(String, u64)>, Vec<String>) = match p.get("listing").and_then(Value::as_array) {
|
||||
Some(l) => {
|
||||
(l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(), strs(p, "listingWarnings"))
|
||||
}
|
||||
None => {
|
||||
let l = walk.list().map_err(|e| bad("library.autoImportScan", e))?;
|
||||
(l.files, l.warnings)
|
||||
}
|
||||
};
|
||||
let base = |imported: Value| {
|
||||
let mut r = json!({"imported": imported, "folder": folder, "subfolders": subfolders});
|
||||
if !list_warnings.is_empty() {
|
||||
r["listingWarnings"] = json!(list_warnings);
|
||||
}
|
||||
r
|
||||
};
|
||||
let mut fresh = Vec::new();
|
||||
for (ps, size) in listing {
|
||||
@@ -1280,7 +1440,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
if fresh.is_empty() {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
return Ok(base(json!([])));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
// a photo deleted from the library doesn't come back on its own because its file
|
||||
@@ -1297,13 +1457,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
if p.get("start").and_then(Value::as_bool) == Some(false) {
|
||||
// the caller imports them (the app: on a worker thread)
|
||||
return Ok(json!({"imported": [], "folder": folder, "import": params}));
|
||||
let mut r = base(json!([]));
|
||||
r["import"] = params;
|
||||
return Ok(r);
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
s.selection = sel;
|
||||
let mut result = json!({"imported": r["imported"], "folder": folder});
|
||||
let mut result = base(r["imported"].clone());
|
||||
if let Some(warnings) = r.get("warnings") {
|
||||
result["warnings"] = warnings.clone();
|
||||
}
|
||||
|
||||
@@ -310,6 +310,8 @@ pub struct ImportDefaults {
|
||||
pub auto_folder: Option<String>,
|
||||
pub auto_copy: bool,
|
||||
pub auto_album: Option<String>,
|
||||
/// Auto import also looks in the watched folder's subfolders (off: its top level only).
|
||||
pub auto_subfolders: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -980,6 +980,8 @@ mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_albums_local;
|
||||
#[cfg(test)]
|
||||
mod tests_auto_import;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_angle;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Auto Import with subfolders (issue #1109): off by default (the watched folder's top level
|
||||
//! only); on, its subfolders' photos too, never hidden folders, the library's own folder or
|
||||
//! folder links, within bounds that end in a warning.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::library::AutoImportWalk;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-autoimport-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A small procedural PNG (distinct per seed).
|
||||
fn write_png(path: &Path, seed: u8) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Two scans: a file is imported once its size held between them.
|
||||
fn scan_twice(s: &mut Session) -> serde_json::Value {
|
||||
s.execute("library.autoImportScan", &json!({})).unwrap();
|
||||
s.execute("library.autoImportScan", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
fn names(files: &[(String, u64)], root: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = files.iter().map(|(p, _)| Path::new(p).strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_photos_are_imported_only_with_subfolders_on() {
|
||||
let dir = temp_dir("nested");
|
||||
write_png(&dir.join("top.png"), 1);
|
||||
write_png(&dir.join("2026").join("10").join("deep.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["subfolders"], false, "off by default: {r}");
|
||||
let r = scan_twice(&mut s);
|
||||
assert_eq!(r["subfolders"], false, "{r}");
|
||||
assert_eq!(s.catalog.len(), 1, "the top level only: {r}");
|
||||
for _ in 0..2 {
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
}
|
||||
let r = s.execute("library.autoImport", &json!({"subfolders": true})).unwrap();
|
||||
assert_eq!(r["subfolders"], true, "{r}");
|
||||
let r = scan_twice(&mut s);
|
||||
assert_eq!(r["subfolders"], true, "{r}");
|
||||
assert_eq!(r["imported"].as_array().map(Vec::len), Some(1), "the nested photo: {r}");
|
||||
assert_eq!(s.catalog.len(), 2);
|
||||
// a file still growing in a subfolder waits, as at the top level
|
||||
write_png(&dir.join("2026").join("11").join("new.png"), 3);
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"].as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_setting_persists_with_the_library() {
|
||||
let dir = temp_dir("persist");
|
||||
let lib = dir.join("lib");
|
||||
let watched = dir.join("watched");
|
||||
std::fs::create_dir_all(&watched).unwrap();
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(!s.import_defaults.auto_subfolders, "off by default");
|
||||
s.execute("library.autoImport", &json!({"folder": watched.to_string_lossy(), "subfolders": true})).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.import_defaults.auto_subfolders);
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["subfolders"], true);
|
||||
// other settings leave it alone; turning it off sticks too
|
||||
let r = s.execute("library.autoImport", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(r["subfolders"], true, "{r}");
|
||||
s.execute("library.autoImport", &json!({"subfolders": false})).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(!s.import_defaults.auto_subfolders);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Hidden folders and the library's own folder (where copy-mode arrivals go) are never listed,
|
||||
/// so copies are never imported again.
|
||||
#[test]
|
||||
fn hidden_folders_and_the_library_are_skipped() {
|
||||
let dir = temp_dir("skip");
|
||||
let lib = dir.join("Library");
|
||||
write_png(&dir.join("shoot").join("a.png"), 1);
|
||||
write_png(&dir.join(".cache").join("h.png"), 2);
|
||||
write_png(&dir.join("shoot").join(".h.png"), 3);
|
||||
std::fs::write(dir.join("shoot").join("notes.txt"), "not a photo").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "copy": true, "subfolders": true})).unwrap();
|
||||
let r = scan_twice(&mut s);
|
||||
assert_eq!(r["imported"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(lib.join("Originals").is_dir(), "copied into the library inside the watched folder");
|
||||
let listed = AutoImportWalk::of(&s).unwrap().list().unwrap();
|
||||
assert_eq!(names(&listed.files, &dir), ["shoot/a.png"], "{listed:?}");
|
||||
assert!(listed.warnings.is_empty(), "{listed:?}");
|
||||
for _ in 0..3 {
|
||||
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
||||
}
|
||||
assert_eq!(s.catalog.len(), 1, "the copy isn't imported again");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The depth and entry bounds hold, each with a warning, so a huge tree can't stall the scan.
|
||||
#[test]
|
||||
fn bounds_stop_the_listing_with_a_warning() {
|
||||
let dir = temp_dir("bounds");
|
||||
let mut deep = dir.clone();
|
||||
for i in 0..5 {
|
||||
deep = deep.join(format!("l{i}"));
|
||||
write_png(&deep.join(format!("p{i}.png")), i);
|
||||
}
|
||||
let mut w = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new());
|
||||
let all = w.list().unwrap();
|
||||
assert_eq!(all.files.len(), 5);
|
||||
assert!(all.warnings.is_empty(), "{all:?}");
|
||||
w.max_depth = 3;
|
||||
let l = w.list().unwrap();
|
||||
assert_eq!(names(&l.files, &dir), ["l0/l1/p1.png", "l0/p0.png"], "three levels of entries listed");
|
||||
assert!(l.warnings.iter().any(|w| w.contains("3 folder levels")), "{l:?}");
|
||||
w.max_depth = 16;
|
||||
w.max_entries = 4;
|
||||
let l = w.list().unwrap();
|
||||
assert!(l.files.len() < 5, "{l:?}");
|
||||
assert!(l.warnings.iter().any(|w| w.contains("after 4 files and folders")), "{l:?}");
|
||||
// the warnings reach the scan's result
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "subfolders": true})).unwrap();
|
||||
let r = s.execute("library.autoImportScan", &json!({"listing": [], "listingWarnings": ["stopped"]})).unwrap();
|
||||
assert_eq!(r["listingWarnings"], json!(["stopped"]), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The default bounds: 16 levels, 200,000 entries.
|
||||
#[test]
|
||||
fn default_bounds() {
|
||||
let w = AutoImportWalk::new("x", true, Vec::new());
|
||||
assert_eq!((w.max_depth, w.max_entries), (16, 200_000));
|
||||
}
|
||||
|
||||
/// A watched folder that's gone is an error for the scan (as before); a subfolder that can't be
|
||||
/// read is skipped with a warning.
|
||||
#[test]
|
||||
fn a_missing_folder_errs_and_an_unreadable_subfolder_warns() {
|
||||
let dir = temp_dir("unreadable");
|
||||
let w = AutoImportWalk::new(&dir.join("gone").to_string_lossy(), true, Vec::new());
|
||||
assert!(w.list().is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
write_png(&dir.join("ok").join("a.png"), 1);
|
||||
let locked = dir.join("locked");
|
||||
write_png(&locked.join("b.png"), 2);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
// (root reads it anyway)
|
||||
if !names(&l.files, &dir).contains(&"locked/b.png".to_string()) {
|
||||
assert_eq!(names(&l.files, &dir), ["ok/a.png"]);
|
||||
assert!(l.warnings.iter().any(|w| w.contains("locked")), "{l:?}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Links to folders aren't followed (a link back up would loop); links to files are files.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folder_links_are_not_followed() {
|
||||
let dir = temp_dir("links");
|
||||
let outside = temp_dir("links-outside");
|
||||
write_png(&outside.join("o.png"), 1);
|
||||
write_png(&dir.join("sub").join("a.png"), 2);
|
||||
std::os::unix::fs::symlink(&outside, dir.join("sub").join("elsewhere")).unwrap();
|
||||
std::os::unix::fs::symlink(&dir, dir.join("sub").join("loop")).unwrap();
|
||||
std::os::unix::fs::symlink(outside.join("o.png"), dir.join("sub").join("linked.png")).unwrap();
|
||||
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
|
||||
assert_eq!(names(&l.files, &dir), ["sub/a.png", "sub/linked.png"], "{l:?}");
|
||||
assert!(l.warnings.is_empty(), "{l:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// A name that isn't valid Unicode is skipped with a warning, not a crash or a mangled path.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn non_unicode_names_are_skipped() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let dir = temp_dir("names");
|
||||
write_png(&dir.join("sub").join("a.png"), 1);
|
||||
let odd = dir.join("sub").join(std::ffi::OsStr::from_bytes(b"bad\xff.png"));
|
||||
if std::fs::write(&odd, b"x").is_err() {
|
||||
return; // a file system that refuses such names
|
||||
}
|
||||
let l = AutoImportWalk::new(&dir.to_string_lossy(), true, Vec::new()).list().unwrap();
|
||||
assert_eq!(names(&l.files, &dir), ["sub/a.png"], "{l:?}");
|
||||
assert!(l.warnings.iter().any(|w| w.contains("valid Unicode")), "{l:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1049,6 +1049,7 @@
|
||||
"Photos per bracket (0 = auto)": "Fotos pro Belichtungsreihe (0 = automatisch)",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos, die unter „Lokal“ angezeigt, aber nie hinzugefügt oder geändert wurden, verlassen den Katalog, wenn ihr Ordner so lange nicht durchsucht wurde (Prüfung beim Öffnen der Bibliothek). Dateien bleiben auf dem Datenträger; beim Durchsuchen erscheinen sie wieder.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotos, die in diesem Ordner ankommen (Tethering, Scanner, Synchronisierungs-App), werden hinzugefügt, sobald sie vollständig sind.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "Fotos in Ordnern innerhalb des überwachten Ordners werden ebenfalls hinzugefügt (ausgeblendete Ordner und der Ordner der Bibliothek selbst werden übersprungen).",
|
||||
"Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Auch Import einschließlich Drag-and-drop, Suche fehlender Fotos, Erstellen und Verwerfen von Smart-Vorschauen, Umbenennungsvorschau,",
|
||||
"Pick": "Auswahl",
|
||||
"Pick Flag": "Auswahlmarkierung",
|
||||
|
||||
@@ -1003,6 +1003,7 @@
|
||||
"Photos per bracket (0 = auto)": "Fotos por horquillado (0 = automático)",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Las fotos vistas en Local, pero nunca añadidas ni modificadas, salen del catálogo cuando su carpeta no se ha explorado durante este tiempo (se comprueba al abrir la biblioteca). Los archivos permanecen en el disco; al explorar la carpeta vuelven a aparecer.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Las fotos que llegan a esta carpeta (captura conectada, un escáner, una aplicación de sincronización) se añaden en cuanto están completas.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "También se añaden las fotos de las carpetas que hay dentro de la carpeta vigilada (se omiten las carpetas ocultas y la propia carpeta de la biblioteca).",
|
||||
"Pick": "Seleccionada",
|
||||
"Pick Flag": "Marca de seleccionada",
|
||||
"Pick a colour on the photo with the eyedropper.": "Elegir un color en la foto con el cuentagotas.",
|
||||
|
||||
@@ -998,6 +998,7 @@
|
||||
"Photos per bracket (0 = auto)": "Photos par bracketing (0 = auto)",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Les photos vues dans Local mais jamais ajoutées ni modifiées quittent le catalogue lorsque leur dossier n'a pas été parcouru depuis cette durée (vérifié à l'ouverture de la bibliothèque). Les fichiers restent sur le disque ; parcourir le dossier les fait réapparaître.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Les photos qui arrivent dans ce dossier (capture connectée, scanner, application de synchronisation) sont ajoutées dès qu'elles sont complètes.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "Les photos des dossiers situés dans le dossier surveillé sont aussi ajoutées (les dossiers masqués et le dossier de la bibliothèque elle-même sont ignorés).",
|
||||
"Pick": "Sélectionnée",
|
||||
"Pick Flag": "Drapeau de sélection",
|
||||
"Pick a colour on the photo with the eyedropper.": "Choisissez une couleur sur la photo avec la pipette.",
|
||||
|
||||
@@ -1019,6 +1019,7 @@
|
||||
"Photos": "写真",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "ローカルで参照しただけの写真は、この期間フォルダを開いていなければカタログから外します。ファイルはディスクに残り、再びフォルダを開くと表示されます",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "このフォルダに転送された写真は、転送が完了すると自動で追加されます",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "監視フォルダー内のフォルダーにある写真も追加されます(隠しフォルダーとライブラリ自体のフォルダーはスキップされます)。",
|
||||
"Pick": "採用",
|
||||
"Pick a colour on the photo with the eyedropper.": "スポイトで写真の色を選択してください",
|
||||
"Pick the sharpest photo of each burst": "連写ごとに最も鮮明な写真を採用",
|
||||
|
||||
@@ -1013,6 +1013,7 @@
|
||||
"Photos per bracket (0 = auto)": "Fotos por bracketing (0 = automático)",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos vistas em Local mas nunca adicionadas nem alteradas saem do catálogo quando sua pasta não é navegada por esse período (verificado ao abrir a biblioteca). Os arquivos permanecem no disco; navegar pela pasta as mostra novamente.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotos que chegam a esta pasta (captura conectada, um scanner, um app de sincronização) são adicionadas assim que ficam completas.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "As fotos em pastas dentro da pasta monitorada também são adicionadas (pastas ocultas e a própria pasta da biblioteca são ignoradas).",
|
||||
"Pick": "Escolher",
|
||||
"Pick Flag": "Marcação de escolhida",
|
||||
"Pick a colour on the photo with the eyedropper.": "Escolha uma cor na foto com o conta-gotas.",
|
||||
|
||||
@@ -838,6 +838,7 @@
|
||||
"Photos": "Фото",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Фото, которые видели в «Локальном», но ни разу не добавляли и не меняли, исключаются из каталога, если папку не открывали так долго (проверяется при открытии библиотеки). Файлы остаются на диске; при просмотре папки они появятся снова.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Фото, поступающие в эту папку (тетеринг, сканер, приложение синхронизации), добавляются, как только будут готовы.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "Фотографии из папок внутри отслеживаемой папки тоже добавляются (скрытые папки и папка самой библиотеки пропускаются).",
|
||||
"Pick": "Выбрать",
|
||||
"Pick a colour on the photo with the eyedropper.": "Выберите цвет на фото пипеткой.",
|
||||
"Pick the sharpest photo of each burst": "Выбирать самое чёткое фото в каждой серии",
|
||||
|
||||
@@ -1140,6 +1140,7 @@
|
||||
"Photos": "Фото",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Фото, переглянуті в локальних, але ніколи не додані й не змінені, вилучаються з каталогу, якщо їхню теку не оглядали протягом цього часу (перевіряється під час відкриття бібліотеки). Файли залишаються на диску; огляд теки знову їх показує.",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Фото, що надходять до цієї теки (камерне підключення, сканер, застосунок синхронізації), додаються одразу після завершення запису.",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "Фотографії з тек усередині відстежуваної теки теж додаються (приховані теки та тека самої бібліотеки пропускаються).",
|
||||
"Pick": "Відібрати",
|
||||
"Pick a colour on the photo with the eyedropper.": "Виберіть колір на фото піпеткою.",
|
||||
"Pick the sharpest photo of each burst": "Відібрати найчіткіше фото кожної серії",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"Photos": "照片",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "仅在“本地”中查看过、从未添加或更改的照片,若其文件夹在此时长内未被浏览,将移出目录(打开图库时检查)。文件保留在磁盘上;浏览该文件夹即可再次显示。",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "进入此文件夹的照片(联机拍摄、扫描仪、同步应用)在传输完成后即被添加。",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "监视文件夹内各文件夹中的照片也会添加(跳过隐藏文件夹和图库本身的文件夹)。",
|
||||
"Pick": "留用",
|
||||
"Pick a colour on the photo with the eyedropper.": "用吸管在照片上取色。",
|
||||
"Pick the sharpest photo of each burst": "为每组连拍选取最清晰的照片",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"Photos": "照片",
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "曾在本機瀏覽、但未加入或變更的照片,在其資料夾超過此期間未瀏覽時會移出目錄(開啟照片庫時檢驗)。檔案仍保留在磁碟上,再次瀏覽資料夾即可重新顯示。",
|
||||
"Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "此資料夾中新出現的照片(連線拍攝、掃描器或同步程式)會在檔案完整後立即加入。",
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).": "監看資料夾內各資料夾中的相片也會加入(略過隱藏資料夾和圖庫本身的資料夾)。",
|
||||
"Pick": "精選",
|
||||
"Pick a colour on the photo with the eyedropper.": "使用滴管在照片上選取色彩。",
|
||||
"Pick the sharpest photo of each burst": "選取每組連拍中最清晰的照片",
|
||||
|
||||
+28
-17
@@ -1021,7 +1021,7 @@ impl LightcraftApp {
|
||||
// auto import: list the watched folder every few seconds (on a worker thread: it may be on
|
||||
// a network share), then import what's new like any import (also on a worker thread)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
|
||||
if let Some(walk) = lightcraft_engine::cmd::library::AutoImportWalk::of(&self.session) {
|
||||
const LABEL: &str = "Auto Import";
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0
|
||||
@@ -1031,23 +1031,34 @@ impl LightcraftApp {
|
||||
&& !self.tasks.is_running(LABEL)
|
||||
{
|
||||
self.ui.auto_import_at = now;
|
||||
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
|
||||
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
|
||||
let listing = match listing {
|
||||
Ok(l) => l,
|
||||
Err(e) => return log::debug!("auto import: {e}"),
|
||||
let work = move || walk.list();
|
||||
let done =
|
||||
|app: &mut LightcraftApp, ctx: &egui::Context, listing: Result<lightcraft_engine::cmd::library::AutoImportListing, String>| {
|
||||
let listing = match listing {
|
||||
Ok(l) => l,
|
||||
Err(e) => return log::debug!("auto import: {e}"),
|
||||
};
|
||||
// what the listing left out (an unreadable subfolder, a bound reached): said once
|
||||
if listing.warnings != app.ui.auto_import_warned {
|
||||
for w in &listing.warnings {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
if let Some(w) = listing.warnings.first() {
|
||||
app.toast(ctx, w.clone());
|
||||
}
|
||||
app.ui.auto_import_warned = listing.warnings.clone();
|
||||
}
|
||||
let p = serde_json::json!({"listing": listing.files, "listingWarnings": listing.warnings, "start": false});
|
||||
let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return };
|
||||
let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return };
|
||||
let paths: Vec<String> =
|
||||
params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("paths");
|
||||
}
|
||||
let undo0 = app.session.undo.len();
|
||||
app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
|
||||
};
|
||||
let p = serde_json::json!({"listing": listing, "start": false});
|
||||
let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return };
|
||||
let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return };
|
||||
let paths: Vec<String> =
|
||||
params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("paths");
|
||||
}
|
||||
let undo0 = app.session.undo.len();
|
||||
app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
|
||||
};
|
||||
// listed every few seconds: quiet, no row in the activity stack
|
||||
if let Err(e) = tasks::spawn(self, LABEL, None, work, done) {
|
||||
log::warn!("{e}");
|
||||
|
||||
@@ -378,6 +378,16 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
if d.auto_folder.is_some() {
|
||||
row(ui, t, "", |ui| {
|
||||
let mut sub = d.auto_subfolders;
|
||||
let r = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders")).on_hover_text(crate::i18n::tr(
|
||||
"Photos in folders inside the watched folder are added too (hidden folders and the library's own folder are skipped).",
|
||||
));
|
||||
register(ui.ctx(), "check:settings.autoImportSubfolders", r.rect);
|
||||
if r.changed() {
|
||||
let _ = app.run("library.autoImport", json!({"subfolders": sub}));
|
||||
}
|
||||
});
|
||||
row(ui, t, "", |ui| {
|
||||
let mut copy = d.auto_copy;
|
||||
if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() {
|
||||
|
||||
@@ -600,6 +600,9 @@ pub struct UiState {
|
||||
/// When the watched folder was last scanned (egui time).
|
||||
#[serde(skip)]
|
||||
pub auto_import_at: f64,
|
||||
/// The last auto-import listing's warnings, shown once until they change.
|
||||
#[serde(skip)]
|
||||
pub auto_import_warned: Vec<String>,
|
||||
/// The develop control whose slider is being dragged (geometry sliders show a grid).
|
||||
#[serde(skip)]
|
||||
pub dragging_control: Option<String>,
|
||||
@@ -1077,6 +1080,7 @@ impl Default for UiState {
|
||||
external_edits: Vec::new(),
|
||||
was_focused: true,
|
||||
auto_import_at: 0.0,
|
||||
auto_import_warned: Vec::new(),
|
||||
search: String::new(),
|
||||
focus_search: false,
|
||||
renaming_mask: None,
|
||||
|
||||
@@ -373,6 +373,30 @@ fn auto_import_listing_has_no_row() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Issue #1109: Settings ▸ Import ▸ Auto Import has an Include subfolders box under the watched
|
||||
/// folder, off by default, that switches `library.autoImport`'s `subfolders`.
|
||||
#[test]
|
||||
fn auto_import_include_subfolders_box() {
|
||||
let mut h = demo();
|
||||
let dir = std::env::temp_dir().join(format!("lc-activity-autoimport-sub-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.app.session.execute("library.autoImport", &json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "import"}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.request("ui.widgets", json!({}), T);
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
}
|
||||
assert!(has(&h, "check:settings.autoImportSubfolders"), "the box is shown");
|
||||
assert!(!h.app.session.import_defaults.auto_subfolders, "off by default");
|
||||
click(&mut h, "check:settings.autoImportSubfolders");
|
||||
assert!(h.app.session.import_defaults.auto_subfolders);
|
||||
click(&mut h, "check:settings.autoImportSubfolders");
|
||||
assert!(!h.app.session.import_defaults.auto_subfolders);
|
||||
h.app.session.import_defaults.auto_folder = None;
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_added_later_grow_the_stack() {
|
||||
let mut h = demo();
|
||||
|
||||
@@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) |
|
||||
| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) |
|
||||
| **Total** | 408 | 51 | 72 | 35 | 193/201 (96%) | 145/157 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 172.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -73,7 +73,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
|
||||
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
|
||||
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) |
|
||||
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s |
|
||||
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s. **Include subfolders** (off by default, so the default is unchanged; `subfolders` on `library.autoImport`, reported by `library.autoImportScan`, issue #1109) also lists its subfolders' photos: hidden folders, folder links and the library's own folder (where copies go) are skipped, 16 levels / 200,000 entries at most, an unreadable subfolder or a bound reached is a warning (`listingWarnings`), not a failed scan |
|
||||
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
|
||||
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
|
||||
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## 2026-10-11
|
||||
|
||||
### Auto Import looks in subfolders
|
||||
- Settings ▸ Import ▸ Auto Import has an **Include subfolders** box (issue #1109): photos that arrive anywhere
|
||||
inside the watched folder, e.g. a tethering or sync app's dated folders, are added too. Off by default, so the
|
||||
watched folder's top level only, as before. Hidden folders, links to folders and the library's own folder (where
|
||||
copied photos go) are never looked in; the scan goes 16 folder levels deep and stops after 200,000 files and
|
||||
folders, and says so. A subfolder LightCraft can't read is skipped with a warning. Agents: `subfolders` on
|
||||
`library.autoImport`.
|
||||
|
||||
### JPEG XL export
|
||||
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
|
||||
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a
|
||||
|
||||
Reference in New Issue
Block a user