mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
Merge main into feat/copyright-status
# Conflicts: # crates/engine/src/tests_xmp.rs # docs/parity.md
This commit is contained in:
@@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| {
|
||||
Ok(serde_json::to_value(crate::devices::devices()).unwrap_or_default())
|
||||
Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default())
|
||||
}),
|
||||
cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| {
|
||||
let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?;
|
||||
@@ -745,10 +745,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"library.import",
|
||||
"Add Photos…",
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -773,11 +773,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => {
|
||||
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
|
||||
}
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad(
|
||||
"library.import",
|
||||
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
|
||||
)
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
|
||||
@@ -61,7 +61,10 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
Ok(crate::rename::token_help_json())
|
||||
}),
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -70,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
None,
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
|
||||
@@ -5,24 +5,48 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The file Missing Photos checks for `p`, if it is in scope: a library photo (not in Recently
|
||||
/// Deleted, not a Local-only record seen while browsing a folder) whose original is a file.
|
||||
///
|
||||
/// Local browse records are out of scope on purpose: browsing can leave tens of thousands of
|
||||
/// them in the catalog, they never show in library views (Missing Photos included), and a
|
||||
/// folder that is gone is simply not browsed again. The sidebar count, the Missing Photos view,
|
||||
/// `library.missing` and Find Missing Photos all use this one rule.
|
||||
pub fn checked_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } if p.in_library() => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every in-scope photo's file ([`checked_path`]), without touching the disk.
|
||||
pub fn candidates(cat: &Catalog) -> Vec<String> {
|
||||
cat.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// [`missing`] with the existence check supplied (tests count the file-system calls).
|
||||
pub fn missing_with(cat: &Catalog, mut exists: impl FnMut(&str) -> bool) -> Vec<(PhotoId, String)> {
|
||||
cat.photos().filter_map(|p| checked_path(p).filter(|f| !exists(f)).map(|f| (p.id, f.to_string()))).collect()
|
||||
}
|
||||
|
||||
/// Library photos whose original file can't be found: (id, path).
|
||||
pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
s.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } if !p.deleted && !Path::new(path).exists() => Some((p.id, path.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
missing_with(&s.catalog, |f| Path::new(f).exists())
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// the photos its query already narrowed to).
|
||||
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
@@ -50,7 +74,11 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
ops.extend(crate::cmd::convert::content_op(id, ph, info));
|
||||
}
|
||||
s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?;
|
||||
let op = match <[Op; 1]>::try_from(ops) {
|
||||
Ok([op]) => op,
|
||||
Err(ops) => Op::Batch { ops },
|
||||
};
|
||||
s.commit("Relink Photo", op)?;
|
||||
s.media.forget(id);
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
+202
-21
@@ -1,6 +1,15 @@
|
||||
//! Cameras and memory cards: mounted volumes with a `DCIM` folder (the DCF layout every camera
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Add from Device, and
|
||||
//! writes). Listed by `library.devices`; the app offers them under File → Import from Device, and
|
||||
//! the import review copies from them into the library.
|
||||
//!
|
||||
//! Menus are rebuilt every frame (even with their popups closed), so [`devices`] never touches the
|
||||
//! disk: it serves the last scan and, when that is older than [`MAX_AGE`], starts one background
|
||||
//! rescan (coalesced; no lock is held while it runs). A changed result calls the [`on_change`]
|
||||
//! hook, which the UI uses to repaint, so hot-plugged cards still appear. [`devices_now`] scans
|
||||
//! synchronously for explicit requests (`library.devices`, Add from Device without a path).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -14,6 +23,9 @@ pub struct Device {
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// How long a scan is served before [`devices`] starts another one.
|
||||
pub const MAX_AGE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS`
|
||||
/// (paths joined like `PATH`) replaces them (tests, unusual mounts).
|
||||
fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
@@ -34,21 +46,114 @@ fn mount_parents() -> Vec<std::path::PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card. Cached for a couple of seconds:
|
||||
/// menus ask often, and listing mounts can be slow (network volumes).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
static CACHE: Mutex<Option<(Instant, Vec<Device>)>> = Mutex::new(None);
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((t, d)) = c.as_ref()
|
||||
&& t.elapsed() < Duration::from_secs(2)
|
||||
{
|
||||
return d.clone();
|
||||
type Scanner = dyn Fn() -> Vec<Device> + Send + Sync;
|
||||
type Notify = dyn Fn() + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatchState {
|
||||
list: Vec<Device>,
|
||||
/// When the served list was scanned (`None`: never). No clock (and no threads) on wasm, where
|
||||
/// there are no mounted volumes either.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
scanned: Option<std::time::Instant>,
|
||||
/// A background scan is running.
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// A cached device list, refreshed off the calling thread.
|
||||
pub struct DeviceWatch {
|
||||
scanner: Box<Scanner>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
max_age: Duration,
|
||||
state: Mutex<WatchState>,
|
||||
notify: Mutex<Option<Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl DeviceWatch {
|
||||
pub fn new(scanner: impl Fn() -> Vec<Device> + Send + Sync + 'static, max_age: Duration) -> Arc<Self> {
|
||||
Arc::new(DeviceWatch { scanner: Box::new(scanner), max_age, state: Mutex::default(), notify: Mutex::new(None) })
|
||||
}
|
||||
let d = scan();
|
||||
*c = Some((Instant::now(), d.clone()));
|
||||
d
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, WatchState> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Called (on the scanning thread) whenever a scan changes the list.
|
||||
pub fn on_change(&self, f: impl Fn() + Send + Sync + 'static) {
|
||||
*self.notify.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(f));
|
||||
}
|
||||
|
||||
/// The last scan, immediately; starts a background rescan when it is stale and none is running.
|
||||
pub fn get(self: &Arc<Self>) -> Vec<Device> {
|
||||
let mut s = self.state();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !s.refreshing && s.scanned.is_none_or(|t| t.elapsed() >= self.max_age) {
|
||||
s.refreshing = true;
|
||||
let me = self.clone();
|
||||
// the scan runs without the state lock; `get` only ever waits for a list clone
|
||||
let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || {
|
||||
let list = (me.scanner)();
|
||||
me.store(list);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
s.refreshing = false;
|
||||
}
|
||||
}
|
||||
s.list.clone()
|
||||
}
|
||||
|
||||
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
|
||||
pub fn scan_now(&self) -> Vec<Device> {
|
||||
let list = (self.scanner)();
|
||||
self.store(list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
fn store(&self, list: Vec<Device>) {
|
||||
let changed = {
|
||||
let mut s = self.state();
|
||||
s.refreshing = false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.scanned = Some(std::time::Instant::now());
|
||||
}
|
||||
let changed = s.list != list;
|
||||
s.list = list;
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
let f = self.notify.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(f) = f {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A background scan is running.
|
||||
pub fn refreshing(&self) -> bool {
|
||||
self.state().refreshing
|
||||
}
|
||||
}
|
||||
|
||||
fn global() -> &'static Arc<DeviceWatch> {
|
||||
static WATCH: std::sync::OnceLock<Arc<DeviceWatch>> = std::sync::OnceLock::new();
|
||||
WATCH.get_or_init(|| DeviceWatch::new(scan, MAX_AGE))
|
||||
}
|
||||
|
||||
/// Mounted volumes that look like a camera or a memory card, without blocking: the last scan,
|
||||
/// refreshed in the background when older than [`MAX_AGE`] (empty until the first scan lands).
|
||||
pub fn devices() -> Vec<Device> {
|
||||
global().get()
|
||||
}
|
||||
|
||||
/// Scan the mounted volumes now (blocking), for explicit requests.
|
||||
pub fn devices_now() -> Vec<Device> {
|
||||
global().scan_now()
|
||||
}
|
||||
|
||||
/// Run `f` whenever a scan changes the device list (the UI requests a repaint).
|
||||
pub fn on_change(f: impl Fn() + Send + Sync + 'static) {
|
||||
global().on_change(f);
|
||||
}
|
||||
|
||||
fn scan() -> Vec<Device> {
|
||||
@@ -73,10 +178,7 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
roots.extend(extra);
|
||||
let mut out: Vec<Device> = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(&root) else { continue };
|
||||
let Some(dcim) = rd.flatten().map(|e| e.path()).find(|p| p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM"))) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dcim) = dcim_in(&root) else { continue };
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| root.to_string_lossy().to_string());
|
||||
if !out.iter().any(|d| d.root == root.to_string_lossy()) {
|
||||
out.push(Device { name, path: dcim.to_string_lossy().to_string(), root: root.to_string_lossy().to_string() });
|
||||
@@ -85,9 +187,22 @@ pub fn devices_in(parents: &[std::path::PathBuf], extra: Vec<std::path::PathBuf>
|
||||
out
|
||||
}
|
||||
|
||||
/// The volume's `DCIM` folder: the usual spellings are checked directly, so a volume's root is
|
||||
/// only listed (other spellings on a case-sensitive volume) off Windows, whose drives aren't.
|
||||
fn dcim_in(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = ["DCIM", "dcim"].iter().map(|n| root.join(n)).find(|p| p.is_dir()) {
|
||||
return Some(p);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
std::fs::read_dir(root).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")) && p.is_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn volumes_with_a_dcim_folder_are_devices() {
|
||||
@@ -96,10 +211,76 @@ mod tests {
|
||||
std::fs::create_dir_all(base.join("EOS_DIGITAL/DCIM/100CANON")).unwrap();
|
||||
std::fs::create_dir_all(base.join("Backup/Photos")).unwrap();
|
||||
std::fs::create_dir_all(base.join("SD/dcim")).unwrap();
|
||||
std::fs::create_dir_all(base.join("X/Dcim")).unwrap();
|
||||
let d = devices_in(std::slice::from_ref(&base), Vec::new());
|
||||
let names: Vec<&str> = d.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD"], "{d:?}");
|
||||
assert!(d[0].path.ends_with("DCIM") && d[1].path.ends_with("dcim"));
|
||||
assert_eq!(names, ["EOS_DIGITAL", "SD", "X"], "{d:?}");
|
||||
// the DCIM folder itself, in a spelling that opens on that volume
|
||||
for x in &d {
|
||||
let p = std::path::Path::new(&x.path);
|
||||
assert!(p.is_dir() && p.file_name().is_some_and(|n| n.eq_ignore_ascii_case("DCIM")), "{x:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn card(name: &str) -> Device {
|
||||
Device { name: name.into(), path: format!("/v/{name}/DCIM"), root: format!("/v/{name}") }
|
||||
}
|
||||
|
||||
/// Wait (bounded) for `f`.
|
||||
fn until(f: impl Fn() -> bool) -> bool {
|
||||
let t = std::time::Instant::now();
|
||||
while t.elapsed() < Duration::from_secs(20) {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_scans_never_block_and_hot_plug_still_arrives() {
|
||||
// a scanner that takes 1.5 s (a sleeping network drive) and counts its runs
|
||||
const SLOW: Duration = Duration::from_millis(1500);
|
||||
let mounted = Arc::new(Mutex::new(Vec::<Device>::new()));
|
||||
let runs = Arc::new(AtomicUsize::new(0));
|
||||
let (m, r) = (mounted.clone(), runs.clone());
|
||||
let watch = DeviceWatch::new(
|
||||
move || {
|
||||
r.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(SLOW);
|
||||
m.lock().unwrap().clone()
|
||||
},
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
let changes = Arc::new(AtomicUsize::new(0));
|
||||
let c = changes.clone();
|
||||
watch.on_change(move || {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
// cold: served at once (empty) while one scan runs in the background
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..50 {
|
||||
assert!(watch.get().is_empty());
|
||||
}
|
||||
assert!(t.elapsed() < SLOW / 2, "get() waited for the scan: {:?}", t.elapsed());
|
||||
assert!(watch.refreshing());
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1, "requests while scanning are coalesced");
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 0, "nothing changed");
|
||||
// a card is plugged in: the next stale request starts a rescan, still without waiting
|
||||
mounted.lock().unwrap().push(card("EOS_DIGITAL"));
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
let t = std::time::Instant::now();
|
||||
assert!(watch.get().is_empty(), "the old list is served while rescanning");
|
||||
assert!(t.elapsed() < SLOW / 2, "warm get() waited: {:?}", t.elapsed());
|
||||
assert!(until(|| changes.load(Ordering::SeqCst) == 1), "a changed scan notifies");
|
||||
assert_eq!(watch.get(), [card("EOS_DIGITAL")]);
|
||||
// explicit requests scan synchronously
|
||||
assert!(until(|| !watch.refreshing()));
|
||||
mounted.lock().unwrap().clear();
|
||||
assert!(watch.scan_now().is_empty());
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,10 +553,9 @@ impl ExportOptions {
|
||||
r(ResizeMode::ShortEdge, v)
|
||||
} else if let Some(v) = f("megapixels") {
|
||||
r(ResizeMode::Megapixels, v)
|
||||
} else if let Some(v) = f("percent") {
|
||||
r(ResizeMode::Percent, v)
|
||||
} else {
|
||||
return None;
|
||||
let v = f("percent")?;
|
||||
r(ResizeMode::Percent, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -659,7 +658,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
let (w, h) = (img.width, img.height);
|
||||
match &mut img.samples {
|
||||
DeepSamples::U16(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.round().clamp(0.0, 65535.0) as u16;
|
||||
@@ -667,7 +666,7 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar
|
||||
}
|
||||
}
|
||||
DeepSamples::F32(v) => {
|
||||
let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
|
||||
if let Some(out) = unsharp(w, h, &src, target, amount) {
|
||||
for (d, s) in v.iter_mut().zip(out.as_flattened()) {
|
||||
*d = s.max(0.0);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Last-resort panic guard (never-crash standard, `AGENTS.md`): a panic that escapes a command
|
||||
//! becomes that command's error and the session (catalog, library, undo history) stays usable.
|
||||
//! It's a safety net, not a way to report errors: commands return `Err` themselves.
|
||||
|
||||
use std::any::Any;
|
||||
use std::io::Write;
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The text of a panic payload (`panic!("…")` gives a `&str` or a `String`).
|
||||
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
|
||||
match (payload.downcast_ref::<&str>(), payload.downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown panic".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`; a panic inside it becomes `Err("<what> failed unexpectedly: <message>")`. On wasm32
|
||||
/// (built with `panic = "abort"`) nothing can be caught and this just runs `f`.
|
||||
pub fn catch<T>(what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
|
||||
let msg = format!("{what} failed unexpectedly: {}", panic_message(p.as_ref()));
|
||||
log::error!("{msg}");
|
||||
msg
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a panic hook that keeps the default report (stderr) and also appends the panic's
|
||||
/// location and message to `log` (best effort), so a panic in a GUI session leaves a trace.
|
||||
pub fn install_hook(log: PathBuf) {
|
||||
let default = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
default(info);
|
||||
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
|
||||
let _ = writeln!(f, "{secs} [{thread}] panic at {at}: {}", panic_message(info.payload()));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_an_error() {
|
||||
assert_eq!(catch("ok", || 7), Ok(7));
|
||||
let e = catch("`demo.cmd`", || -> u32 { panic!("boom {}", 1) }).unwrap_err();
|
||||
assert_eq!(e, "`demo.cmd` failed unexpectedly: boom 1");
|
||||
assert!(catch("x", || std::panic::panic_any(5u8)).unwrap_err().ends_with("unknown panic"));
|
||||
}
|
||||
}
|
||||
+55
-34
@@ -8,8 +8,9 @@
|
||||
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
|
||||
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
|
||||
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
|
||||
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]).
|
||||
//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
|
||||
//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills
|
||||
//! in when the file has none (read before step 5, so it also files and names the copy).
|
||||
//! 7. Optionally apply a preset and add keywords (the import dialog's options).
|
||||
//! 8. Commit all new photos as one undoable op.
|
||||
//!
|
||||
@@ -66,8 +67,9 @@ pub struct ImportOptions {
|
||||
pub convert_dng: bool,
|
||||
}
|
||||
|
||||
/// How copies are filed in the destination.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
/// How copies are filed in the destination. The date is the capture time, else the time of the
|
||||
/// import.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Organize {
|
||||
/// `YYYY/YYYY-MM-DD/` by capture date.
|
||||
#[default]
|
||||
@@ -76,17 +78,36 @@ pub enum Organize {
|
||||
ByMonth,
|
||||
/// All in the destination itself.
|
||||
Flat,
|
||||
/// A folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → `2026/20260114/`: the template's own
|
||||
/// `/` make the levels, each expanded with the file-name tokens ([`crate::rename::expand_folder`];
|
||||
/// never outside the destination).
|
||||
Template(String),
|
||||
}
|
||||
|
||||
impl Organize {
|
||||
/// `date` | `month` | `flat`, or a folder template (anything with a `{`, `/` or `\`).
|
||||
pub fn parse(s: &str) -> Option<Organize> {
|
||||
match s {
|
||||
"date" | "day" | "byDay" => Some(Organize::ByDay),
|
||||
"month" | "byMonth" => Some(Organize::ByMonth),
|
||||
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
|
||||
t if t.contains(['{', '/', '\\']) => Some(Organize::Template(t.trim().to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders (inside the destination) a copy of `p` goes to.
|
||||
pub fn folders(&self, p: &Photo) -> Vec<String> {
|
||||
let date = p.date();
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
match self {
|
||||
Organize::ByDay => vec![year.into(), day.into()],
|
||||
Organize::ByMonth => vec![year.into(), day.get(..7).unwrap_or("undated").into()],
|
||||
Organize::Flat => Vec::new(),
|
||||
Organize::Template(t) => crate::rename::expand_folder(t, p, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file found by [`scan`], for the import review.
|
||||
@@ -322,16 +343,10 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
|
||||
/// made unique with -1, -2…; returns the new path.
|
||||
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
|
||||
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
|
||||
let year = day.get(..4).unwrap_or("undated");
|
||||
let dir = match organize {
|
||||
Organize::ByDay => root.join(year).join(day),
|
||||
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
|
||||
Organize::Flat => root.to_path_buf(),
|
||||
};
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
@@ -553,21 +568,38 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy, Some(root))
|
||||
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
// the template sees the photo as it will be catalogued
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let n = crate::rename::expand(t, &q, seq);
|
||||
seq += 1;
|
||||
n
|
||||
});
|
||||
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
|
||||
match copy_into(root, &path, &opts.organize.folders(&q), name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
@@ -583,7 +615,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
let mut info = info;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
@@ -606,19 +637,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
if let Some(x) = packet {
|
||||
match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
|
||||
crate::sidecar::merge_into(&mut p, &sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("import {path}: XMP: {e}"),
|
||||
}
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod demo;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
pub mod files;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod media;
|
||||
@@ -237,7 +238,8 @@ impl Session {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
// last-resort guard: a panic in a command is that command's error, not a crash
|
||||
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
@@ -507,8 +509,9 @@ impl Session {
|
||||
});
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
let missing: std::collections::HashSet<PhotoId> = cmd::missing::missing(self).into_iter().map(|m| m.0).collect();
|
||||
self.visible.retain(|id| missing.contains(id));
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
self.visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
self.visible = self.catalog.arrange_stacks(&self.visible);
|
||||
|
||||
@@ -228,8 +228,8 @@ impl Session {
|
||||
self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes());
|
||||
}
|
||||
let view_written = self.view_json();
|
||||
self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&self.library.as_ref().expect("just set").report)
|
||||
let library = self.library.insert(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written });
|
||||
Ok(&library.report)
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
|
||||
@@ -309,14 +309,10 @@ fn output_path(first: &str, suffix: &str) -> String {
|
||||
let p = std::path::Path::new(first);
|
||||
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
|
||||
for k in 1.. {
|
||||
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
|
||||
let cand = dir.join(name);
|
||||
if !cand.exists() {
|
||||
return cand.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") });
|
||||
// (`u64::MAX` taken names can't exist; the last candidate is the fallback)
|
||||
let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX));
|
||||
free.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Parse `merge.*` command parameters into a kind and finishing options.
|
||||
|
||||
@@ -82,6 +82,148 @@ fn format_date(iso: &str, fmt: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
|
||||
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct TokenHelp {
|
||||
/// The token as typed, e.g. `{seq:3}`.
|
||||
pub tag: &'static str,
|
||||
/// Other spellings with the same meaning.
|
||||
pub aliases: &'static [&'static str],
|
||||
pub meaning: &'static str,
|
||||
}
|
||||
|
||||
/// Every template token, in the order the help shows them.
|
||||
pub const TOKENS: &[TokenHelp] = &[
|
||||
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
|
||||
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
|
||||
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
|
||||
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
|
||||
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
|
||||
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
|
||||
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
|
||||
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
|
||||
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
|
||||
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
|
||||
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
|
||||
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
|
||||
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
|
||||
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
|
||||
];
|
||||
|
||||
/// The `%` directives of `{date:…}`.
|
||||
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
|
||||
("%Y", "year, 4 digits"),
|
||||
("%y", "year, 2 digits"),
|
||||
("%m", "month 01–12"),
|
||||
("%d", "day 01–31"),
|
||||
("%H", "hour 00–23"),
|
||||
("%M", "minute"),
|
||||
("%S", "second"),
|
||||
("%%", "a literal %"),
|
||||
];
|
||||
|
||||
/// How templates behave, one sentence each (shown under the token list).
|
||||
pub const TEMPLATE_NOTES: &[&str] = &[
|
||||
"The original extension is always added; {ext} only puts it inside the name as well.",
|
||||
"A blank template keeps the original names.",
|
||||
"{date} is the capture time; a photo without one uses the time it was imported.",
|
||||
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
|
||||
"Unknown tags stay as typed — check the preview for a {typo}.",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
|
||||
];
|
||||
|
||||
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
|
||||
pub fn token_summary() -> String {
|
||||
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
|
||||
pub fn sample_photo() -> Photo {
|
||||
let mut p =
|
||||
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
|
||||
p.captured = Some("2026-01-14T05:58:48".into());
|
||||
p.rating = 4;
|
||||
p.meta.camera = "Canon EOS R5".into();
|
||||
p.meta.lens = "RF24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.title = "Harbour".into();
|
||||
p.meta.creator = "Ann Lee".into();
|
||||
p
|
||||
}
|
||||
|
||||
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
|
||||
pub fn token_example(tag: &str) -> String {
|
||||
expand_tokens(tag, &sample_photo(), 1, 1)
|
||||
}
|
||||
|
||||
/// Why a folder template (`{date:%Y}/{date:%Y%m%d}`) can't be used, if it can't: it must be
|
||||
/// relative (no leading `/` or `\`, drive letter or `~`) and have no `.` / `..` levels.
|
||||
pub fn folder_template_error(template: &str) -> Option<String> {
|
||||
let t = template.trim();
|
||||
if t.is_empty() {
|
||||
return Some("the folder template is empty".into());
|
||||
}
|
||||
let b = t.as_bytes();
|
||||
if t.starts_with(['/', '\\', '~']) || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') {
|
||||
return Some("the folder template must be relative to the destination (no leading /, \\, ~ or drive)".into());
|
||||
}
|
||||
if t.split(['/', '\\']).any(|s| matches!(s.trim(), "." | "..")) {
|
||||
return Some("the folder template may not contain . or .. folders".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The folders (relative to the destination) a folder template gives `p`: the template's own `/`
|
||||
/// (or `\`) separate levels; each level's tokens are expanded and the result made a safe folder
|
||||
/// name — a value can never add a level or climb out (`/`, `\`, `:`… become `-`, leading and
|
||||
/// trailing dots are dropped). A level that comes out empty (missing metadata) is `unknown`; empty
|
||||
/// levels in the template (`a//b`) are skipped. `{date}` is the capture time, else [`Photo::date`]'s
|
||||
/// fallback (the import time). Check [`folder_template_error`] first; levels it rejects are skipped.
|
||||
pub fn expand_folder(template: &str, p: &Photo, seq: usize) -> Vec<String> {
|
||||
template
|
||||
.split(['/', '\\'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && !matches!(*s, "." | ".."))
|
||||
.map(|s| {
|
||||
let v = sanitize(&expand_tokens(s, p, seq, 1));
|
||||
if v.is_empty() { "unknown".to_string() } else { v }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
|
||||
pub fn unknown_tokens(template: &str) -> Vec<String> {
|
||||
let p = sample_photo();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}') else { break };
|
||||
let tag = &rest[i..i + j + 1];
|
||||
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
|
||||
out.push(tag.to_string());
|
||||
}
|
||||
rest = &rest[i + j + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
|
||||
/// `photo.renameTokens` command).
|
||||
pub fn token_help_json() -> serde_json::Value {
|
||||
let tokens: Vec<serde_json::Value> = TOKENS
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
|
||||
.collect();
|
||||
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
|
||||
serde_json::json!({
|
||||
"tokens": tokens,
|
||||
"dateDirectives": directives,
|
||||
"notes": TEMPLATE_NOTES,
|
||||
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
|
||||
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
@@ -358,6 +500,66 @@ impl Session {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_templates_stay_inside_the_destination() {
|
||||
let mut p = sample_photo();
|
||||
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
|
||||
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
|
||||
// metadata can't add levels or climb out
|
||||
p.meta.camera = "../../etc/x".into();
|
||||
p.meta.title = "..".into();
|
||||
assert_eq!(expand_folder("{camera}/{title}/{date:%Y}", &p, 1), vec!["-..-etc-x", "unknown", "2026"]);
|
||||
p.meta.camera = "C:\\Windows".into();
|
||||
assert_eq!(expand_folder("{camera}", &p, 1), vec!["C--Windows"]);
|
||||
// missing metadata → `unknown`; missing capture time → the import time
|
||||
p.meta.camera.clear();
|
||||
p.captured = None;
|
||||
assert_eq!(expand_folder("{camera}//{date:%Y%m%d}", &p, 1), vec!["unknown", "20260120"]);
|
||||
for bad in ["", " ", "/abs/{date}", "\\\\server\\x", "C:/x", "c:x", "~/x", "../{date}", "{date}/../x", "a/./b", "{date:%Y}/.."] {
|
||||
assert!(folder_template_error(bad).is_some(), "{bad:?} should be rejected");
|
||||
}
|
||||
for ok in ["{date:%Y}/{date:%Y%m%d}", "Trips/{camera}", "{date}", "a//b"] {
|
||||
assert_eq!(folder_template_error(ok), None, "{ok:?}");
|
||||
}
|
||||
// even unchecked, rejected levels are skipped rather than followed
|
||||
assert_eq!(expand_folder("/../{date:%Y}/./x", &p, 1), vec!["2026", "x"]);
|
||||
}
|
||||
|
||||
/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is
|
||||
/// replaced, never left literal, and the help's examples match the documented behaviour.
|
||||
#[test]
|
||||
fn token_help_matches_the_implementation() {
|
||||
let p = sample_photo();
|
||||
for t in TOKENS {
|
||||
for tag in std::iter::once(&t.tag).chain(t.aliases) {
|
||||
let v = expand_tokens(tag, &p, 1, 1);
|
||||
assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})");
|
||||
assert!(!v.is_empty(), "{tag}: the sample photo should give an example");
|
||||
}
|
||||
}
|
||||
assert_eq!(token_example("{seq:3}"), "001");
|
||||
assert_eq!(token_example("{date}"), "20260114");
|
||||
assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848");
|
||||
assert_eq!(token_example("{ext}"), "CR3");
|
||||
assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3");
|
||||
for (d, _) in DATE_DIRECTIVES {
|
||||
let v = format_date("2026-01-14T05:58:48", d);
|
||||
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
|
||||
}
|
||||
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
|
||||
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
|
||||
let json = token_help_json();
|
||||
assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len());
|
||||
assert_eq!(json["tokens"][0]["example"], "IMG_0042");
|
||||
// the command descriptions that list the tokens list all of them
|
||||
for id in ["photo.rename", "photo.renamePreview", "library.import"] {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) {
|
||||
assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates() {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00");
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
@@ -84,6 +86,9 @@ pub struct SidecarData {
|
||||
pub alt_text: Option<String>,
|
||||
pub extended_description: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
|
||||
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
|
||||
pub captured: Option<String>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
}
|
||||
|
||||
@@ -115,6 +120,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
captured: m.capture_time.map(|d| d.to_iso()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = m.rating {
|
||||
@@ -143,8 +149,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
|
||||
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
|
||||
/// the file is kept). Returns true if develop changed.
|
||||
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
|
||||
if p.captured.is_none() {
|
||||
p.captured = sc.captured.clone();
|
||||
}
|
||||
if let Some(r) = sc.rating {
|
||||
p.rating = r;
|
||||
}
|
||||
@@ -321,6 +331,9 @@ impl Session {
|
||||
Op::SetLabel { id, label: q.label },
|
||||
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
|
||||
];
|
||||
if q.captured != p.captured {
|
||||
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
|
||||
}
|
||||
if develop_changed {
|
||||
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
|
||||
}
|
||||
|
||||
@@ -257,6 +257,37 @@ fn copy_with_destination_organize_rename_and_metadata_preset() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy with a custom folder template: `{date:%Y}/{date:%Y%m%d}` files a photo under
|
||||
/// `2026/20260114/` (an undated file by the import time); templates that would leave the
|
||||
/// destination are refused.
|
||||
#[test]
|
||||
fn copy_with_a_folder_template() {
|
||||
let src = temp_dir("tplsrc");
|
||||
let dest = temp_dir("tpldest");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
|
||||
for bad in ["../{date}", "/tmp/{date}", "{date:%Y}/../x", "C:/x"] {
|
||||
let r = s.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": bad}),
|
||||
);
|
||||
assert!(r.is_err(), "{bad} should be refused");
|
||||
}
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
|
||||
for d in [&src, &dest] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto import: files in the watched folder are added once their size held between two scans,
|
||||
/// into the named album; non-photos are tried once; the selection stays put.
|
||||
#[test]
|
||||
@@ -585,3 +616,31 @@ fn smart_previews_folder_is_chosen_per_library() {
|
||||
assert_eq!(r["custom"], false, "{r}");
|
||||
assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
/// Missing Photos covers library photos only: a small library next to many Local browse
|
||||
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
|
||||
#[test]
|
||||
fn missing_photos_skip_local_browse_records() {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..205u64 {
|
||||
let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string();
|
||||
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
p.local = i >= 5; // 5 library photos, 200 seen while browsing
|
||||
p.deleted = i == 4;
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
s.commit("Add", Op::Batch { ops }).unwrap();
|
||||
let mut checked = 0;
|
||||
let lost = crate::cmd::missing::missing_with(&s.catalog, |_| {
|
||||
checked += 1;
|
||||
false
|
||||
});
|
||||
assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked");
|
||||
assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files");
|
||||
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4));
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
|
||||
fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
let dir = temp_dir("hdr");
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, b).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
@@ -71,7 +71,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
|
||||
fn panorama_command_with_auto_crop() {
|
||||
let dir = temp_dir("pano");
|
||||
let mut paths = Vec::new();
|
||||
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() {
|
||||
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
|
||||
let img = v.to_srgb8();
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let p = dir.join(format!("P_{i}.png"));
|
||||
|
||||
@@ -446,6 +446,58 @@ fn dng_export_compression_choices() {
|
||||
assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}");
|
||||
}
|
||||
|
||||
fn write_jpeg(path: &Path, capture: Option<&str>) {
|
||||
let (w, h) = (32usize, 24usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
||||
let exif = capture.map(|c| {
|
||||
lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() })
|
||||
});
|
||||
let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
const DATED_SIDECAR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif="http://ns.adobe.com/exif/1.0/"
|
||||
exif:DateTimeOriginal="2026-01-14T05:58:48" />
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn sidecar_capture_time_fills_in_when_the_file_has_none() {
|
||||
let src = temp_dir("sidecar-date");
|
||||
write_jpeg(&src.join("sample.jpg"), None);
|
||||
std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
// the embedded capture time wins over the sidecar's
|
||||
write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09"));
|
||||
std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap();
|
||||
let lib = temp_dir("sidecar-date-lib");
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// copy: the sidecar's date also files the copy
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
|
||||
assert_eq!(r["sidecars"], 2, "{r}");
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone();
|
||||
let sample = by_name(&s, "sample.jpg");
|
||||
assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") };
|
||||
assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}");
|
||||
assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09"));
|
||||
|
||||
// Read Metadata from File fills in a missing capture time too
|
||||
let id = sample.id;
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();
|
||||
std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48"));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Copyright status, rights usage terms and copyright info URL: edited with `photo.setMeta` (one
|
||||
/// undo step), copied / pasted, kept in metadata presets, written to the sidecar as XMP Rights
|
||||
/// Management fields and read back on a fresh import.
|
||||
|
||||
Reference in New Issue
Block a user