Merge PR #604
# Conflicts: # crates/engine/src/sidecar.rs # crates/ui-egui/locales/de.json # crates/ui-egui/locales/es.json # crates/ui-egui/locales/ja.json # crates/ui-egui/locales/pt-br.json # crates/ui-egui/locales/ru.json # crates/ui-egui/locales/zh-hans.json # crates/ui-egui/locales/zh-hant.json # crates/ui-egui/src/panels/dialogs.rs # docs/whats-new.md
This commit is contained in:
commit
2f35949678
27 files changed
+2363
-19
No files matched your search
@@ -1454,7 +1454,7 @@ fn merge_rules(
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -146,6 +147,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Synchronize Folder commands (see [`crate::sync`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::sync::{SyncChoice, scan, synchronize};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn path(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`"))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "folder.scanChanges",
|
||||
"Scan Folder for Changes",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.scanChanges";
|
||||
let path = path(p, C)?;
|
||||
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
|
||||
let v = serde_json::to_value(&changes).unwrap_or_default();
|
||||
s.folder_changes = Some(changes);
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.synchronize",
|
||||
"Synchronize Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
|
||||
always,
|
||||
sync
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.synchronize";
|
||||
let path = path(p, C)?;
|
||||
let d = SyncChoice::default();
|
||||
let choice = SyncChoice {
|
||||
import_new: bool_or(p, "importNew", d.import_new),
|
||||
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
|
||||
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
|
||||
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
|
||||
};
|
||||
let changes = match s.take_folder_changes(&path) {
|
||||
Some(c) => c,
|
||||
// the person agreed to what a scan showed: never act on something else
|
||||
None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))),
|
||||
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
let r = synchronize(s, changes, choice)?;
|
||||
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
|
||||
}
|
||||
@@ -850,8 +850,17 @@ impl ImportJob {
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read),
|
||||
// as long as they are still there, as they were: a file gone or changed since is probed again
|
||||
let cached: Vec<Option<ProbeInfo>> = todo
|
||||
.iter()
|
||||
.map(|f| {
|
||||
// (the web's files are not std::fs files: kept as probed)
|
||||
self.cache
|
||||
.remove(f)
|
||||
.filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size))
|
||||
})
|
||||
.collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
@@ -1045,7 +1054,7 @@ fn name_family(name: &str) -> String {
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
pub(crate) fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod smart;
|
||||
pub mod sync;
|
||||
mod view;
|
||||
pub mod walk;
|
||||
|
||||
@@ -166,6 +167,9 @@ pub struct Session {
|
||||
/// Photos in the current source with no filter on (cached like `visible`).
|
||||
total: Option<((u64, String), usize)>,
|
||||
pub undo: Vec<UndoEntry>,
|
||||
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
|
||||
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
|
||||
commits: u64,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
@@ -252,6 +256,9 @@ pub struct Session {
|
||||
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
|
||||
/// File probes from the last import review (`library.importPreview`), reused by the import.
|
||||
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
|
||||
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
|
||||
/// (see [`Session::take_folder_changes`]).
|
||||
pub folder_changes: Option<sync::FolderChanges>,
|
||||
/// The last (or running) Build Previews.
|
||||
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
|
||||
/// Develop defaults applied on import (persisted in prefs.json).
|
||||
@@ -290,6 +297,7 @@ impl Session {
|
||||
visible_gen: 0,
|
||||
total: None,
|
||||
undo: Vec::new(),
|
||||
commits: 0,
|
||||
redo: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
@@ -334,6 +342,7 @@ impl Session {
|
||||
recent_keywords: Vec::new(),
|
||||
before: Default::default(),
|
||||
import_probes: Default::default(),
|
||||
folder_changes: None,
|
||||
preview_build: None,
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
@@ -425,6 +434,7 @@ impl Session {
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
self.commits = self.commits.wrapping_add(1);
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -489,6 +499,12 @@ impl Session {
|
||||
|
||||
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
|
||||
/// depends on the state the previous one left).
|
||||
/// How many undo steps were committed in this session so far: what a command that commits
|
||||
/// several steps compares before and after to merge them ([`Session::merge_undo`]).
|
||||
pub fn commits(&self) -> u64 {
|
||||
self.commits
|
||||
}
|
||||
|
||||
pub fn merge_undo(&mut self, n: usize, label: &str) {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
@@ -885,4 +901,6 @@ mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
@@ -448,6 +448,18 @@ impl Session {
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
/// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
|
||||
/// (per photo that is a pass over the whole catalog).
|
||||
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
|
||||
ids.iter()
|
||||
.map(|id| match (self.catalog.photo(*id), &owners) {
|
||||
(Some(p), Some(o)) => o.naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
@@ -478,9 +490,15 @@ impl Session {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
|
||||
Ok(Some((self.sidecar_op(id, sc)?, from)))
|
||||
}
|
||||
|
||||
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
|
||||
/// thread) to photo `id`: the sidecar wins. No file is read.
|
||||
pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result<Op> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let sc = sc.resolve_label(&self.catalog);
|
||||
let mut q = (**p).clone();
|
||||
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
|
||||
let mut ops = vec compares the folder (and the folders inside it) with the library and finds
|
||||
//! three kinds of change, touching nothing:
|
||||
//!
|
||||
//! - **new** files: supported files the library doesn't have, by path or by content (the import
|
||||
//! scan's duplicate rules, [`crate::import::scan_with`]);
|
||||
//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule,
|
||||
//! [`crate::cmd::missing::checked_path`]);
|
||||
//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the
|
||||
//! library or was last edited here, and says something the library doesn't (rating, flag,
|
||||
//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as
|
||||
//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at.
|
||||
//!
|
||||
//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the
|
||||
//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as
|
||||
//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is
|
||||
//! read on a worker thread and only the catalog changes happen on the UI thread — with what every
|
||||
//! command does after them there: the library's durable save and, with XMP auto-write on, the
|
||||
//! sidecars of the photos whose metadata was read.
|
||||
//!
|
||||
//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a
|
||||
//! worker thread: a folder on a sleeping network share must never stall a frame.
|
||||
//!
|
||||
//! Limits of telling an update from a sidecar that is merely older:
|
||||
//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update;
|
||||
//! - the library records when a photo came in and when its develop settings last changed, not
|
||||
//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such
|
||||
//! a change made here (with auto-write off), is listed as an update, and reading it puts the
|
||||
//! older value back. Reading updates is therefore never the default;
|
||||
//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start
|
||||
//! with a fixed clock, so their own times say little;
|
||||
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
|
||||
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
|
||||
//!
|
||||
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
|
||||
//! not cancelled midway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress};
|
||||
use crate::media::ProbeInfo;
|
||||
use crate::sidecar::SidecarNaming;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
|
||||
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
|
||||
/// whole scan) and the library's photos checked.
|
||||
#[derive(Default)]
|
||||
pub struct SyncProgress {
|
||||
pub files: ScanProgress,
|
||||
pub checked: std::sync::atomic::AtomicUsize,
|
||||
pub to_check: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl SyncProgress {
|
||||
/// (done, of how many), both halves together.
|
||||
pub fn counts(&self) -> (usize, usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
|
||||
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
|
||||
pub struct SyncInput {
|
||||
folder: String,
|
||||
scan: ScanInput,
|
||||
/// The library's photos in the folder, each with how its sidecar is named.
|
||||
photos: Vec<(Arc<Photo>, SidecarNaming)>,
|
||||
/// For the colour label names a sidecar may use.
|
||||
labels: Catalog,
|
||||
}
|
||||
|
||||
/// What changed in a folder since it came into the library.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct FolderChanges {
|
||||
/// The folder, as asked for.
|
||||
pub path: String,
|
||||
/// Files to import.
|
||||
pub new: Vec<ImportCandidate>,
|
||||
/// Files whose content the library already has (under another path).
|
||||
pub duplicates: usize,
|
||||
/// Supported files that couldn't be read.
|
||||
pub unreadable: Vec<ImportCandidate>,
|
||||
/// Photos whose file is gone.
|
||||
pub missing: Vec<FolderPhoto>,
|
||||
/// Photos whose sidecar has news.
|
||||
pub metadata: Vec<FolderPhoto>,
|
||||
/// Photos whose file was renamed or moved within the folder (found by its content).
|
||||
pub moved: Vec<FolderMoved>,
|
||||
/// The folder itself is not there (moved or renamed whole, or on a disk that isn't
|
||||
/// connected): nothing else is reported, rather than every photo as missing.
|
||||
pub offline: bool,
|
||||
/// The probes of the new files, kept for the import that follows.
|
||||
#[serde(skip)]
|
||||
probes: HashMap<String, ProbeInfo>,
|
||||
/// The folder's photos as the scan saw them: the scan is current while they are the same
|
||||
/// records (any change to one replaces it in the catalog).
|
||||
#[serde(skip)]
|
||||
seen: Vec<Arc<Photo>>,
|
||||
}
|
||||
|
||||
/// A photo of the folder and its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderPhoto {
|
||||
pub id: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A photo whose file is now somewhere else in the folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct FolderMoved {
|
||||
pub id: u64,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// What [`synchronize`] does with the changes found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SyncChoice {
|
||||
pub import_new: bool,
|
||||
pub relink_moved: bool,
|
||||
pub remove_missing: bool,
|
||||
pub read_metadata: bool,
|
||||
}
|
||||
|
||||
impl Default for SyncChoice {
|
||||
/// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and
|
||||
/// photos whose file moved follow it (nothing is lost, and undo puts them back).
|
||||
fn default() -> Self {
|
||||
SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`synchronize`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct SyncReport {
|
||||
pub imported: usize,
|
||||
pub relinked: usize,
|
||||
pub removed: usize,
|
||||
pub read: usize,
|
||||
/// (path or photo, why) for what couldn't be done.
|
||||
pub failed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn invalid(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
|
||||
impl SyncInput {
|
||||
/// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A
|
||||
/// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk`
|
||||
/// (as for Remove Disk from Library: its missing photos can be a whole disk's).
|
||||
pub fn new(s: &mut Session, path: &str, disk: bool) -> Result<SyncInput> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(invalid("missing `path`"));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(invalid("choose a folder, not the whole startup disk"));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(invalid(format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
|
||||
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
|
||||
}
|
||||
let namings = s.sidecar_namings(&ids);
|
||||
let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
|
||||
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
|
||||
Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Find what changed in the folder (see the module docs). Reads the disk, never the session.
|
||||
///
|
||||
/// Both halves wait on the disk (a network share answers each file slowly), so they run at once:
|
||||
/// the look for new files (which probes them on its own threads) beside the checks of the
|
||||
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
|
||||
/// is set.
|
||||
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, photos, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (out, checks) = std::thread::scope(|sc| {
|
||||
let photos = &photos;
|
||||
let labels = &labels;
|
||||
let checks = sc.spawn(move || check_photos(photos, labels, progress));
|
||||
let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files);
|
||||
// (a check that panicked counts as nothing found; the scan still reports the rest)
|
||||
(out, checks.join().unwrap_or_default())
|
||||
});
|
||||
let mut changes = FolderChanges { path: folder, seen, ..Default::default() };
|
||||
for ((p, _), check) in photos.iter().zip(checks) {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { continue };
|
||||
let found = FolderPhoto { id: p.id.0, path: file.to_string() };
|
||||
match check {
|
||||
Check::Missing => changes.missing.push(found),
|
||||
Check::News => changes.metadata.push(found),
|
||||
Check::Nothing => {}
|
||||
}
|
||||
}
|
||||
let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels);
|
||||
for c in out.candidates {
|
||||
if moved_from.values().any(|to| *to == c.path) {
|
||||
continue;
|
||||
}
|
||||
let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted);
|
||||
match (c.duplicate.as_deref(), &c.error) {
|
||||
// a file only looked at in Local is not in the library yet: it is new
|
||||
(Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }),
|
||||
(Some("path"), _) => {}
|
||||
(Some(_), _) => changes.duplicates += 1,
|
||||
(None, Some(_)) => changes.unreadable.push(c),
|
||||
(None, None) => changes.new.push(c),
|
||||
}
|
||||
}
|
||||
let missing = std::mem::take(&mut changes.missing);
|
||||
for m in missing {
|
||||
match moved_from.get(&m.path) {
|
||||
Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }),
|
||||
None => changes.missing.push(m),
|
||||
}
|
||||
}
|
||||
changes.probes = out.probes;
|
||||
changes
|
||||
}
|
||||
|
||||
/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library
|
||||
/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile
|
||||
/// don't make a scan stale.
|
||||
fn same_for_sync(a: &Arc<Photo>, b: &Arc<Photo>) -> bool {
|
||||
if Arc::ptr_eq(a, b) {
|
||||
return true;
|
||||
}
|
||||
let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() };
|
||||
(&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop)
|
||||
== (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop)
|
||||
&& meta(a) == meta(b)
|
||||
}
|
||||
|
||||
/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup<id>`.
|
||||
fn base_hash(h: &str) -> &str {
|
||||
h.split(':').next().unwrap_or(h)
|
||||
}
|
||||
|
||||
/// Missing photos' files found elsewhere in the folder (old path → new path): the file was
|
||||
/// renamed or moved, and the photo (with its edits) follows it rather than going missing.
|
||||
///
|
||||
/// By content first: each missing file's hash claims one file with that content (two missing
|
||||
/// files with the same content claim two). A photo with no hash on record (taken over from a
|
||||
/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing
|
||||
/// file and exactly one unclaimed file of the folder agree on them.
|
||||
fn match_moved(
|
||||
missing: &[FolderPhoto],
|
||||
candidates: &[ImportCandidate],
|
||||
probes: &HashMap<String, ProbeInfo>,
|
||||
labels: &Catalog,
|
||||
) -> HashMap<String, String> {
|
||||
// distinct missing files (a photo's virtual copies share its file), with what is known of them
|
||||
let mut files: Vec<(&str, Option<&Photo>)> = Vec::new();
|
||||
for m in missing {
|
||||
if !files.iter().any(|(p, _)| *p == m.path) {
|
||||
files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p)));
|
||||
}
|
||||
}
|
||||
let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) {
|
||||
by_hash.entry(base_hash(h)).or_default().push_back(path);
|
||||
}
|
||||
}
|
||||
let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path");
|
||||
let mut moved: HashMap<String, String> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable) {
|
||||
let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref());
|
||||
if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) {
|
||||
moved.insert(from.to_string(), c.path.clone());
|
||||
}
|
||||
}
|
||||
let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h));
|
||||
let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for (path, p) in &files {
|
||||
if let Some(p) = p.filter(|p| p.content_hash.is_none())
|
||||
&& let Some(k) = key(p.file_size, p.width, p.height)
|
||||
{
|
||||
hashless.entry(k).or_default().push(path);
|
||||
}
|
||||
}
|
||||
let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) {
|
||||
if let Some(k) = key(c.file_size, c.width, c.height) {
|
||||
found.entry(k).or_default().push(c.path.as_str());
|
||||
}
|
||||
}
|
||||
for (k, from) in hashless {
|
||||
if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) {
|
||||
moved.insert(from.to_string(), to.to_string());
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// What checking one photo of the folder found.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Check {
|
||||
#[default]
|
||||
Nothing,
|
||||
Missing,
|
||||
News,
|
||||
}
|
||||
|
||||
fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check {
|
||||
let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing };
|
||||
if !Path::new(file).exists() {
|
||||
Check::Missing
|
||||
} else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) {
|
||||
Check::News
|
||||
} else {
|
||||
Check::Nothing
|
||||
}
|
||||
}
|
||||
|
||||
/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the
|
||||
/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found.
|
||||
fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.to_check.store(photos.len(), Relaxed);
|
||||
let cancel = &progress.files.cancel;
|
||||
let check = |p: &Photo, naming: SidecarNaming| {
|
||||
let c = check_photo(p, naming, labels);
|
||||
progress.checked.fetch_add(1, Relaxed);
|
||||
c
|
||||
};
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return vec![Check::Nothing; photos.len()];
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = crate::import::workers(photos.len());
|
||||
if n > 1 {
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]);
|
||||
std::thread::scope(|sc| {
|
||||
for _ in 0..n {
|
||||
sc.spawn(|| {
|
||||
while !cancel.load(Relaxed) {
|
||||
let i = next.fetch_add(1, Relaxed);
|
||||
let Some((p, naming)) = photos.get(i) else { break };
|
||||
let c = check(p, *naming);
|
||||
if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect()
|
||||
}
|
||||
|
||||
/// [`scan_with`] on the calling thread.
|
||||
pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result<FolderChanges> {
|
||||
let input = SyncInput::new(s, path, disk)?;
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
fn not_there(path: &str) -> EngineError {
|
||||
invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)"))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current:
|
||||
/// the folder holds the same photos, none of them changed. What the person was shown is what
|
||||
/// gets done. Used once. (Changes elsewhere in the library don't matter.)
|
||||
pub fn take_folder_changes(&mut self, path: &str) -> Option<FolderChanges> {
|
||||
let c = self.folder_changes.take()?;
|
||||
if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) {
|
||||
return None;
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let mut now: Vec<PhotoId> = self.catalog.query(&f, &Sort::default());
|
||||
let mut then: Vec<PhotoId> = c.seen.iter().map(|p| p.id).collect();
|
||||
now.sort();
|
||||
then.sort();
|
||||
let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q)));
|
||||
unchanged.then_some(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo
|
||||
/// (imported, or edited here) and says something the library doesn't.
|
||||
fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool {
|
||||
let Source::File { path } = &p.source else { return false };
|
||||
let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false };
|
||||
let Ok(meta) = std::fs::metadata(&file) else { return false };
|
||||
if meta.len() > MAX_SIDECAR_BYTES {
|
||||
return false;
|
||||
}
|
||||
let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false };
|
||||
let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max();
|
||||
if seen.is_some_and(|t| saved <= t) {
|
||||
return false;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&file) else { return false };
|
||||
let Ok(sc) = crate::sidecar::parse_sidecar(&text, p.kind == lightcraft_catalog::MediaKind::Raw) else { return false };
|
||||
let sc = sc.resolve_label(labels);
|
||||
let mut q = p.clone();
|
||||
crate::sidecar::merge_into(&mut q, &sc, "");
|
||||
(q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop)
|
||||
}
|
||||
|
||||
/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo
|
||||
/// whose file is back by now is left alone. Once the import is in, a part that fails is
|
||||
/// reported in `failed` and the rest is still done (and still one step).
|
||||
pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<SyncReport> {
|
||||
let (work, mut commit) = SyncJob::start(s, changes, choice)?;
|
||||
let mut steps = Vec::new();
|
||||
work.run(&SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
Ok(commit.finish(s))
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what
|
||||
/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the
|
||||
/// import's probes, whether files are still where the scan found them, the sidecars) and hands
|
||||
/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the
|
||||
/// catalog and [`SyncCommit::finish`] makes them one undo step.
|
||||
pub struct SyncJob;
|
||||
|
||||
/// The file-system half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncWork {
|
||||
/// The import of the new files, and the files.
|
||||
import: Option<(crate::import::ImportJob, Vec<String>)>,
|
||||
moved: Vec<FolderMoved>,
|
||||
missing: Vec<FolderPhoto>,
|
||||
/// (photo, its file, how its sidecar is named)
|
||||
sidecars: Vec<(PhotoId, String, SidecarNaming, bool)>,
|
||||
}
|
||||
|
||||
/// The catalog half of a synchronize (see [`SyncJob`]).
|
||||
pub struct SyncCommit {
|
||||
opts: ImportOptions,
|
||||
now: String,
|
||||
/// The library the run was readied for: steps never land in another one (File › Open
|
||||
/// Library while the work runs), whose photo ids name other photos.
|
||||
library: Arc<()>,
|
||||
/// The run's own undo steps so far, and the history's length after the last of them: they
|
||||
/// are merged only while nothing else came in between (an Undo or an edit made meanwhile
|
||||
/// stays its own step, and the run's parts stay theirs).
|
||||
own: usize,
|
||||
after_last: Option<usize>,
|
||||
interleaved: bool,
|
||||
report: SyncReport,
|
||||
}
|
||||
|
||||
/// A piece of work done, for [`SyncCommit::apply`].
|
||||
pub enum SyncStep {
|
||||
/// A batch of new files, readied for the catalog.
|
||||
Import(crate::import::Prepared),
|
||||
/// Moved photos whose file is still at its new place (photo, new path).
|
||||
Relink(Vec<(PhotoId, String)>),
|
||||
/// Missing photos whose file is still gone.
|
||||
Remove(Vec<PhotoId>),
|
||||
/// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read).
|
||||
Sidecars(Vec<(PhotoId, String, std::result::Result<crate::sidecar::SidecarData, String>)>),
|
||||
}
|
||||
|
||||
impl SyncJob {
|
||||
/// Ready a synchronize of `changes` as `choice` says. Reads no file.
|
||||
pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> {
|
||||
if changes.offline {
|
||||
return Err(not_there(&changes.path));
|
||||
}
|
||||
let FolderChanges { new, missing, metadata, moved, probes, .. } = changes;
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() };
|
||||
let import = if choice.import_new && !new.is_empty() {
|
||||
s.import_probes = probes;
|
||||
let job = crate::import::ImportJob::new(s, opts.clone())?;
|
||||
Some((job, new.into_iter().map(|c| c.path).collect()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string());
|
||||
let sidecars = if choice.read_metadata {
|
||||
let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::<Vec<_>>());
|
||||
metadata
|
||||
.into_iter()
|
||||
.zip(namings)
|
||||
.filter_map(|(m, naming)| {
|
||||
let p = s.catalog.photo(PhotoId(m.id))?;
|
||||
Some((p.id, m.path, naming, p.kind == lightcraft_catalog::MediaKind::Raw))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let work = SyncWork {
|
||||
import,
|
||||
moved: if choice.relink_moved { moved } else { Vec::new() },
|
||||
missing: if choice.remove_missing { missing } else { Vec::new() },
|
||||
sidecars,
|
||||
};
|
||||
let commit = SyncCommit {
|
||||
opts,
|
||||
now,
|
||||
library: Arc::clone(&s.library_identity),
|
||||
own: 0,
|
||||
after_last: None,
|
||||
interleaved: false,
|
||||
report: SyncReport::default(),
|
||||
};
|
||||
Ok((work, commit))
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncWork {
|
||||
/// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop).
|
||||
/// Stops before the next piece once `progress.files.cancel` is set; what was handed over
|
||||
/// stays handed over.
|
||||
pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let cancel = &progress.files.cancel;
|
||||
let SyncWork { import, moved, missing, sidecars } = self;
|
||||
let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len();
|
||||
progress.to_check.store(pieces, Relaxed);
|
||||
progress.checked.store(0, Relaxed);
|
||||
let step = |n: usize| {
|
||||
progress.checked.fetch_add(n, Relaxed);
|
||||
};
|
||||
if let Some((mut job, files)) = import {
|
||||
for chunk in files.chunks(crate::import::batch_size()) {
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let prepared = job.prepare_files(chunk.to_vec(), cancel);
|
||||
step(chunk.len());
|
||||
// (the files are added in place: a batch nobody takes leaves nothing behind on disk)
|
||||
if !send(SyncStep::Import(prepared)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
// still where the scan found it, and still gone from where the photo says
|
||||
let checked = moved.len();
|
||||
let relink: Vec<(PhotoId, String)> =
|
||||
moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect();
|
||||
step(checked);
|
||||
if !relink.is_empty() && !send(SyncStep::Relink(relink)) {
|
||||
return;
|
||||
}
|
||||
if cancel.load(Relaxed) {
|
||||
return;
|
||||
}
|
||||
let gone: Vec<PhotoId> = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect();
|
||||
step(missing.len());
|
||||
if !gone.is_empty() && !send(SyncStep::Remove(gone)) {
|
||||
return;
|
||||
}
|
||||
let mut read = Vec::with_capacity(sidecars.len());
|
||||
for (id, path, naming, raw) in sidecars {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
read.push((id, path.clone(), read_sidecar(&path, naming, raw)));
|
||||
step(1);
|
||||
}
|
||||
if !read.is_empty() {
|
||||
send(SyncStep::Sidecars(read));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse photo file `path`'s XMP sidecar file.
|
||||
fn read_sidecar(path: &str, naming: SidecarNaming, raw: bool) -> std::result::Result<crate::sidecar::SidecarData, String> {
|
||||
let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?;
|
||||
let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len();
|
||||
if len > MAX_SIDECAR_BYTES {
|
||||
return Err(format!("{}: too large for an XMP sidecar", file.display()));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
crate::sidecar::parse_sidecar(&text, raw).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
impl SyncCommit {
|
||||
/// Put a step into the catalog (no file is touched). A part that fails is reported in the
|
||||
/// end, and the rest is still done.
|
||||
pub fn apply(&mut self, s: &mut Session, step: SyncStep) {
|
||||
if !Arc::ptr_eq(&self.library, &s.library_identity) {
|
||||
self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into()));
|
||||
return;
|
||||
}
|
||||
let commits = s.commits();
|
||||
if self.after_last.is_some_and(|n| n != s.undo.len()) {
|
||||
self.interleaved = true;
|
||||
}
|
||||
self.apply_step(s, step);
|
||||
if s.commits() != commits {
|
||||
self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0);
|
||||
self.after_last = Some(s.undo.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_step(&mut self, s: &mut Session, step: SyncStep) {
|
||||
let report = &mut self.report;
|
||||
match step {
|
||||
SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) {
|
||||
Ok(r) => {
|
||||
report.imported += r.imported.len();
|
||||
report.failed.extend(r.failed);
|
||||
}
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
},
|
||||
SyncStep::Relink(moved) => {
|
||||
let ops: Vec<Op> =
|
||||
moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Relink Moved Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.relinked += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Remove(gone) => {
|
||||
let ops: Vec<Op> = gone
|
||||
.iter()
|
||||
.filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library()))
|
||||
.map(|id| Op::SetDeleted { id: *id, deleted: true })
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Remove Missing Photos", Op::Batch { ops }) {
|
||||
Ok(()) => report.removed += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStep::Sidecars(read) => {
|
||||
let mut ops = Vec::new();
|
||||
for (id, path, sc) in read {
|
||||
match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) {
|
||||
Ok(op) => ops.push(op),
|
||||
Err(e) => report.failed.push((path, e.to_string())),
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
match s.commit("Read Metadata from File", Op::Batch { ops }) {
|
||||
Ok(()) => report.read += n,
|
||||
Err(e) => report.failed.push((String::new(), e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What was done, as one undo step ("Synchronize Folder").
|
||||
pub fn finish(mut self, s: &mut Session) -> SyncReport {
|
||||
// a report line once, however many steps came after a library switch
|
||||
self.report.failed.dedup();
|
||||
let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len());
|
||||
let steps = if ours { self.own.min(s.undo.len()) } else { 0 };
|
||||
s.merge_undo(steps, "Synchronize Folder");
|
||||
if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) {
|
||||
last.label = "Synchronize Folder".into();
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
@@ -1124,3 +1124,17 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
|
||||
assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30)));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_gone_since_the_import_review_is_not_imported() {
|
||||
let src = temp_dir("gone-since-review");
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
std::fs::remove_file(src.join("b.png")).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}");
|
||||
assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Synchronize Folder: bring a library folder up to date with what is on disk (see
|
||||
//! [`crate::sync`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
||||
//!
|
||||
//! * Given a folder of the library, when files were added to it (or to a folder inside it), the
|
||||
//! scan lists them as new; files the library already has, by path or by content, are not new.
|
||||
//! * When a photo's file was deleted or moved away, the scan lists the photo as missing.
|
||||
//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the
|
||||
//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older
|
||||
//! than that, or one that agrees with the library, is not an update.
|
||||
//! * Scanning changes nothing.
|
||||
//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and
|
||||
//! reads metadata updates only when asked. Whatever it does is one undo step.
|
||||
//! * Only a folder of the library can be synchronized, never the whole startup disk.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small procedural PNG (distinct per seed).
|
||||
fn write_png(path: &str, seed: u8) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) 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::new(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn sidecar(rating: u8) -> String {
|
||||
format!(
|
||||
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:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/>
|
||||
</rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write `a.xmp` beside `a.png`, dated `age` before now.
|
||||
fn write_sidecar(png: &str, rating: u8, age: Duration) {
|
||||
let xmp = Path::new(png).with_extension("xmp");
|
||||
std::fs::write(&xmp, sidecar(rating)).unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&xmp).unwrap();
|
||||
f.set_modified(SystemTime::now() - age).unwrap();
|
||||
}
|
||||
|
||||
/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time.
|
||||
fn library(dir: &Scratch) -> Session {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/day1/b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2);
|
||||
s
|
||||
}
|
||||
|
||||
fn paths(v: &Value, key: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// What the library holds: each photo's file, whether it is in the library, and its rating.
|
||||
fn holdings(s: &Session) -> Vec<(String, bool, u8)> {
|
||||
let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn scan(s: &mut Session, path: &str) -> Value {
|
||||
s.execute("folder.scanChanges", &json!({"path": path})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_folder_has_no_changes() {
|
||||
let dir = Scratch::new("none");
|
||||
let mut s = library(&dir);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_added_to_the_folder_or_a_folder_inside_it_are_new() {
|
||||
let dir = Scratch::new("new");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
write_png(&dir.path("trip/day2/d.png"), 4);
|
||||
// the same bytes as a photo the library has: not new
|
||||
std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}");
|
||||
assert_eq!(r["duplicates"], 1, "{r}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_whose_file_is_gone_is_missing() {
|
||||
let dir = Scratch::new("missing");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}");
|
||||
assert!(r["missing"][0]["id"].is_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_saved_by_another_app_is_a_metadata_update() {
|
||||
let dir = Scratch::new("meta");
|
||||
let mut s = library(&dir);
|
||||
// saved now: after the photo came in (the session clock says 2026-09-30)
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_sidecar_or_one_that_agrees_is_no_update() {
|
||||
let dir = Scratch::new("meta-old");
|
||||
let mut s = library(&dir);
|
||||
// older than the import: whatever it says, the library has had its say since
|
||||
write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600));
|
||||
// new, but says what the library already knows
|
||||
write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() {
|
||||
let dir = Scratch::new("sync");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays");
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap();
|
||||
assert_eq!(a.rating, 0, "metadata is read only when asked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_everything_is_one_undo_step() {
|
||||
let dir = Scratch::new("sync-all");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let r =
|
||||
s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||||
let library: Vec<String> = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one undo step puts everything back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_no_changes_synchronizes_without_an_undo_step() {
|
||||
let dir = Scratch::new("sync-none");
|
||||
let mut s = library(&dir);
|
||||
let steps = s.undo.len();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||||
assert_eq!(s.undo.len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_of_the_library_is_synchronized() {
|
||||
let dir = Scratch::new("refused");
|
||||
let mut s = library(&dir);
|
||||
std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap();
|
||||
for (cmd, p, why) in [
|
||||
("folder.scanChanges", json!({}), "missing path"),
|
||||
("folder.scanChanges", json!({"path": " "}), "blank path"),
|
||||
("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.scanChanges", json!({"path": "/"}), "the startup disk"),
|
||||
("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"),
|
||||
("folder.synchronize", json!({"path": "/"}), "the startup disk"),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_acts_on_the_changes_just_scanned() {
|
||||
let dir = Scratch::new("cached");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new").len(), 1);
|
||||
// arrived after the scan the person was shown: not part of what they agreed to
|
||||
write_png(&dir.path("trip/d.png"), 4);
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
// the scan is used once; the next synchronize looks again
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "d.png now: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_is_stale_once_the_library_changed() {
|
||||
let dir = Scratch::new("stale");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// c.png comes in some other way between the scan and the synchronize
|
||||
s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_scan_stops_checking_files() {
|
||||
let dir = Scratch::new("cancel");
|
||||
let mut s = library(&dir);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let c = crate::sync::scan_with(input, &progress);
|
||||
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
|
||||
let dir = Scratch::new("many");
|
||||
for i in 0..40u8 {
|
||||
write_png(&dir.path(&format!("big/{i:02}.png")), i);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap();
|
||||
for i in (0..40u8).step_by(3) {
|
||||
std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap();
|
||||
}
|
||||
for i in (1..40u8).step_by(5) {
|
||||
write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO);
|
||||
}
|
||||
let r = scan(&mut s, &dir.path("big"));
|
||||
let expect_missing: Vec<String> = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
let expect_meta: Vec<String> = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect();
|
||||
assert_eq!(paths(&r, "missing"), expect_missing);
|
||||
assert_eq!(paths(&r, "metadata"), expect_meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() {
|
||||
let dir = Scratch::new("offline");
|
||||
let mut s = library(&dir);
|
||||
// an unplugged disk, or a folder moved away whole
|
||||
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
|
||||
for cmd in ["folder.scanChanges", "folder.synchronize"] {
|
||||
let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("not there"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() {
|
||||
let dir = Scratch::new("disks");
|
||||
let mut s = library(&dir);
|
||||
// a library photo on another disk makes the scratch folder's root look like it holds disks
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() },
|
||||
"x.jpg",
|
||||
"JPEG",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
if std::path::Path::new("/Volumes").is_dir() {
|
||||
let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string();
|
||||
assert!(e.contains("disk: true"), "{e}");
|
||||
}
|
||||
// a folder inside one disk needs nothing more
|
||||
assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_only_browsed_in_local_is_new_and_joins_the_library() {
|
||||
let dir = Scratch::new("local");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
// looking at the folder in Local catalogues c.png as a browse record, not a library photo
|
||||
s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local));
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() {
|
||||
let dir = Scratch::new("moved");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
assert_eq!(r["moved"][0]["id"], a.0, "{r}");
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}");
|
||||
let p = s.catalog.photo(a).unwrap();
|
||||
assert!(p.in_library() && p.rating == 4, "the same photo, edits and all");
|
||||
assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() {
|
||||
let dir = Scratch::new("full-undo");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
// a long session: the history is at its limit and drops its oldest steps
|
||||
for i in 0..1005u32 {
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap();
|
||||
}
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap();
|
||||
assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
let undo = s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(undo["undone"], "Synchronize Folder");
|
||||
assert_eq!(holdings(&s), before, "one step undoes the import and the removal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_stays_current_while_only_other_folders_change() {
|
||||
let dir = Scratch::new("current");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("other/x.png"), 9);
|
||||
s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// something elsewhere in the library changes while the dialog is open (a background import…)
|
||||
let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_to_the_folders_photos_makes_the_scan_stale() {
|
||||
let dir = Scratch::new("stale-folder");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap();
|
||||
// the dialog asks for exactly what it showed: refused, never redone on the spot
|
||||
let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("scan again"), "{e}");
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done");
|
||||
// an agent that just asks to synchronize gets a fresh scan
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_progress_counts_the_photos_checked_too() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let dir = Scratch::new("progress");
|
||||
let mut s = library(&dir);
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
crate::sync::scan_with(input, &progress);
|
||||
assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2));
|
||||
let (done, total) = progress.counts();
|
||||
assert!(done == total && total >= 2, "{done}/{total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sidecar_the_library_wrote_itself_is_no_update() {
|
||||
let dir = Scratch::new("autowrite");
|
||||
let mut s = library(&dir);
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap();
|
||||
s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar");
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert!(paths(&r, "metadata").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() {
|
||||
// as photos taken over from a Lightroom catalog are: no hash on record
|
||||
let dir = Scratch::new("nohash");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut p = (**s.catalog.photo(a).unwrap()).clone();
|
||||
p.content_hash = None;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_duplicates_are_each_relinked() {
|
||||
let dir = Scratch::new("dups");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap();
|
||||
assert!(Path::new(&dir.path("trip/a-copy.png")).exists());
|
||||
std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap();
|
||||
std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap();
|
||||
let r = scan(&mut s, &dir.path("trip"));
|
||||
assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(paths(&r, "missing").is_empty(), "{r}");
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap();
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_regions_found_meanwhile_leave_the_scan_current() {
|
||||
let dir = Scratch::new("faces");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
scan(&mut s, &dir.path("trip"));
|
||||
// background face search adds a region to a photo of the folder
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let mut meta = s.catalog.photo(a).unwrap().meta.clone();
|
||||
meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None });
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap();
|
||||
let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap();
|
||||
assert_eq!(r["imported"], 1, "{r}");
|
||||
}
|
||||
|
||||
/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the
|
||||
/// disk, the work (every file read) on a worker, each step committed back on the UI thread.
|
||||
fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport {
|
||||
let changes = crate::sync::scan(s, path, false).unwrap();
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap();
|
||||
for step in rx {
|
||||
commit.apply(s, step);
|
||||
}
|
||||
commit.finish(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_of_synchronizing_runs_apart_from_its_commits() {
|
||||
let dir = Scratch::new("halves");
|
||||
let mut s = library(&dir);
|
||||
let before = holdings(&s);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO);
|
||||
let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true };
|
||||
let r = sync_in_halves(&mut s, &dir.path("trip"), choice);
|
||||
assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}");
|
||||
assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(holdings(&s), before, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_checks_again_that_a_new_file_is_still_there() {
|
||||
let dir = Scratch::new("ready");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
// the file goes away between readying (UI thread) and the work (worker): the work finds out
|
||||
let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
std::fs::remove_file(dir.path("trip/c.png")).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(r.imported, 0, "{r:?}");
|
||||
assert_eq!(r.failed.len(), 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_synchronize_does_nothing_more() {
|
||||
let dir = Scratch::new("cancel-run");
|
||||
let mut s = library(&dir);
|
||||
let steps_before = s.undo.len();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut sent = 0;
|
||||
work.run(&progress, |_| {
|
||||
sent += 1;
|
||||
true
|
||||
});
|
||||
assert_eq!(sent, 0);
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!((r.imported, s.undo.len()), (0, steps_before));
|
||||
}
|
||||
|
||||
/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to
|
||||
/// commit when it likes.
|
||||
fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec<crate::sync::SyncStep>, crate::sync::SyncCommit) {
|
||||
let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap();
|
||||
let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap();
|
||||
let mut steps = Vec::new();
|
||||
work.run(&crate::sync::SyncProgress::default(), |step| {
|
||||
steps.push(step);
|
||||
true
|
||||
});
|
||||
(steps, commit)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() {
|
||||
let dir = Scratch::new("switched");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
// File › Open Library while the work runs: another catalog, whose photo ids mean other photos
|
||||
s.library_identity = std::sync::Arc::new(());
|
||||
let before = holdings(&s);
|
||||
let undo = s.undo.len();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
let r = commit.finish(&mut s);
|
||||
assert_eq!(holdings(&s), before, "nothing landed in the other library");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!((r.imported, r.removed), (0, 0), "{r:?}");
|
||||
assert!(!r.failed.is_empty(), "and the report says why: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() {
|
||||
let dir = Scratch::new("undo-mid");
|
||||
let mut s = library(&dir);
|
||||
let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap();
|
||||
let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() };
|
||||
let (mut steps, mut commit) = readied(&mut s, &dir, choice);
|
||||
assert!(steps.len() >= 2, "an import step and a remove step");
|
||||
let first = steps.remove(0);
|
||||
commit.apply(&mut s, first);
|
||||
// the person presses Undo between two steps of the run
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for step in steps {
|
||||
commit.apply(&mut s, step);
|
||||
}
|
||||
commit.finish(&mut s);
|
||||
// undoing the run's last step never takes the rating with it
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
|
||||
let dir = Scratch::new("namings");
|
||||
let mut s = library(&dir);
|
||||
// a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp
|
||||
for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) },
|
||||
name,
|
||||
"X",
|
||||
6,
|
||||
4,
|
||||
"2026-01-01",
|
||||
);
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let ids: Vec<lightcraft_catalog::PhotoId> = s.catalog.photos().map(|p| p.id).collect();
|
||||
let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect();
|
||||
assert_eq!(s.sidecar_namings(&ids), one_by_one);
|
||||
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
|
||||
}
|
||||
@@ -1989,5 +1989,32 @@
|
||||
"Export Keywords…": "Stichwörter exportieren…",
|
||||
"Import Keywords": "Stichwörter importieren",
|
||||
"Export Keywords": "Stichwörter exportieren",
|
||||
"Keyword Lists": "Stichwortlisten"
|
||||
"Keyword Lists": "Stichwortlisten",
|
||||
"Synchronize Folder…": "Ordner synchronisieren…",
|
||||
"Synchronize Folder": "Ordner synchronisieren",
|
||||
"Synchronize": "Synchronisieren",
|
||||
"Scanning…": "Wird durchsucht…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:",
|
||||
"Import new photos": "Neue Fotos importieren",
|
||||
"Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen",
|
||||
"Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen",
|
||||
"The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.",
|
||||
"Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind",
|
||||
"Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.",
|
||||
"Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde",
|
||||
"A folder is already being synchronized": "Es wird bereits ein Ordner synchronisiert",
|
||||
"Synchronizing…": "Wird synchronisiert…",
|
||||
"Relinked": "Neu verknüpft",
|
||||
"Removed": "Entfernt",
|
||||
"Metadata read": "Metadaten gelesen",
|
||||
"Failed": "Fehlgeschlagen",
|
||||
"Stopped": "Angehalten",
|
||||
"Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)",
|
||||
"Not saved": "Nicht gespeichert",
|
||||
"A folder is being synchronized": "Ein Ordner wird gerade synchronisiert",
|
||||
"Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist"
|
||||
}
|
||||
@@ -1834,5 +1834,32 @@
|
||||
"Export Keywords…": "Exportar palabras clave…",
|
||||
"Import Keywords": "Importar palabras clave",
|
||||
"Export Keywords": "Exportar palabras clave",
|
||||
"Keyword Lists": "Listas de palabras clave"
|
||||
"Keyword Lists": "Listas de palabras clave",
|
||||
"Synchronize Folder…": "Sincronizar carpeta…",
|
||||
"Synchronize Folder": "Sincronizar carpeta",
|
||||
"Synchronize": "Sincronizar",
|
||||
"Scanning…": "Explorando…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras apps",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "Poner la biblioteca al día con esta carpeta y las carpetas que contiene:",
|
||||
"Import new photos": "Importar fotos nuevas",
|
||||
"Remove missing photos from the library": "Quitar de la biblioteca las fotos que faltan",
|
||||
"Read metadata updates from XMP sidecars": "Leer cambios de metadatos de los archivos XMP complementarios",
|
||||
"The library is up to date with this folder.": "La biblioteca está al día con esta carpeta.",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Las fotos que faltan pasan a Eliminadas recientemente y se pueden restaurar; no se toca ningún archivo del disco.",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.",
|
||||
"Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar",
|
||||
"Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.",
|
||||
"Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió",
|
||||
"A folder is already being synchronized": "Ya se está sincronizando una carpeta",
|
||||
"Synchronizing…": "Sincronizando…",
|
||||
"Relinked": "Revinculadas",
|
||||
"Removed": "Quitadas",
|
||||
"Metadata read": "Metadatos leídos",
|
||||
"Failed": "Con errores",
|
||||
"Stopped": "Detenido",
|
||||
"Stopped by an error (see the log)": "Detenido por un error (ver el registro)",
|
||||
"Not saved": "Sin guardar",
|
||||
"A folder is being synchronized": "Se está sincronizando una carpeta",
|
||||
"Wait for the import to finish": "Espera a que termine la importación"
|
||||
}
|
||||
@@ -1836,5 +1836,32 @@
|
||||
"Export Keywords…": "キーワードを書き出し…",
|
||||
"Import Keywords": "キーワードを読み込み",
|
||||
"Export Keywords": "キーワードを書き出し",
|
||||
"Keyword Lists": "キーワードリスト"
|
||||
"Keyword Lists": "キーワードリスト",
|
||||
"Synchronize Folder…": "フォルダを同期…",
|
||||
"Synchronize Folder": "フォルダを同期",
|
||||
"Synchronize": "同期",
|
||||
"Scanning…": "スキャン中…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ディスク上のこのフォルダに追加された写真や見つからない写真、ほかのアプリで変更された XMP サイドカーを探します",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "このフォルダとその中のフォルダに合わせてライブラリを最新にします:",
|
||||
"Import new photos": "新しい写真を読み込む",
|
||||
"Remove missing photos from the library": "見つからない写真をライブラリから除去",
|
||||
"Read metadata updates from XMP sidecars": "XMP サイドカーからメタデータの更新を読み込む",
|
||||
"The library is up to date with this folder.": "ライブラリはこのフォルダと一致しています。",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "見つからない写真は「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。",
|
||||
"Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル",
|
||||
"Skipped: files that can't be read": "スキップ: 読み込めないファイル",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。",
|
||||
"Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す",
|
||||
"A folder is already being synchronized": "すでにフォルダを同期中です",
|
||||
"Synchronizing…": "同期中…",
|
||||
"Relinked": "リンクし直し",
|
||||
"Removed": "除去",
|
||||
"Metadata read": "メタデータ読み込み",
|
||||
"Failed": "失敗",
|
||||
"Stopped": "停止しました",
|
||||
"Stopped by an error (see the log)": "エラーで停止しました(ログを参照)",
|
||||
"Not saved": "保存されていません",
|
||||
"A folder is being synchronized": "フォルダを同期中です",
|
||||
"Wait for the import to finish": "読み込みが終わるまでお待ちください"
|
||||
}
|
||||
@@ -1834,5 +1834,32 @@
|
||||
"Export Keywords…": "Exportar palavras-chave…",
|
||||
"Import Keywords": "Importar palavras-chave",
|
||||
"Export Keywords": "Exportar palavras-chave",
|
||||
"Keyword Lists": "Listas de palavras-chave"
|
||||
"Keyword Lists": "Listas de palavras-chave",
|
||||
"Synchronize Folder…": "Sincronizar pasta…",
|
||||
"Synchronize Folder": "Sincronizar pasta",
|
||||
"Synchronize": "Sincronizar",
|
||||
"Scanning…": "Verificando…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Encontrar fotos adicionadas a esta pasta no disco ou ausentes dela, e arquivos XMP auxiliares alterados por outros apps",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "Atualizar a biblioteca com esta pasta e as pastas dentro dela:",
|
||||
"Import new photos": "Importar fotos novas",
|
||||
"Remove missing photos from the library": "Remover fotos ausentes da biblioteca",
|
||||
"Read metadata updates from XMP sidecars": "Ler atualizações de metadados dos arquivos XMP auxiliares",
|
||||
"The library is up to date with this folder.": "A biblioteca está atualizada com esta pasta.",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "As fotos ausentes vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.",
|
||||
"Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar",
|
||||
"Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.",
|
||||
"Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido",
|
||||
"A folder is already being synchronized": "Uma pasta já está sendo sincronizada",
|
||||
"Synchronizing…": "Sincronizando…",
|
||||
"Relinked": "Revinculadas",
|
||||
"Removed": "Removidas",
|
||||
"Metadata read": "Metadados lidos",
|
||||
"Failed": "Com falha",
|
||||
"Stopped": "Interrompido",
|
||||
"Stopped by an error (see the log)": "Interrompido por um erro (veja o log)",
|
||||
"Not saved": "Não salvo",
|
||||
"A folder is being synchronized": "Uma pasta está sendo sincronizada",
|
||||
"Wait for the import to finish": "Aguarde a importação terminar"
|
||||
}
|
||||
@@ -1546,5 +1546,33 @@
|
||||
"Export Keywords…": "Экспорт ключевых слов…",
|
||||
"Import Keywords": "Импорт ключевых слов",
|
||||
"Export Keywords": "Экспорт ключевых слов",
|
||||
"Keyword Lists": "Списки ключевых слов"
|
||||
"Keyword Lists": "Списки ключевых слов",
|
||||
"Synchronize Folder…": "Синхронизировать папку…",
|
||||
"Synchronize Folder": "Синхронизация папки",
|
||||
"Synchronize": "Синхронизировать",
|
||||
"Scanning…": "Сканирование…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Найти фото, добавленные в эту папку на диске или пропавшие из неё, и файлы XMP, изменённые другими приложениями",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "Привести медиатеку в соответствие с этой папкой и вложенными папками:",
|
||||
"Import new photos": "Импортировать новые фото",
|
||||
"Remove missing photos from the library": "Убрать отсутствующие фото из медиатеки",
|
||||
"Read metadata updates from XMP sidecars": "Прочитать изменения метаданных из файлов XMP",
|
||||
"The library is up to date with this folder.": "Медиатека соответствует этой папке.",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.",
|
||||
"Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте",
|
||||
"Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.",
|
||||
"Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён",
|
||||
"A folder is already being synchronized": "Папка уже синхронизируется",
|
||||
"Synchronizing…": "Синхронизация…",
|
||||
"Imported": "Импортировано",
|
||||
"Relinked": "Связано заново",
|
||||
"Removed": "Убрано",
|
||||
"Metadata read": "Метаданные прочитаны",
|
||||
"Failed": "Ошибки",
|
||||
"Stopped": "Остановлено",
|
||||
"Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)",
|
||||
"Not saved": "Не сохранено",
|
||||
"A folder is being synchronized": "Идёт синхронизация папки",
|
||||
"Wait for the import to finish": "Дождитесь окончания импорта"
|
||||
}
|
||||
@@ -1834,5 +1834,32 @@
|
||||
"Export Keywords…": "导出关键字…",
|
||||
"Import Keywords": "导入关键字",
|
||||
"Export Keywords": "导出关键字",
|
||||
"Keyword Lists": "关键字列表"
|
||||
"Keyword Lists": "关键字列表",
|
||||
"Synchronize Folder…": "同步文件夹…",
|
||||
"Synchronize Folder": "同步文件夹",
|
||||
"Synchronize": "同步",
|
||||
"Scanning…": "正在扫描…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "查找磁盘上此文件夹中新增或缺失的照片,以及被其他应用更改的 XMP 附属文件",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "让图库与此文件夹及其中的文件夹保持一致:",
|
||||
"Import new photos": "导入新照片",
|
||||
"Remove missing photos from the library": "从图库中移除缺失的照片",
|
||||
"Read metadata updates from XMP sidecars": "从 XMP 附属文件读取元数据更新",
|
||||
"The library is up to date with this folder.": "图库与此文件夹一致。",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "缺失的照片会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "读取附属文件会用其内容替换照片的评级、标签、元数据和编辑。",
|
||||
"Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件",
|
||||
"Skipped: files that can't be read": "已跳过:无法读取的文件",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。",
|
||||
"Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片",
|
||||
"A folder is already being synchronized": "已有文件夹正在同步",
|
||||
"Synchronizing…": "正在同步…",
|
||||
"Relinked": "已重新链接",
|
||||
"Removed": "已移除",
|
||||
"Metadata read": "已读取元数据",
|
||||
"Failed": "失败",
|
||||
"Stopped": "已停止",
|
||||
"Stopped by an error (see the log)": "因错误而停止(见日志)",
|
||||
"Not saved": "未保存",
|
||||
"A folder is being synchronized": "正在同步文件夹",
|
||||
"Wait for the import to finish": "请等待导入完成"
|
||||
}
|
||||
@@ -1834,5 +1834,32 @@
|
||||
"Export Keywords…": "匯出關鍵字…",
|
||||
"Import Keywords": "匯入關鍵字",
|
||||
"Export Keywords": "匯出關鍵字",
|
||||
"Keyword Lists": "關鍵字清單"
|
||||
"Keyword Lists": "關鍵字清單",
|
||||
"Synchronize Folder…": "同步資料夾…",
|
||||
"Synchronize Folder": "同步資料夾",
|
||||
"Synchronize": "同步",
|
||||
"Scanning…": "正在掃描…",
|
||||
"Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "尋找磁碟上此資料夾中新增或遺失的照片,以及被其他 App 更改的 XMP 附屬檔案",
|
||||
"Bring the library up to date with this folder and the folders inside it:": "讓照片庫與此資料夾及其中的資料夾保持一致:",
|
||||
"Import new photos": "匯入新照片",
|
||||
"Remove missing photos from the library": "從照片庫移除遺失的照片",
|
||||
"Read metadata updates from XMP sidecars": "從 XMP 附屬檔案讀取中繼資料更新",
|
||||
"The library is up to date with this folder.": "照片庫與此資料夾一致。",
|
||||
"Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "遺失的照片會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。",
|
||||
"Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "讀取附屬檔案會以其內容取代照片的評等、標籤、中繼資料和編輯。",
|
||||
"Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案",
|
||||
"Skipped: files that can't be read": "已略過:無法讀取的檔案",
|
||||
"This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。",
|
||||
"Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片",
|
||||
"A folder is already being synchronized": "已有資料夾正在同步",
|
||||
"Synchronizing…": "正在同步…",
|
||||
"Relinked": "已重新連結",
|
||||
"Removed": "已移除",
|
||||
"Metadata read": "已讀取中繼資料",
|
||||
"Failed": "失敗",
|
||||
"Stopped": "已停止",
|
||||
"Stopped by an error (see the log)": "因錯誤而停止(請見記錄)",
|
||||
"Not saved": "未儲存",
|
||||
"A folder is being synchronized": "正在同步資料夾",
|
||||
"Wait for the import to finish": "請等待匯入完成"
|
||||
}
|
||||
@@ -322,6 +322,8 @@ impl Headless {
|
||||
|| crate::panels::left::fs_cached_running(&self.view.ctx) > 0
|
||||
|| self.app.merge.busy()
|
||||
|| self.app.scan.is_some()
|
||||
|| self.app.sync.is_some()
|
||||
|| self.app.sync_run.is_some()
|
||||
|| self.app.import.is_some()
|
||||
|| self.app.export.is_some()
|
||||
|| self.app.session.denoise_busy()
|
||||
|
||||
@@ -393,6 +393,12 @@ pub struct ScanTask {
|
||||
sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// An import and a Synchronize Folder never run at once: each readies its files against the
|
||||
/// library as it was when it started, so both could add the same file.
|
||||
pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> {
|
||||
if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
|
||||
pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
||||
if app.scan.is_some() {
|
||||
@@ -565,6 +571,7 @@ impl ScanTask {
|
||||
|
||||
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`).
|
||||
pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> {
|
||||
busy_synchronizing(app)?;
|
||||
let queue = d.selected_paths();
|
||||
if queue.is_empty() {
|
||||
return Err("no photos selected".into());
|
||||
@@ -616,6 +623,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
|
||||
|
||||
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
|
||||
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
||||
busy_synchronizing(app)?;
|
||||
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod softpaint;
|
||||
pub mod state;
|
||||
pub mod sync;
|
||||
pub mod tasks;
|
||||
pub mod text_field;
|
||||
pub mod theme;
|
||||
@@ -80,6 +81,8 @@ mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_switch_library;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
#[cfg(test)]
|
||||
mod tests_titlebar;
|
||||
#[cfg(test)]
|
||||
mod tests_unsaved;
|
||||
@@ -265,6 +268,12 @@ pub struct LightcraftApp {
|
||||
pub import: Option<import::ImportTask>,
|
||||
/// A folder scan in progress (feeds the import review).
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// A Synchronize Folder scan in progress (feeds its dialog).
|
||||
pub sync: Option<sync::SyncTask>,
|
||||
/// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it).
|
||||
pub sync_owns_changes: bool,
|
||||
/// A Synchronize Folder at work in the background.
|
||||
pub sync_run: Option<sync::SyncRun>,
|
||||
/// A Lightroom catalog inspect/import in progress.
|
||||
pub lightroom: Option<lightroom_import::LightroomTask>,
|
||||
/// Last terminal Lightroom result, exposed by the command's status/wait response.
|
||||
@@ -337,6 +346,9 @@ impl LightcraftApp {
|
||||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
scan: None,
|
||||
sync: None,
|
||||
sync_owns_changes: false,
|
||||
sync_run: None,
|
||||
lightroom: None,
|
||||
lightroom_last: None,
|
||||
export: None,
|
||||
@@ -765,6 +777,8 @@ impl LightcraftApp {
|
||||
self.renderer.poll(ctx, &mut self.session);
|
||||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
sync::poll(self, ctx);
|
||||
sync::poll_run(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
lightroom_import::tick(self, ctx);
|
||||
tasks::poll(self, ctx);
|
||||
@@ -789,7 +803,12 @@ impl LightcraftApp {
|
||||
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
|
||||
const LABEL: &str = "Auto Import";
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) {
|
||||
if now - self.ui.auto_import_at >= 3.0
|
||||
&& self.import.is_none()
|
||||
&& self.sync_run.is_none()
|
||||
&& self.lightroom.is_none()
|
||||
&& !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>| {
|
||||
@@ -988,6 +1007,7 @@ impl LightcraftApp {
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
sync::progress_window(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
lightroom_import::progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
|
||||
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
|
||||
Dialog::SynchronizeFolder { .. } => "Synchronize Folder",
|
||||
// (nothing to download from in this build: the dialog explains the manual install)
|
||||
Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model",
|
||||
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
|
||||
@@ -852,6 +853,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
|
||||
}
|
||||
d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d),
|
||||
Dialog::RemoveFolder { name, count, path, disk } => {
|
||||
ui.label(crate::i18n::tr_format!(
|
||||
"Remove “{name}” and its {count} photo{} from the library?",
|
||||
@@ -957,6 +959,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::KeywordTag { editing: None, .. } => "Create",
|
||||
Dialog::KeywordTag { .. } => "Save",
|
||||
Dialog::RemoveFolder { .. } => "Remove",
|
||||
Dialog::SynchronizeFolder { .. } => "Synchronize",
|
||||
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
|
||||
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
|
||||
Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again",
|
||||
@@ -965,8 +968,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
_ => "OK",
|
||||
};
|
||||
// installing waits for the licence to be accepted
|
||||
// (and Synchronize waits for the scan)
|
||||
let can_confirm = informational
|
||||
|| !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. })
|
||||
|| !matches!(
|
||||
&dlg,
|
||||
Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. }
|
||||
)
|
||||
// smart-album rules that can't mean anything wait to be fixed
|
||||
&& !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty());
|
||||
let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok))));
|
||||
@@ -1008,6 +1015,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
| Dialog::SmartRules { .. }
|
||||
| Dialog::KeywordTag { .. }
|
||||
| Dialog::KeywordSet { .. }
|
||||
| Dialog::SynchronizeFolder { .. }
|
||||
) =>
|
||||
{
|
||||
app.toast(ctx, e)
|
||||
@@ -1332,6 +1340,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::Import { opts } => crate::import::start(app, opts),
|
||||
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
|
||||
Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})),
|
||||
d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d),
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,6 +1251,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
} else if n.path != "/" {
|
||||
resp.context_menu(|ui| {
|
||||
folder_label_menu(app, ui, n);
|
||||
synchronize_item(app, ui, n, &n.name);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove Disk from Library…"))
|
||||
@@ -1264,6 +1265,22 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which
|
||||
/// scans the folder in the background.
|
||||
fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) {
|
||||
let r = ui
|
||||
.button(crate::i18n::tr("Synchronize Folder…"))
|
||||
.on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps"));
|
||||
crate::widgets::register(ui.ctx(), "folderSynchronize", r.rect);
|
||||
if r.clicked() {
|
||||
// a disk, or a folder holding disks, was chosen as such
|
||||
if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`,
|
||||
/// its colours `folderLabel:<colour>`).
|
||||
fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) {
|
||||
@@ -1315,6 +1332,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
||||
ui.close();
|
||||
}
|
||||
folder_label_menu(app, ui, n);
|
||||
synchronize_item(app, ui, n, &label);
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove from Library…"))
|
||||
|
||||
@@ -727,10 +727,47 @@ pub enum Dialog {
|
||||
/// A whole disk or share (`library.removeFolder` takes it only on request).
|
||||
disk: bool,
|
||||
},
|
||||
/// Synchronize Folder: what changed in a library folder on disk, and what to do about it
|
||||
/// (`folder.synchronize`; the scan runs in [`crate::sync`]).
|
||||
SynchronizeFolder {
|
||||
path: String,
|
||||
/// What the dialog calls it (a folder's last two names).
|
||||
name: String,
|
||||
/// A whole disk, or a folder holding disks (`folder.synchronize` `disk`).
|
||||
#[serde(default)]
|
||||
disk: bool,
|
||||
/// What the scan found (`None` while it runs).
|
||||
counts: Option<SyncCounts>,
|
||||
import_new: bool,
|
||||
#[serde(default = "yes")]
|
||||
relink_moved: bool,
|
||||
remove_missing: bool,
|
||||
read_metadata: bool,
|
||||
},
|
||||
About,
|
||||
Shortcuts,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How many changes a Synchronize Folder scan found, by kind.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncCounts {
|
||||
/// The folder isn't there (moved, renamed, or on a disk that isn't connected).
|
||||
#[serde(default)]
|
||||
pub offline: bool,
|
||||
pub new: usize,
|
||||
pub duplicates: usize,
|
||||
pub unreadable: usize,
|
||||
pub missing: usize,
|
||||
pub metadata: usize,
|
||||
#[serde(default)]
|
||||
pub moved: usize,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can
|
||||
//! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what
|
||||
//! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly
|
||||
//! the scan the dialog showed (see `lightcraft_engine::sync`).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::state::{Dialog, SyncCounts};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// A running scan of the dialog's folder.
|
||||
pub struct SyncTask {
|
||||
path: String,
|
||||
progress: Arc<SyncProgress>,
|
||||
rx: std::sync::mpsc::Receiver<FolderChanges>,
|
||||
}
|
||||
|
||||
/// Open the dialog for folder `path` (called `name` in it) and start scanning.
|
||||
pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> {
|
||||
cancel(app);
|
||||
let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?;
|
||||
let progress = Arc::new(SyncProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = progress.clone();
|
||||
let job = move || {
|
||||
let _ = tx.send(scan_with(input, &p));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.sync = Some(SyncTask { path: path.to_string(), progress, rx });
|
||||
let d = SyncChoice::default();
|
||||
app.ui.dialog = Some(Dialog::SynchronizeFolder {
|
||||
path: path.to_string(),
|
||||
name: name.to_string(),
|
||||
disk,
|
||||
counts: None,
|
||||
import_new: d.import_new,
|
||||
relink_moved: d.relink_moved,
|
||||
remove_missing: d.remove_missing,
|
||||
read_metadata: d.read_metadata,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop a running scan and let go of a finished one the dialog made (one an agent made with
|
||||
/// `folder.scanChanges` is the agent's).
|
||||
fn cancel(app: &mut LightcraftApp) {
|
||||
if let Some(t) = app.sync.take() {
|
||||
t.progress.files.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if std::mem::take(&mut app.sync_owns_changes) {
|
||||
app.session.folder_changes = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect a finished scan into the dialog (called every frame). A scan whose dialog was closed
|
||||
/// is cancelled.
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let open_for = match &app.ui.dialog {
|
||||
Some(Dialog::SynchronizeFolder { path, .. }) => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let Some(open_for) = open_for else {
|
||||
if app.sync.is_some() || app.sync_owns_changes {
|
||||
cancel(app);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(task) = app.sync.as_ref() else { return };
|
||||
if task.path != open_for {
|
||||
cancel(app);
|
||||
return;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let changes = match task.rx.try_recv() {
|
||||
Ok(c) => c,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(_) => {
|
||||
app.sync = None;
|
||||
app.ui.dialog = None;
|
||||
app.toast(ctx, crate::i18n::tr("Scan failed"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
app.sync = None;
|
||||
let c = SyncCounts {
|
||||
offline: changes.offline,
|
||||
new: changes.new.len(),
|
||||
duplicates: changes.duplicates,
|
||||
unreadable: changes.unreadable.len(),
|
||||
missing: changes.missing.len(),
|
||||
metadata: changes.metadata.len(),
|
||||
moved: changes.moved.len(),
|
||||
};
|
||||
app.session.folder_changes = Some(changes);
|
||||
app.sync_owns_changes = true;
|
||||
if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog {
|
||||
*counts = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the scan is (files probed, of how many).
|
||||
fn progress(app: &LightcraftApp) -> Option<(usize, usize)> {
|
||||
app.sync.as_ref().map(|t| t.progress.counts())
|
||||
}
|
||||
|
||||
/// The dialog's body.
|
||||
pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
|
||||
let Dialog::SynchronizeFolder { path, name, counts, import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(420.0);
|
||||
ui.label(crate::i18n::tr("Bring the library up to date with this folder and the folders inside it:"));
|
||||
ui.label(egui::RichText::new(name.as_str()).strong());
|
||||
ui.label(egui::RichText::new(path.as_str()).color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
let Some(c) = counts.as_ref() else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
match progress(app) {
|
||||
Some((done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))),
|
||||
_ => ui.label(crate::i18n::tr("Scanning…")),
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
if c.offline {
|
||||
ui.label(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected."));
|
||||
return;
|
||||
}
|
||||
let choice = |ui: &mut egui::Ui, on: &mut bool, n: usize, text: String, widget: &str| {
|
||||
let r = ui.add_enabled(n > 0, egui::Checkbox::new(on, text));
|
||||
crate::widgets::register(ui.ctx(), widget, r.rect);
|
||||
};
|
||||
let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text));
|
||||
choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew");
|
||||
choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved");
|
||||
choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing");
|
||||
choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata");
|
||||
ui.add_space(6.0);
|
||||
let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim));
|
||||
if c.new + c.moved + c.missing + c.metadata == 0 {
|
||||
dim(ui, crate::i18n::tr("The library is up to date with this folder."));
|
||||
}
|
||||
if c.missing > 0 {
|
||||
dim(ui, crate::i18n::tr("Missing photos move to Recently Deleted and can be restored; no file on disk is touched."));
|
||||
}
|
||||
if c.metadata > 0 {
|
||||
dim(ui, crate::i18n::tr("Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says."));
|
||||
}
|
||||
if c.duplicates > 0 {
|
||||
dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates));
|
||||
}
|
||||
if c.unreadable > 0 {
|
||||
dim(ui, &count("Skipped: files that can't be read", c.unreadable));
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize with the dialog's choices: the scan the dialog showed is handed to a worker
|
||||
/// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went
|
||||
/// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices.
|
||||
pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
|
||||
let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else {
|
||||
return Err("the folder is still being scanned".into());
|
||||
};
|
||||
if app.sync_run.is_some() {
|
||||
return Err(crate::i18n::tr("A folder is already being synchronized").to_string());
|
||||
}
|
||||
// (an import readies its files against the library as it was: both could add the same file)
|
||||
if app.import.is_some() {
|
||||
return Err(crate::i18n::tr("Wait for the import to finish").to_string());
|
||||
}
|
||||
let choice = SyncChoice { import_new: *import_new, relink_moved: *relink_moved, remove_missing: *remove_missing, read_metadata: *read_metadata };
|
||||
app.sync_owns_changes = false;
|
||||
let started = match app.session.take_folder_changes(path) {
|
||||
Some(changes) => SyncJob::start(&mut app.session, changes, choice).map_err(|e| e.to_string()),
|
||||
None => Err(format!("{path} changed since it was scanned: scan again")),
|
||||
};
|
||||
let (work, commit) = match started {
|
||||
Ok(halves) => halves,
|
||||
Err(e) => {
|
||||
// look again, in the background, keeping what was ticked
|
||||
if open(app, path, name, *disk).is_ok()
|
||||
&& let Some(Dialog::SynchronizeFolder { import_new: i, relink_moved: l, remove_missing: m, read_metadata: x, .. }) =
|
||||
&mut app.ui.dialog
|
||||
{
|
||||
(*i, *l, *m, *x) = (*import_new, *relink_moved, *remove_missing, *read_metadata);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let progress = Arc::new(SyncProgress::default());
|
||||
let crashed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (p, c) = (progress.clone(), crashed.clone());
|
||||
let job = move || {
|
||||
// (a panic in the work ends it; what was handed over is still committed, and the end
|
||||
// says it failed)
|
||||
if lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())).is_err() {
|
||||
c.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() });
|
||||
Ok(json!({"started": true}))
|
||||
}
|
||||
|
||||
/// A synchronize at work: the file-system half on a worker thread, its steps committed here
|
||||
/// between frames ([`poll_run`]).
|
||||
pub struct SyncRun {
|
||||
name: String,
|
||||
progress: Arc<SyncProgress>,
|
||||
/// The work ended in a panic (logged).
|
||||
crashed: Arc<std::sync::atomic::AtomicBool>,
|
||||
rx: std::sync::mpsc::Receiver<SyncStep>,
|
||||
/// Steps that went into the catalog but could not be saved (a full disk…), and why.
|
||||
not_saved: Vec<String>,
|
||||
commit: Option<SyncCommit>,
|
||||
/// Cancel was pressed: nothing new is started; what was readied is still committed.
|
||||
stopping: bool,
|
||||
}
|
||||
|
||||
impl SyncRun {
|
||||
/// (done, of how many) — for a progress row.
|
||||
pub fn counts(&self) -> (usize, usize) {
|
||||
self.progress.counts()
|
||||
}
|
||||
/// Stop after the piece in progress.
|
||||
pub fn cancel(&mut self) {
|
||||
self.stopping = true;
|
||||
self.progress.files.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SyncRun {
|
||||
/// A dropped run (the app closing) stops its worker at the next piece.
|
||||
fn drop(&mut self) {
|
||||
self.progress.files.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit what the worker readied (called every frame); when it is done, one undo step and a
|
||||
/// word on what happened.
|
||||
pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(run) = app.sync_run.as_mut() else { return };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let mut steps = Vec::new();
|
||||
let finished = loop {
|
||||
match run.rx.try_recv() {
|
||||
Ok(step) => steps.push(step),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break false,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => break true,
|
||||
}
|
||||
};
|
||||
let Some(mut commit) = run.commit.take() else { return };
|
||||
let mut not_saved = Vec::new();
|
||||
for step in steps {
|
||||
// (a step that went in but couldn't be saved — a full disk — is said at the end)
|
||||
if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {
|
||||
commit.apply(s, step);
|
||||
Ok(Value::Null)
|
||||
}) {
|
||||
not_saved.push(e.to_string());
|
||||
}
|
||||
}
|
||||
let Some(run) = app.sync_run.as_mut() else { return };
|
||||
run.not_saved.extend(not_saved);
|
||||
if !finished {
|
||||
run.commit = Some(commit);
|
||||
return;
|
||||
}
|
||||
let (name, stopped, crashed, mut not_saved) =
|
||||
(run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved));
|
||||
app.sync_run = None;
|
||||
let mut report = None;
|
||||
if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {
|
||||
report = Some(commit.finish(s));
|
||||
Ok(Value::Null)
|
||||
}) {
|
||||
not_saved.push(e.to_string());
|
||||
}
|
||||
let r = report.unwrap_or_default();
|
||||
for (path, e) in &r.failed {
|
||||
log::warn!("synchronize {name}: {path}: {e}");
|
||||
}
|
||||
for e in ¬_saved {
|
||||
log::warn!("synchronize {name}: not saved: {e}");
|
||||
}
|
||||
let mut parts: Vec<String> =
|
||||
[("Imported", r.imported), ("Relinked", r.relinked), ("Removed", r.removed), ("Metadata read", r.read), ("Failed", r.failed.len())]
|
||||
.into_iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(what, n)| format!("{} {n}", crate::i18n::tr(what)))
|
||||
.collect();
|
||||
// how it ended comes first: never "up to date" for a run that was stopped or failed
|
||||
if crashed {
|
||||
parts.insert(0, crate::i18n::tr("Stopped by an error (see the log)").to_string());
|
||||
} else if stopped {
|
||||
parts.insert(0, crate::i18n::tr("Stopped").to_string());
|
||||
}
|
||||
if let Some(e) = not_saved.first() {
|
||||
parts.push(format!("{}: {e}", crate::i18n::tr("Not saved")));
|
||||
}
|
||||
let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") };
|
||||
if not_saved.is_empty() && !crashed {
|
||||
app.toast(ctx, format!("{name}: {text}"));
|
||||
} else {
|
||||
app.toast_for(ctx, format!("{name}: {text}"), 12.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo
|
||||
/// step; nothing new is started).
|
||||
pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(run) = &app.sync_run else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (done, total) = run.counts();
|
||||
let text =
|
||||
if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) };
|
||||
let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 };
|
||||
let stopping = run.stopping;
|
||||
let mut cancel = false;
|
||||
egui::Window::new(crate::i18n::tr("Synchronize Folder"))
|
||||
.id(egui::Id::new("sync-progress"))
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_BOTTOM, [0.0, -80.0])
|
||||
.fixed_size([340.0, 80.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
|
||||
let r = ui.add_enabled(!stopping, egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
});
|
||||
if cancel && let Some(run) = app.sync_run.as_mut() {
|
||||
run.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Synchronize Folder in the app (see `lightcraft_engine::sync`).
|
||||
//!
|
||||
//! Scenarios, in the words of someone whose folder changed outside LightCraft:
|
||||
//!
|
||||
//! * Right-click a folder in the sidebar's Folders section ▸ Synchronize Folder…: a dialog opens
|
||||
//! at once and scans the folder without holding up the app, then says how many photos are new,
|
||||
//! missing, or have metadata updates.
|
||||
//! * Synchronize with the defaults imports the new photos and leaves missing ones alone.
|
||||
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
|
||||
//! * Cancel leaves the library as it was.
|
||||
//! * Synchronize hands the work to the background at once: the dialog closes, the app keeps
|
||||
//! answering, and the photos arrive over the next frames under a progress window, as one undo
|
||||
//! step.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-sync-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
/// The path as the folder tree writes it (forward slashes; widget ids carry it).
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_png(path: &str, seed: u8) {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) 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(std::path::Path::new(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The app with `trip/a.png` and `trip/b.png` imported, then `c.png` added and `b.png` deleted
|
||||
/// on disk behind its back.
|
||||
fn changed_folder(dir: &Scratch) -> Headless {
|
||||
write_png(&dir.path("trip/a.png"), 1);
|
||||
write_png(&dir.path("trip/b.png"), 2);
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
|
||||
write_png(&dir.path("trip/c.png"), 3);
|
||||
std::fs::remove_file(dir.path("trip/b.png")).unwrap();
|
||||
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn in_library(h: &Headless) -> Vec<String> {
|
||||
let mut v: Vec<String> = h.app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Open the dialog from the folder's menu and wait for the scan.
|
||||
fn open_and_scan(h: &mut Headless, folder: &str) {
|
||||
// the rows above it hold one folder each and open by themselves
|
||||
let row = format!("source:libfolder:{folder}");
|
||||
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}");
|
||||
let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}"));
|
||||
let c = rect.center();
|
||||
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once");
|
||||
let scanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })));
|
||||
assert!(scanned, "the scan finished: {:?}", h.app.ui.dialog);
|
||||
}
|
||||
|
||||
/// Synchronize: the dialog closes and the work goes to the background at once (nothing has
|
||||
/// changed yet when the confirm returns); then wait for it.
|
||||
fn synchronize(h: &mut Headless) {
|
||||
let before = in_library(h);
|
||||
// the dialog's confirm itself, between frames: it only hands the work over
|
||||
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(h.app.sync_run.is_some(), "the work runs in the background");
|
||||
assert_eq!(in_library(h), before, "nothing was done on the UI thread");
|
||||
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes");
|
||||
}
|
||||
|
||||
/// Tick "Remove missing photos" and wait until the click has landed. A click is queued input:
|
||||
/// on a loaded machine the dialog can still be settling into place when it arrives, so it is
|
||||
/// clicked again (at the box's current place) until it lands.
|
||||
fn tick_remove_missing(h: &mut Headless) {
|
||||
let ticked = |h: &Headless| matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. }));
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
if h.step_until(Duration::from_secs(3), ticked) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the box never ticked: {:?}", h.app.ui.dialog);
|
||||
}
|
||||
|
||||
fn counts(h: &Headless) -> crate::state::SyncCounts {
|
||||
match &h.app.ui.dialog {
|
||||
Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(),
|
||||
d => panic!("no scanned dialog: {d:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialog_says_what_changed_in_the_folder() {
|
||||
let dir = Scratch::new("counts");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
let c = counts(&h);
|
||||
assert_eq!((c.new, c.missing, c.metadata), (1, 1, 0), "{c:?}");
|
||||
assert_eq!(in_library(&h), vec!["a.png", "b.png"], "scanning changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronizing_with_the_defaults_imports_the_new_photos_only() {
|
||||
let dir = Scratch::new("defaults");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
synchronize(&mut h);
|
||||
assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"], "c came in; the missing b stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticking_remove_missing_also_removes_the_missing_photos() {
|
||||
let dir = Scratch::new("remove");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
tick_remove_missing(&mut h);
|
||||
synchronize(&mut h);
|
||||
assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted");
|
||||
let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(in_library(&h), vec!["a.png", "b.png"], "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_leaves_the_library_as_it_was() {
|
||||
let dir = Scratch::new("cancel");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
h.request("ui.key", json!({"key": "escape"}), T);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
|
||||
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_scan_is_kept_between_frames() {
|
||||
let dir = Scratch::new("agent");
|
||||
let mut h = changed_folder(&dir);
|
||||
let r = h.request("engine.execute", json!({"command": "folder.scanChanges", "params": {"path": dir.path("trip")}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..5 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.session.folder_changes.is_some(), "the app doesn't drop a scan it didn't make");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() {
|
||||
let dir = Scratch::new("restale");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
tick_remove_missing(&mut h);
|
||||
// a photo of the folder changes while the dialog is open
|
||||
let a = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id;
|
||||
let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [a.0], "rating": 3}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], false, "not done on a stale scan: {r}");
|
||||
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog stays open");
|
||||
let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })));
|
||||
assert!(rescanned, "and scans again");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })), "keeping what was ticked");
|
||||
synchronize(&mut h);
|
||||
assert_eq!(in_library(&h), vec!["a.png", "c.png"], "c came in, the missing b went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() {
|
||||
let dir = Scratch::new("stop");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
let steps = h.app.session.undo.len();
|
||||
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
||||
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
||||
// the progress window's Cancel, before any frame committed anything
|
||||
h.app.sync_run.as_mut().expect("running").cancel();
|
||||
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("Stopped"), "never 'up to date': {toast:?}");
|
||||
assert!(h.app.session.undo.len() <= steps + 1, "what was done by then is at most one step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synchronize_and_an_import_never_run_at_once() {
|
||||
let dir = Scratch::new("exclusive");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
||||
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
||||
// dropping the same folder on the window while the run works would add its files twice
|
||||
let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip")]);
|
||||
assert!(r.is_err(), "no import while synchronizing: {r:?}");
|
||||
assert!(h.step_until(T, |h| h.app.sync_run.is_none()));
|
||||
// and the other way round: an import is running (no frame has run since it started)
|
||||
let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip/a.png")]);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(h.app.import.is_some());
|
||||
let d = Dialog::SynchronizeFolder {
|
||||
path: dir.path("trip"),
|
||||
name: "trip".into(),
|
||||
disk: false,
|
||||
counts: Some(Default::default()),
|
||||
import_new: true,
|
||||
relink_moved: true,
|
||||
remove_missing: false,
|
||||
read_metadata: false,
|
||||
};
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d);
|
||||
assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}");
|
||||
}
|
||||
+1
-1
@@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸ and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from LiLine truncated
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
|
||||
@@ -86,6 +86,15 @@
|
||||
Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit
|
||||
Settings.
|
||||
|
||||
### Synchronize Folder
|
||||
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize
|
||||
Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose
|
||||
file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then
|
||||
choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently
|
||||
Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing
|
||||
runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk
|
||||
is touched. Agents use `folder.scanChanges` and `folder.synchronize`.
|
||||
|
||||
### Folder colour labels
|
||||
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row
|
||||
shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`
|
||||
|
||||
Reference in new issue
Block a user