Merge pull request #389 from storytold/claude/integrate-batch-6
Integrate #373 (keymap editor), #218 (Lightroom catalog import), #367 (parallel import copies), #344 (import file types)
This commit is contained in:
commit
67b7bc8bc6
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -191,6 +191,18 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
|||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Lightroom Classic catalog migration
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
|
||||
|
|
|
|||
|
|
@ -167,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
|||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"# comment\n{}\n{}\n{}\n{}\n",
|
||||
"# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
// (settled: under load a grid shot taken before its thumbnails is darker than the
|
||||
// dimmed export dialog over them, and the comparison below fails)
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot"}),
|
||||
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
|
||||
json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
|
||||
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
|
||||
),
|
||||
)
|
||||
|
|
@ -181,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
|||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 4);
|
||||
assert_eq!(replies.len(), 6);
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
for (p, dimmed) in [(&a, false), (&b, true)] {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
|||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
|
|
|
|||
|
|
@ -283,6 +283,13 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
|||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|| {
|
||||
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ pub struct NativeMenu {
|
|||
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
|
||||
structure: String,
|
||||
text_focus: bool,
|
||||
/// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
|
||||
/// press reaches it.
|
||||
capturing: bool,
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
|
||||
|
|
@ -211,7 +214,7 @@ impl NativeMenu {
|
|||
let _ = tx.send(e.id.0);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
|
||||
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
|
||||
m.rebuild(app);
|
||||
app.native_menu = true;
|
||||
m
|
||||
|
|
@ -281,6 +284,7 @@ impl NativeMenu {
|
|||
}
|
||||
self.menu.init_for_nsapp();
|
||||
self.text_focus = false;
|
||||
self.capturing = false;
|
||||
self.publish_shortcuts(app);
|
||||
}
|
||||
|
||||
|
|
@ -345,7 +349,8 @@ impl NativeMenu {
|
|||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
// (none while the keymap editor records a shortcut: every key goes to it)
|
||||
app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
|
|
@ -407,14 +412,16 @@ impl NativeMenu {
|
|||
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
|
||||
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
if focus != self.text_focus {
|
||||
let capturing = app.recording_shortcut.is_some();
|
||||
if focus != self.text_focus || capturing != self.capturing {
|
||||
self.text_focus = focus;
|
||||
self.capturing = capturing;
|
||||
for it in self.items.values() {
|
||||
let Some(sc) = it.shortcut.as_deref() else { continue };
|
||||
if it.accel.is_none() || !yields_to_text(sc) {
|
||||
if it.accel.is_none() {
|
||||
continue;
|
||||
}
|
||||
let accel = if focus { None } else { it.accel };
|
||||
let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
|
||||
let _ = match &it.handle {
|
||||
Handle::Plain(h) => h.set_accelerator(accel),
|
||||
Handle::Check(h) => h.set_accelerator(accel),
|
||||
|
|
|
|||
|
|
@ -1062,6 +1062,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
ok()
|
||||
}),
|
||||
// ---- import
|
||||
cmd!(query "library.inspectLightroom", "Inspect Lightroom Catalog", [], None,
|
||||
"{path: .lrcat} → {photos, collections, missing, warnings}; read-only, no Lightroom required",
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
"Import Lightroom Catalog",
|
||||
[],
|
||||
None,
|
||||
"{path: .lrcat, updateExisting?: false}; direct import of paths, ratings, flags, metadata, keywords, collections, virtual copies & mapped edits. Archives source settings/history first. Existing edited photos are preserved unless updateExisting=true; source catalog is read-only.",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("library.importLightroom", "missing path"))?;
|
||||
crate::lightroom_catalog::import(s, std::path::Path::new(path), bool_or(p, "updateExisting", false))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.importPreview",
|
||||
"Review Import",
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
|||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
|
||||
let n = workers(paths.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
|
@ -404,18 +404,16 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…;
|
||||
/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file,
|
||||
/// synced and checked against `probe_hash`, the content hash the probe computed — or compared
|
||||
/// byte for byte with the source when there is none); a bad one is removed and the photo
|
||||
/// reported as failed.
|
||||
fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
/// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the
|
||||
/// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and
|
||||
/// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte
|
||||
/// with the source when there is none); a bad one is removed and the photo reported as failed.
|
||||
fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>) -> Result<String, String> {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
|
||||
let expect = probe_hash.and_then(lightcraft_preview::Hash128::parse);
|
||||
crate::import_move::copy_new(Path::new(src), &dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
crate::import_move::copy_new(Path::new(src), dir, &name, expect).map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network
|
||||
|
|
@ -641,6 +639,50 @@ impl Prepared {
|
|||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Recheck Add-only entries against the owner catalog immediately before commit.
|
||||
/// Preparation may have run while another import added the same path or content.
|
||||
pub fn revalidate_add(&mut self, s: &Session) {
|
||||
let mut paths = HashMap::new();
|
||||
let mut hashes = HashMap::new();
|
||||
for photo in s.catalog.photos() {
|
||||
if let Source::File { path } = &photo.source {
|
||||
paths.insert(normalize_import_path(path), photo.id);
|
||||
}
|
||||
if let Some(hash) = &photo.content_hash {
|
||||
hashes.insert(hash.clone(), photo.id);
|
||||
}
|
||||
}
|
||||
let mut items = Vec::with_capacity(self.items.len());
|
||||
for item in std::mem::take(&mut self.items) {
|
||||
let PreparedItem::Ready(ready) = item else {
|
||||
items.push(item);
|
||||
continue;
|
||||
};
|
||||
let ReadyFile { path, stored, info, sidecar, placed } = *ready;
|
||||
let existing_path = paths.get(&normalize_import_path(&stored)).copied().or_else(|| paths.get(&normalize_import_path(&path)).copied());
|
||||
let existing_hash = info.content_hash.as_ref().and_then(|hash| hashes.get(hash).copied());
|
||||
if let Some(existing) = existing_path.or(existing_hash) {
|
||||
if let Some(placed) = &placed {
|
||||
crate::import_move::rollback(placed);
|
||||
}
|
||||
items.push(PreparedItem::Duplicate {
|
||||
path,
|
||||
existing: Some(existing),
|
||||
reason: if existing_path.is_some() { "path" } else { "content" },
|
||||
hash: info.content_hash,
|
||||
});
|
||||
} else {
|
||||
items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
}
|
||||
self.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_import_path(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
|
|
@ -699,8 +741,9 @@ impl ImportJob {
|
|||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files — several at once,
|
||||
/// with the names and outcomes of one after another. Nothing in the catalog changes. Stops
|
||||
/// before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
|
|
@ -750,24 +793,39 @@ impl ImportJob {
|
|||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
let mut plan = Plan::default();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Failed(path, e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
|
||||
if !opts.local
|
||||
&& let Some(h) = &info.content_hash
|
||||
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
|
||||
{
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) }));
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
|
|
@ -786,7 +844,6 @@ impl ImportJob {
|
|||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
|
|
@ -803,37 +860,35 @@ impl ImportJob {
|
|||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
// the name this file gets depends on what a pending transfer of a like-named file
|
||||
// into the same folder ends up with: that one goes first
|
||||
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
|
||||
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
|
||||
if plan.transfers.iter().any(|t| t.family == family) {
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let how = if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
How::Place { name: name.unwrap_or(own) }
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
How::Copy { name }
|
||||
};
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
|
||||
plan.slots.push(Slot::Transfer);
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
|
||||
continue;
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
plan.slots.push(Slot::Item(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() })));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
|
|
@ -841,27 +896,199 @@ impl ImportJob {
|
|||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
plan.slots.push(Slot::Item(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed: None }))));
|
||||
}
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
|
||||
/// file order. Transfers not started because `cancel` was set leave no item.
|
||||
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
|
||||
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
|
||||
for slot in std::mem::take(&mut plan.slots) {
|
||||
let item = match slot {
|
||||
Slot::Item(it) => it,
|
||||
Slot::Transfer => match done.next().flatten() {
|
||||
Some(it) => it,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if let PreparedItem::Ready(r) = &item
|
||||
&& let Some(h) = &r.info.content_hash
|
||||
{
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
out.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]).
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
slots: Vec<Slot>,
|
||||
transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
enum Slot {
|
||||
Item(PreparedItem),
|
||||
/// The outcome of the next of [`Plan::transfers`].
|
||||
Transfer,
|
||||
}
|
||||
|
||||
/// A file to copy or move into `dir`, planned in order and run on any thread ([`run_transfer`]).
|
||||
struct Transfer {
|
||||
path: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
dir: PathBuf,
|
||||
how: How,
|
||||
/// Copy as DNG.
|
||||
dng: bool,
|
||||
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
|
||||
/// names, so they never run at the same time.
|
||||
family: (String, String),
|
||||
}
|
||||
|
||||
enum How {
|
||||
/// A verified copy, under `name` (default: the file's own).
|
||||
Copy { name: Option<String> },
|
||||
/// Move: placed (linked or copied) without touching the source.
|
||||
Place { name: String },
|
||||
}
|
||||
|
||||
/// The names a file called `name` could end up with, as one key: the transfers number a taken name
|
||||
/// `IMG-1.JPG`, `IMG-2.JPG`…, and Copy as DNG names its DNG `IMG.dng`, `IMG-2.dng`…, so `IMG.JPG`,
|
||||
/// `IMG-1.jpg` and `img.dng` are all `img` (case-insensitive: so are some file systems).
|
||||
fn name_family(name: &str) -> String {
|
||||
let mut stem = match name.rsplit_once('.') {
|
||||
Some((s, _)) if !s.is_empty() => s,
|
||||
_ => name,
|
||||
};
|
||||
while let Some((head, tail)) = stem.rsplit_once('-')
|
||||
&& !head.is_empty()
|
||||
&& !tail.is_empty()
|
||||
&& tail.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
stem = head;
|
||||
}
|
||||
stem.to_lowercase()
|
||||
}
|
||||
|
||||
/// Threads for `jobs` file reads (probes, copies) at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn workers(jobs: usize) -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1))
|
||||
}
|
||||
|
||||
/// Files per batch for a caller that prepares an import in batches ([`ImportJob::prepare_files`]):
|
||||
/// enough to keep every probe and copy thread busy (a batch waits for its slowest file), few
|
||||
/// enough that each batch soon joins the catalog.
|
||||
pub fn batch_size() -> usize {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
workers(usize::MAX).saturating_mul(2).clamp(8, 16)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `transfers` (several at once where threads exist); their outcomes in order, `None` for one
|
||||
/// not started because `cancel` was set. `done` counts each one finished.
|
||||
fn run_transfers(
|
||||
transfers: Vec<Transfer>,
|
||||
file_bytes: Option<&crate::merge::ByteReader>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
) -> Vec<Option<PreparedItem>> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let one = |t: Transfer| {
|
||||
if cancel.load(Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let path = t.path.clone();
|
||||
// last-resort guard: a panic is this file's failure, not the import's
|
||||
let item = crate::guard::catch("import", || run_transfer(t, file_bytes)).unwrap_or_else(|e| PreparedItem::Failed(path, e));
|
||||
done.fetch_add(1, Relaxed);
|
||||
Some(item)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = workers(transfers.len());
|
||||
if n > 1 {
|
||||
let mut results: Vec<Option<PreparedItem>> = std::iter::repeat_with(|| None).take(transfers.len()).collect();
|
||||
let queue = std::sync::Mutex::new(transfers.into_iter().enumerate());
|
||||
let out = std::sync::Mutex::new(&mut results);
|
||||
// (the tests' injected faults hold for the workers too)
|
||||
#[cfg(test)]
|
||||
let fault = crate::import_move::injected_fault();
|
||||
let work = &|| {
|
||||
#[cfg(test)]
|
||||
crate::import_move::inject(fault);
|
||||
loop {
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some((i, t)) = next else { break };
|
||||
let r = one(t);
|
||||
if let Some(slot) = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
let handles: Vec<_> =
|
||||
(0..n).filter_map(|i| std::thread::Builder::new().name(format!("lc-import-copy-{i}")).spawn_scoped(sc, work).ok()).collect();
|
||||
if handles.is_empty() {
|
||||
// no thread to be had: copy here, one after another
|
||||
work();
|
||||
}
|
||||
for h in handles {
|
||||
if h.join().is_err() {
|
||||
log::error!("import: a copy worker stopped unexpectedly");
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
transfers.into_iter().map(one).collect()
|
||||
}
|
||||
|
||||
/// Copy or move one planned file (and Copy as DNG); its item.
|
||||
fn run_transfer(t: Transfer, file_bytes: Option<&crate::merge::ByteReader>) -> PreparedItem {
|
||||
let Transfer { path, mut info, sidecar, dir, how, dng, .. } = t;
|
||||
let mut placed = None;
|
||||
let mut stored = match how {
|
||||
How::Place { name } => match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
How::Copy { name } => match copy_into(&dir, &path, name.as_deref(), info.content_hash.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return PreparedItem::Failed(path, e),
|
||||
},
|
||||
};
|
||||
if dng {
|
||||
// decoding the raw twice and encoding it takes several times the file's size in memory
|
||||
let weight = usize::try_from(info.file_size).unwrap_or(usize::MAX).saturating_mul(6);
|
||||
let _permit = crate::memory::work_gate().acquire(weight);
|
||||
match crate::cmd::convert::write_dng_with(file_bytes, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed }))
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
|
|
@ -1093,3 +1320,31 @@ impl Session {
|
|||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prepared_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revalidate_add_only_converts_matching_ready_item() {
|
||||
let mut s = Session::new();
|
||||
let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg");
|
||||
let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg");
|
||||
let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now");
|
||||
s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap();
|
||||
let ready = |path: &Path| ReadyFile {
|
||||
path: path.to_string_lossy().into(),
|
||||
stored: path.to_string_lossy().into(),
|
||||
info: ProbeInfo { format: "JPG".into(), ..ProbeInfo::default() },
|
||||
sidecar: None,
|
||||
placed: None,
|
||||
};
|
||||
let mut prepared = Prepared {
|
||||
scanned: 2,
|
||||
items: vec![PreparedItem::Ready(Box::new(ready(&existing_path))), PreparedItem::Ready(Box::new(ready(&other_path)))],
|
||||
};
|
||||
prepared.revalidate_add(&s);
|
||||
assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. })));
|
||||
assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ pub(crate) enum Fault {
|
|||
Corrupt,
|
||||
/// Removing the source fails (as on a read-only card).
|
||||
FailRemove,
|
||||
/// Each copy takes a little longer and notes the thread it ran on ([`copy_threads`]), to see
|
||||
/// an import's copies run side by side.
|
||||
Slow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -69,6 +72,21 @@ pub(crate) fn inject(f: Fault) {
|
|||
FAULT.with(|c| c.set(f));
|
||||
}
|
||||
|
||||
/// The calling thread's injected fault (tests only), for the workers it starts to inherit.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn injected_fault() -> Fault {
|
||||
FAULT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// The threads copies made under [`Fault::Slow`] ran on (tests only).
|
||||
#[cfg(test)]
|
||||
static COPY_THREADS: std::sync::Mutex<Vec<std::thread::ThreadId>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn copy_threads() -> Vec<std::thread::ThreadId> {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
fn injected(f: Fault) -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
|
|
@ -284,6 +302,11 @@ pub(crate) fn copy_verified(src: &Path, dst: &Path, expect: Option<lightcraft_pr
|
|||
return Err(io::Error::other("write failed (injected)"));
|
||||
}
|
||||
io::copy(&mut inp, &mut out)?;
|
||||
#[cfg(test)]
|
||||
if injected(Fault::Slow) {
|
||||
COPY_THREADS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(std::thread::current().id());
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
if injected(Fault::Corrupt) {
|
||||
out.write_all(b"!")?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ pub mod guard;
|
|||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
mod lightroom_archive;
|
||||
pub mod lightroom_catalog;
|
||||
pub mod lightroom_job;
|
||||
mod lightroom_sqlite;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
|
|
@ -186,6 +190,8 @@ pub struct Session {
|
|||
pub active_spot: Option<usize>,
|
||||
/// The persistent library this session writes to (`None` = in-memory only).
|
||||
pub library: Option<library::Library>,
|
||||
/// Identity of current library opening; refreshed only after a successful open.
|
||||
pub(crate) library_identity: Arc<()>,
|
||||
/// XMP sidecar preferences (persisted with the library).
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
|
|
@ -274,6 +280,7 @@ impl Session {
|
|||
segmenter: segment::Segmenter::default(),
|
||||
active_spot: None,
|
||||
library: None,
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
export_presets: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -412,6 +412,7 @@ impl Session {
|
|||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -504,12 +505,16 @@ impl Session {
|
|||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
let result = match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,685 @@
|
|||
//! Bounded, deduplicated persistence for Lightroom import source data.
|
||||
//!
|
||||
//! Archives use a deterministic JSON representation wrapped in zlib. The JSON
|
||||
//! writer streams through a bounded compressor, so an import cannot first build
|
||||
//! an unbounded archive buffer. Blob columns are omitted by the catalog reader;
|
||||
//! decoded XMP,
|
||||
//! settings, history, snapshots, identities, paths, collections & membership
|
||||
//! remain available for recovery.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use miniz_oxide::deflate::{
|
||||
core::{CompressorOxide, create_comp_flags_from_zip_params},
|
||||
stream::deflate,
|
||||
};
|
||||
use miniz_oxide::{MZFlush, MZStatus};
|
||||
use serde::ser::{Serialize, SerializeMap, SerializeSeq, SerializeStruct, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
|
||||
/// Maximum size of one complete archive on disk, including format header.
|
||||
pub const MAX_ARCHIVE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Maximum total size retained in managed archives.
|
||||
pub const MAX_TOTAL_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Maximum number of managed archives retained.
|
||||
pub const MAX_MANAGED_ARCHIVES: usize = 8;
|
||||
|
||||
const FORMAT_VERSION: u8 = 1;
|
||||
const HEADER: &[u8] = b"LC-LRARCH\0\x01Z";
|
||||
const NAME_PREFIX: &str = "lightroom-import-";
|
||||
const NAME_SUFFIX: &str = ".lca";
|
||||
const MAX_SCAN: usize = 1024;
|
||||
const IO_BUFFER: usize = 16 * 1024;
|
||||
|
||||
/// Store compact source data below `directory` and return its managed archive.
|
||||
///
|
||||
/// The operation is safe to call before catalog mutation. It rejects a
|
||||
/// symlink directory, writes only `lightroom-import-<number>.lca`, compares the
|
||||
/// complete bounded byte stream with recent managed archives, and retains at
|
||||
/// most [`MAX_MANAGED_ARCHIVES`] and [`MAX_TOTAL_ARCHIVE_BYTES`]. Legacy
|
||||
/// `lightroom-import-*.json` files are deliberately ignored and preserved for
|
||||
/// an explicit caller warning.
|
||||
pub fn store(directory: &Path, data: &CatalogImport) -> Result<PathBuf, String> {
|
||||
prepare_directory(directory)?;
|
||||
let mut managed = managed_files(directory)?;
|
||||
managed.sort_by_key(|entry| entry.index);
|
||||
|
||||
let temporary = temporary_path(directory)?;
|
||||
write_archive(&temporary, data).inspect_err(|_| {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
})?;
|
||||
|
||||
let recent = managed.iter().rev().take(MAX_MANAGED_ARCHIVES);
|
||||
for entry in recent {
|
||||
let same = match same_bounded(&temporary, &entry.path) {
|
||||
Ok(same) => same,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if same {
|
||||
remove_managed_file(&temporary).map_err(|e| e.to_string())?;
|
||||
let matched = entry.path.clone();
|
||||
let mut all = managed.clone();
|
||||
prune_existing(directory, &mut all, Some(&matched))?;
|
||||
return Ok(matched);
|
||||
}
|
||||
}
|
||||
|
||||
let index = managed.last().map_or(0, |entry| entry.index.saturating_add(1));
|
||||
if managed.last().is_some_and(|entry| entry.index == u64::MAX) {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err("managed Lightroom archive index is exhausted".into());
|
||||
}
|
||||
let destination = match next_destination(directory, index) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let size = match fs::metadata(&temporary) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
let new_entry = Managed { index, path: destination.clone(), size };
|
||||
let mut after = managed;
|
||||
after.push(new_entry);
|
||||
after.sort_by_key(|entry| entry.index);
|
||||
let retirees = match retirement_set(&after) {
|
||||
Ok(retirees) => retirees,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let quarantined = match quarantine(&retirees) {
|
||||
Ok(quarantined) => quarantined,
|
||||
Err(error) => {
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = publish_new(&temporary, &destination) {
|
||||
rollback_quarantine(&quarantined);
|
||||
let _ = remove_managed_file(&temporary);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if let Err(error) = remove_managed_file(&temporary) {
|
||||
return Err(format!("archive published but temporary cleanup failed: {error}"));
|
||||
}
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("archive published but could not retire {}: {error}", old.display()));
|
||||
}
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
/// Store source data when possible. An archive that reaches its bounded size is
|
||||
/// omitted with a warning so catalog import can still complete.
|
||||
pub fn store_best_effort(directory: &Path, data: &CatalogImport) -> Result<Option<PathBuf>, String> {
|
||||
match store(directory, data) {
|
||||
Ok(path) => Ok(Some(path)),
|
||||
Err(error) if error.contains("Lightroom archive exceeds 32 MiB") => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Managed {
|
||||
index: u64,
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn prepare_directory(directory: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(directory) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir_all(directory).map_err(|e| e.to_string())?;
|
||||
let meta = fs::symlink_metadata(directory).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_dir() {
|
||||
return Err(format!("archive directory is not a real directory: {}", directory.display()));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn managed_files(directory: &Path) -> Result<Vec<Managed>, String> {
|
||||
let mut files = Vec::new();
|
||||
for (seen, result) in fs::read_dir(directory).map_err(|e| e.to_string())?.enumerate() {
|
||||
if seen >= MAX_SCAN {
|
||||
return Err(format!("too many entries in archive directory (limit {MAX_SCAN})"));
|
||||
}
|
||||
let entry = result.map_err(|e| e.to_string())?;
|
||||
let Some(index) = parse_managed_name(&entry.file_name()) else { continue };
|
||||
let meta = fs::symlink_metadata(entry.path()).map_err(|e| e.to_string())?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
files.push(Managed { index, path: entry.path(), size: meta.len() });
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_managed_name(name: &std::ffi::OsStr) -> Option<u64> {
|
||||
let name = name.to_str()?;
|
||||
let digits = name.strip_prefix(NAME_PREFIX)?.strip_suffix(NAME_SUFFIX)?;
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn temporary_path(directory: &Path) -> Result<PathBuf, String> {
|
||||
let pid = std::process::id();
|
||||
for slot in 0..MAX_SCAN {
|
||||
let path = directory.join(format!(".lightroom-import-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => continue,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe temporary archive name available".into())
|
||||
}
|
||||
|
||||
fn next_destination(directory: &Path, mut index: u64) -> Result<PathBuf, String> {
|
||||
for _ in 0..MAX_SCAN {
|
||||
let path = directory.join(format!("{NAME_PREFIX}{index}{NAME_SUFFIX}"));
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(_) => index = index.checked_add(1).ok_or("managed archive index is exhausted")?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("no safe managed archive name available".into())
|
||||
}
|
||||
|
||||
fn write_archive(path: &Path, data: &CatalogImport) -> Result<(), String> {
|
||||
lightcraft_catalog::safe_file::write_atomic_with(path, &mut |raw| {
|
||||
let mut bounded = BoundedWriter { inner: raw, total: 0 };
|
||||
bounded.write_all(HEADER)?;
|
||||
let mut compressor = ZlibWriter::new(&mut bounded);
|
||||
serde_json::to_writer(&mut compressor, &ArchiveRef(data)).map_err(json_io)?;
|
||||
compressor.finish()?;
|
||||
Ok(())
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("could not write Lightroom archive: {error}"))
|
||||
}
|
||||
|
||||
fn json_io(error: serde_json::Error) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
|
||||
}
|
||||
|
||||
struct BoundedWriter<'a> {
|
||||
inner: &'a mut dyn Write,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Write for BoundedWriter<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
let next = self.total.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.inner.write_all(bytes)?;
|
||||
self.total = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Lightroom archive exceeds 32 MiB")
|
||||
}
|
||||
|
||||
struct ZlibWriter<'a, 'b> {
|
||||
sink: &'a mut BoundedWriter<'b>,
|
||||
compressor: CompressorOxide,
|
||||
plain: u64,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ZlibWriter<'a, 'b> {
|
||||
fn new(sink: &'a mut BoundedWriter<'b>) -> ZlibWriter<'a, 'b> {
|
||||
let flags = create_comp_flags_from_zip_params(6, 15, 0);
|
||||
ZlibWriter { sink, compressor: CompressorOxide::new(flags), plain: 0 }
|
||||
}
|
||||
|
||||
fn run(&mut self, input: &[u8], flush: MZFlush) -> io::Result<()> {
|
||||
let mut rest = input;
|
||||
loop {
|
||||
let mut output = [0u8; IO_BUFFER];
|
||||
let result = deflate(&mut self.compressor, rest, &mut output, flush);
|
||||
self.sink.write_all(&output[..result.bytes_written])?;
|
||||
match result.status {
|
||||
Ok(MZStatus::StreamEnd) => return Ok(()),
|
||||
Ok(MZStatus::Ok) => {
|
||||
let consumed = result.bytes_consumed;
|
||||
if consumed > rest.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid compressor progress"));
|
||||
}
|
||||
rest = &rest[consumed..];
|
||||
if rest.is_empty() && flush == MZFlush::None {
|
||||
return Ok(());
|
||||
}
|
||||
if consumed == 0 && result.bytes_written == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "compressor made no progress"));
|
||||
}
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported compressor status: {status:?}")));
|
||||
}
|
||||
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("compression failed: {error:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.run(&[], MZFlush::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ZlibWriter<'_, '_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let next = self.plain.checked_add(bytes.len() as u64).ok_or_else(limit_error)?;
|
||||
if next > MAX_ARCHIVE_BYTES {
|
||||
return Err(limit_error());
|
||||
}
|
||||
self.run(bytes, MZFlush::None)?;
|
||||
self.plain = next;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn same_bounded(left: &Path, right: &Path) -> io::Result<bool> {
|
||||
let left_meta = fs::symlink_metadata(left)?;
|
||||
let right_meta = fs::symlink_metadata(right)?;
|
||||
if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() || !left_meta.is_file() || !right_meta.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let left_len = left_meta.len();
|
||||
let right_len = right_meta.len();
|
||||
if left_len != right_len || left_len > MAX_ARCHIVE_BYTES {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut left = File::open(left)?;
|
||||
let mut right = File::open(right)?;
|
||||
let mut left_buf = [0u8; IO_BUFFER];
|
||||
let mut right_buf = [0u8; IO_BUFFER];
|
||||
loop {
|
||||
let left_n = left.read(&mut left_buf)?;
|
||||
let right_n = right.read(&mut right_buf)?;
|
||||
if left_n != right_n {
|
||||
return Ok(false);
|
||||
}
|
||||
if left_n == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if left_buf[..left_n] != right_buf[..right_n] {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retirement_set(entries: &[Managed]) -> Result<Vec<Managed>, String> {
|
||||
retirement_set_protected(entries, None)
|
||||
}
|
||||
|
||||
fn retirement_set_protected(entries: &[Managed], protected: Option<&Path>) -> Result<Vec<Managed>, String> {
|
||||
let mut ordered = entries.to_vec();
|
||||
ordered.sort_by_key(|entry| entry.index);
|
||||
let mut total = ordered.iter().try_fold(0u64, |sum, entry| sum.checked_add(entry.size)).ok_or("archive size overflow")?;
|
||||
let mut retirees = Vec::new();
|
||||
while ordered.len() > MAX_MANAGED_ARCHIVES || total > MAX_TOTAL_ARCHIVE_BYTES {
|
||||
let Some(position) = ordered.iter().position(|entry| protected.is_none_or(|path| entry.path != path)) else {
|
||||
return Err("cannot satisfy archive retention limit".into());
|
||||
};
|
||||
let oldest = ordered.remove(position);
|
||||
total = total.saturating_sub(oldest.size);
|
||||
retirees.push(oldest);
|
||||
}
|
||||
Ok(retirees)
|
||||
}
|
||||
|
||||
fn prune_existing(directory: &Path, entries: &mut [Managed], protected: Option<&Path>) -> Result<(), String> {
|
||||
let retirees = retirement_set_protected(entries, protected)?;
|
||||
let quarantined = quarantine(&retirees)?;
|
||||
for (old, held) in quarantined {
|
||||
if let Err(error) = remove_managed_file(&held) {
|
||||
return Err(format!("could not retire {} from {}: {error}", old.display(), directory.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quarantine(entries: &[Managed]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
|
||||
let mut moved = Vec::new();
|
||||
let pid = std::process::id();
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
let held = entry.path.with_file_name(format!(".lightroom-retire-{pid}-{slot}.tmp"));
|
||||
match fs::symlink_metadata(&held) {
|
||||
Ok(_) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("retention temporary path already exists: {}", held.display()));
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
let meta = fs::symlink_metadata(&entry.path).map_err(|error| {
|
||||
rollback_quarantine(&moved);
|
||||
error.to_string()
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(format!("managed archive changed into non-file: {}", entry.path.display()));
|
||||
}
|
||||
if let Err(error) = fs::rename(&entry.path, &held) {
|
||||
rollback_quarantine(&moved);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
moved.push((entry.path.clone(), held));
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rollback_quarantine(moved: &[(PathBuf, PathBuf)]) {
|
||||
for (original, held) in moved.iter().rev() {
|
||||
let _ = fs::rename(held, original);
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_new(temporary: &Path, destination: &Path) -> io::Result<()> {
|
||||
match fs::hard_link(temporary, destination) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(io::Error::new(error.kind(), format!("cannot atomically publish archive: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_managed_file(path: &Path) -> io::Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "refusing to remove non-regular archive path"));
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
struct ArchiveRef<'a>(&'a CatalogImport);
|
||||
|
||||
impl Serialize for ArchiveRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_struct("CatalogImportArchive", 7)?;
|
||||
out.serialize_field("version", &FORMAT_VERSION)?;
|
||||
out.serialize_field("source", &self.0.source)?;
|
||||
out.serialize_field("photos", &PhotoSeq(&self.0.photos))?;
|
||||
out.serialize_field("collections", &RowSeq(&self.0.collections))?;
|
||||
out.serialize_field("members", &RowSeq(&self.0.members))?;
|
||||
out.serialize_field("collection_content", &RowSeq(&self.0.collection_content))?;
|
||||
out.serialize_field("warnings", &self.0.warnings)?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoSeq<'a>(&'a [CatalogPhoto]);
|
||||
impl Serialize for PhotoSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for photo in self.0 {
|
||||
out.serialize_element(&PhotoRef(photo))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoRef<'a>(&'a CatalogPhoto);
|
||||
impl Serialize for PhotoRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let mut out = serializer.serialize_struct("CatalogPhotoArchive", 8)?;
|
||||
out.serialize_field("source_id", &p.source_id)?;
|
||||
out.serialize_field("uuid", &p.uuid)?;
|
||||
out.serialize_field("path", &p.path)?;
|
||||
out.serialize_field("image", &RowRef(&p.image))?;
|
||||
out.serialize_field("settings", &p.settings)?;
|
||||
out.serialize_field("xmp", &p.xmp)?;
|
||||
out.serialize_field("keywords", &p.keywords)?;
|
||||
out.serialize_field("history", &RowSeq(&p.history))?;
|
||||
out.serialize_field("snapshots", &RowSeq(&p.snapshots))?;
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowSeq<'a>(&'a [BTreeMap<String, Value>]);
|
||||
impl Serialize for RowSeq<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for row in self.0 {
|
||||
out.serialize_element(&RowRef(row))?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
struct RowRef<'a>(&'a BTreeMap<String, Value>);
|
||||
impl Serialize for RowRef<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut out = serializer.serialize_map(None)?;
|
||||
for (key, value) in self.0 {
|
||||
out.serialize_entry(key, value)?;
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lightroom_catalog::{CatalogImport, CatalogPhoto};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn data(source: String) -> CatalogImport {
|
||||
CatalogImport {
|
||||
source,
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rich_data() -> CatalogImport {
|
||||
let photo = CatalogPhoto {
|
||||
source_id: 7,
|
||||
uuid: "uuid-7".into(),
|
||||
path: "/photos/one.raw".into(),
|
||||
image: BTreeMap::from([("rating".into(), serde_json::json!(5)), ("opaque_blob".into(), serde_json::json!([1, 2, 3, 4]))]),
|
||||
settings: "s = { Exposure2012 = 0.75 }".into(),
|
||||
xmp: "<xmp>decoded</xmp>".into(),
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
photos: vec![photo],
|
||||
collections: vec![BTreeMap::from([("name".into(), serde_json::json!("Keepers"))])],
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_count(path: &Path) -> usize {
|
||||
fs::read_dir(path).unwrap().filter_map(Result::ok).filter(|entry| parse_managed_name(&entry.file_name()).is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_identical_deterministic_archives() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
let second = store(&path, &input).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(managed_count(&path), 1);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_returns_archive_that_retention_keeps() {
|
||||
let path = tempdir();
|
||||
let input = data("catalog.lrcat".into());
|
||||
let first = store(&path, &input).unwrap();
|
||||
for index in 1..MAX_MANAGED_ARCHIVES {
|
||||
let filler = path.join(format!("lightroom-import-{index}.lca"));
|
||||
File::create(filler).unwrap().set_len(19 * 1024 * 1024).unwrap();
|
||||
}
|
||||
let reused = store(&path, &input).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
assert!(reused.is_file());
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_managed_archive_count_and_preserves_unrelated_files() {
|
||||
let path = tempdir();
|
||||
let human = path.join("notes.txt");
|
||||
let legacy = path.join("lightroom-import-0.json");
|
||||
fs::write(&human, b"keep").unwrap();
|
||||
fs::write(&legacy, b"legacy").unwrap();
|
||||
for index in 0..(MAX_MANAGED_ARCHIVES + 3) {
|
||||
let input = data(format!("catalog-{index}.lrcat"));
|
||||
store(&path, &input).unwrap();
|
||||
}
|
||||
assert!(managed_count(&path) <= MAX_MANAGED_ARCHIVES);
|
||||
assert_eq!(fs::read(&human).unwrap(), b"keep");
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"legacy");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversize_before_creating_archive() {
|
||||
let path = tempdir();
|
||||
let input = data("x".repeat((MAX_ARCHIVE_BYTES as usize) + 1));
|
||||
assert!(store(&path, &input).is_err());
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_catalog_history_is_best_effort_and_does_not_abort_import() {
|
||||
let path = tempdir();
|
||||
let mut input = data("catalog.lrcat".into());
|
||||
input.photos = (0..5_000)
|
||||
.map(|index| CatalogPhoto {
|
||||
source_id: index,
|
||||
uuid: format!("photo-{index}"),
|
||||
path: format!("/photos/{index}.raw"),
|
||||
image: BTreeMap::new(),
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
assert_eq!(managed_count(&path), 0);
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_roundtrips_zlib_payload_and_preserves_numeric_arrays() {
|
||||
let path = tempdir();
|
||||
let archive = store(&path, &rich_data()).unwrap();
|
||||
let bytes = fs::read(archive).unwrap();
|
||||
assert!(bytes.starts_with(HEADER));
|
||||
let json = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&bytes[HEADER.len()..], MAX_ARCHIVE_BYTES as usize).unwrap();
|
||||
let decoded: Value = serde_json::from_slice(&json).unwrap();
|
||||
assert_eq!(decoded["version"], serde_json::json!(FORMAT_VERSION));
|
||||
assert_eq!(decoded["source"], serde_json::json!("source.lrcat"));
|
||||
assert_eq!(decoded["photos"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["source_id"], serde_json::json!(7));
|
||||
assert_eq!(decoded["photos"][0]["uuid"], serde_json::json!("uuid-7"));
|
||||
assert_eq!(decoded["photos"][0]["path"], serde_json::json!("/photos/one.raw"));
|
||||
assert_eq!(decoded["photos"][0]["settings"], serde_json::json!("s = { Exposure2012 = 0.75 }"));
|
||||
assert_eq!(decoded["photos"][0]["xmp"], serde_json::json!("<xmp>decoded</xmp>"));
|
||||
assert_eq!(decoded["photos"][0]["keywords"], serde_json::json!(["Trips|Goa"]));
|
||||
assert_eq!(decoded["photos"][0]["image"]["rating"], serde_json::json!(5));
|
||||
assert_eq!(decoded["photos"][0]["image"]["opaque_blob"], serde_json::json!([1, 2, 3, 4]));
|
||||
assert_eq!(decoded["photos"][0]["history"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["photos"][0]["history"][0]["curve"], serde_json::json!([0, 128, 255]));
|
||||
assert_eq!(decoded["photos"][0]["snapshots"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["collections"][0]["name"], serde_json::json!("Keepers"));
|
||||
assert_eq!(decoded["members"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["members"][0]["image"], serde_json::json!(7));
|
||||
assert_eq!(decoded["collection_content"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(decoded["warnings"], serde_json::json!(["one warning"]));
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_enforces_aggregate_byte_cap_with_small_synthetic_entries() {
|
||||
let mut entries: Vec<_> = (0..MAX_MANAGED_ARCHIVES as u64)
|
||||
.map(|index| Managed { index, path: PathBuf::from(format!("lightroom-import-{index}.lca")), size: 20 * 1024 * 1024 })
|
||||
.collect();
|
||||
entries.push(Managed { index: 8, path: PathBuf::from("lightroom-import-8.lca"), size: 20 * 1024 * 1024 });
|
||||
let retirees = retirement_set(&entries).unwrap();
|
||||
assert_eq!(retirees.len(), 3);
|
||||
assert_eq!(retirees.iter().map(|entry| entry.index).collect::<Vec<_>>(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_failure_preserves_old_archive() {
|
||||
let path = tempdir();
|
||||
let old = path.join("lightroom-import-0.lca");
|
||||
let held = path.join(format!(".lightroom-retire-{}-0.tmp", std::process::id()));
|
||||
fs::write(&old, b"old archive").unwrap();
|
||||
fs::write(&held, b"occupied").unwrap();
|
||||
let entry = Managed { index: 0, path: old.clone(), size: 11 };
|
||||
assert!(quarantine(&[entry]).is_err());
|
||||
assert_eq!(fs::read(&old).unwrap(), b"old archive");
|
||||
fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,450 @@
|
|||
//! Detached Lightroom catalog preparation and owner-thread commit.
|
||||
//!
|
||||
//! `LightroomJob::new` takes only an in-memory session snapshot. `prepare` owns all catalog
|
||||
//! reading, probing and archive staging, while `commit_prepared` only applies catalog operations
|
||||
//! to the session that created the job. Archive index bytes are returned for a worker to finish
|
||||
//! after the owner has accepted the catalog operation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::import::{ImportJob, ImportMode, ImportOptions, Prepared};
|
||||
use crate::lightroom_catalog::{self, CatalogImport, ImportIndex};
|
||||
|
||||
const COMMAND: &str = "library.importLightroom";
|
||||
const MAX_INDEX_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn error(message: impl Into<String>) -> crate::EngineError {
|
||||
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
|
||||
}
|
||||
|
||||
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result<Option<PathBuf>> {
|
||||
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
|
||||
if archive_dir.is_some() && archive_path.is_none() {
|
||||
data.warnings.push("Lightroom source archive exceeded 32 MiB; archive skipped".into());
|
||||
}
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
/// Memory identity of the library which owns a prepared completion.
|
||||
///
|
||||
/// Each successful open/close refreshes session identity, including reopening the same path.
|
||||
/// The owner check deliberately does not compare catalog revisions: ordinary edits while preparation
|
||||
/// runs must survive and are merged by the owner commit.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LightroomLibraryToken {
|
||||
library_dir: Option<PathBuf>,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
|
||||
impl LightroomLibraryToken {
|
||||
fn capture(s: &crate::Session) -> Self {
|
||||
Self {
|
||||
library_dir: s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone()),
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` still refers to the library which started preparation.
|
||||
pub fn matches(&self, s: &crate::Session) -> bool {
|
||||
Arc::ptr_eq(&self.identity, &s.library_identity)
|
||||
&& self.library_dir == s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.clone())
|
||||
}
|
||||
|
||||
pub fn library_dir(&self) -> Option<&Path> {
|
||||
self.library_dir.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-free result of catalog preparation.
|
||||
pub struct PreparedLightroom {
|
||||
pub(crate) data: CatalogImport,
|
||||
pub(crate) normal: Prepared,
|
||||
pub(crate) index: ImportIndex,
|
||||
pub(crate) archive_path: Option<PathBuf>,
|
||||
pub(crate) token: LightroomLibraryToken,
|
||||
pub(crate) missing_sources: HashSet<String>,
|
||||
pub(crate) update_existing: bool,
|
||||
pub(crate) now: String,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl PreparedLightroom {
|
||||
pub fn source(&self) -> &str {
|
||||
&self.data.source
|
||||
}
|
||||
|
||||
pub fn photo_count(&self) -> usize {
|
||||
self.data.photos.len()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled
|
||||
}
|
||||
|
||||
/// Roll back any normal-import filesystem placement held by this prepared value.
|
||||
pub fn rollback(&self) {
|
||||
self.normal.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
/// Index write returned by owner commit. `finish` is intended for a worker after the owner has
|
||||
/// returned, and performs the only archive-index filesystem operation in this pipeline.
|
||||
pub struct LightroomArchiveFinalization {
|
||||
path: Option<PathBuf>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl LightroomArchiveFinalization {
|
||||
pub fn finish(self) -> crate::Result<()> {
|
||||
let (Some(path), Some(bytes)) = (self.path, self.bytes) else { return Ok(()) };
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
return Err(error("Lightroom import index exceeds 16 MiB"));
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &bytes).map_err(|e| error(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner-thread catalog result. The report is ready for the UI immediately; index finalization
|
||||
/// can remain on the worker while its task stays alive.
|
||||
pub struct LightroomCompletion {
|
||||
pub report: Value,
|
||||
pub finalization: LightroomArchiveFinalization,
|
||||
}
|
||||
|
||||
/// Alias used by hosts that call the result a commit.
|
||||
pub type LightroomCommit = LightroomCompletion;
|
||||
|
||||
/// A Lightroom import detached from the owner thread.
|
||||
pub struct LightroomJob {
|
||||
path: PathBuf,
|
||||
update_existing: bool,
|
||||
import: ImportJob,
|
||||
token: LightroomLibraryToken,
|
||||
archive_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl LightroomJob {
|
||||
/// Snapshot session state only. This constructor performs no filesystem work.
|
||||
pub fn new(s: &mut crate::Session, path: PathBuf, update_existing: bool) -> crate::Result<Self> {
|
||||
let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let import = ImportJob::new(s, opts)?;
|
||||
let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop"));
|
||||
Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir })
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.import.total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.import.done.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.done(), self.total())
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &LightroomLibraryToken {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn total_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.total)
|
||||
}
|
||||
|
||||
pub fn done_atomic(&self) -> std::sync::Arc<AtomicUsize> {
|
||||
std::sync::Arc::clone(&self.import.done)
|
||||
}
|
||||
|
||||
/// Finish an owner commit's deferred archive-index write on a worker.
|
||||
pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> {
|
||||
finalization.finish()
|
||||
}
|
||||
|
||||
/// Read/probe/stage all source data off the owner thread.
|
||||
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result<PreparedLightroom> {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.total.store(1, Ordering::Relaxed);
|
||||
self.import.done.store(0, Ordering::Relaxed);
|
||||
let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?;
|
||||
lightroom_catalog::validate_for_job(&data).map_err(error)?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
self.import.done.fetch_add(1, Ordering::Relaxed);
|
||||
let paths: Vec<String> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.map(|photo| photo.path.clone())
|
||||
.collect();
|
||||
let missing_sources = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0)
|
||||
.filter(|photo| !Path::new(&photo.path).is_file())
|
||||
.map(|photo| normalize(&photo.path))
|
||||
.collect();
|
||||
let normal = self.import.prepare(&paths, cancel);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
|
||||
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
|
||||
let index = lightroom_catalog::load_index(index_path.as_deref())?;
|
||||
Ok(PreparedLightroom {
|
||||
data,
|
||||
normal,
|
||||
index,
|
||||
archive_path,
|
||||
token: self.token.clone(),
|
||||
missing_sources,
|
||||
update_existing: self.update_existing,
|
||||
now: self.import.now().to_string(),
|
||||
cancelled: cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply prepared data on the owner thread. No source, archive or index filesystem work occurs.
|
||||
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result<LightroomCompletion> {
|
||||
if prepared.cancelled {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import cancelled"));
|
||||
}
|
||||
if !prepared.token.matches(s) {
|
||||
prepared.normal.rollback();
|
||||
return Err(error("Lightroom import belongs to a different open library"));
|
||||
}
|
||||
// A command-scoped Lightroom import must never rewrite source XMP sidecars. Direct depth-0
|
||||
// callers do not set this transient flag, so they cannot affect a later command.
|
||||
if s.depth > 0 {
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
|
||||
let undo0 = s.undo.len();
|
||||
prepared.normal.revalidate_add(s);
|
||||
let options = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() };
|
||||
let now = prepared.now.clone();
|
||||
let files = crate::import::commit_prepared(s, &options, &now, prepared.normal)?;
|
||||
let applied = lightroom_catalog::apply_prepared(
|
||||
s,
|
||||
prepared.data,
|
||||
lightroom_catalog::ApplyPrepared {
|
||||
update_existing: prepared.update_existing,
|
||||
files,
|
||||
index: &mut prepared.index,
|
||||
archive_path: prepared.archive_path,
|
||||
missing_sources: &prepared.missing_sources,
|
||||
undo0,
|
||||
},
|
||||
)?;
|
||||
let path = prepared.token.library_dir().map(|dir| dir.join("Interop/lightroom-index.json"));
|
||||
let bytes = if path.is_some() { Some(serde_json::to_vec(&applied.index).map_err(|e| error(e.to_string()))?) } else { None };
|
||||
Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } })
|
||||
}
|
||||
|
||||
fn normalize(path: &str) -> String {
|
||||
let path = path.replace('\\', "/");
|
||||
if cfg!(windows) { path.to_lowercase() } else { path }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_data() -> CatalogImport {
|
||||
CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled_prepared(s: &crate::Session) -> PreparedLightroom {
|
||||
PreparedLightroom {
|
||||
data: empty_data(),
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepared_for(s: &crate::Session, path: &str, rating: i64) -> PreparedLightroom {
|
||||
let mut image = std::collections::BTreeMap::new();
|
||||
image.insert("id_local".into(), serde_json::json!(1));
|
||||
image.insert("rating".into(), serde_json::json!(rating));
|
||||
image.insert("pick".into(), serde_json::json!(0));
|
||||
image.insert("fileFormat".into(), serde_json::json!("JPG"));
|
||||
image.insert("fileWidth".into(), serde_json::json!(1));
|
||||
image.insert("fileHeight".into(), serde_json::json!(1));
|
||||
PreparedLightroom {
|
||||
data: CatalogImport {
|
||||
source: "fixture.lrcat".into(),
|
||||
photos: vec![lightroom_catalog::CatalogPhoto {
|
||||
source_id: 1,
|
||||
uuid: "fixture-photo".into(),
|
||||
path: path.into(),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path: None,
|
||||
token: LightroomLibraryToken::capture(s),
|
||||
missing_sources: [normalize(path)].into_iter().collect(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_commit_leaves_owner_catalog_unchanged() {
|
||||
let mut session = crate::Session::new();
|
||||
let before = session.catalog.to_snapshot();
|
||||
let prepared = cancelled_prepared(&session);
|
||||
assert!(commit_prepared(&mut session, prepared).is_err());
|
||||
assert_eq!(session.catalog.to_snapshot(), before);
|
||||
assert!(session.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_identity_rejects_late_completion_after_reopen() {
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
assert!(token.matches(&session));
|
||||
session.library_identity = Arc::new(());
|
||||
assert!(!token.matches(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_reimport_preserves_current_edit_and_uses_one_undo_per_import() {
|
||||
let mut session = crate::Session::new();
|
||||
let path = "/missing/lightroom-job-edit.jpg";
|
||||
let prepared = prepared_for(&session, path, 4);
|
||||
let first = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(first.report["mapping"]["1"].as_u64(), Some(1));
|
||||
assert_eq!(session.undo.len(), 1);
|
||||
let id = lightcraft_catalog::PhotoId(1);
|
||||
let mut personal = lightcraft_develop::DevelopSettings::default();
|
||||
personal.light.exposure = 1.5;
|
||||
session.set_develop(id, personal, "Personal").unwrap();
|
||||
let before = session.catalog.photo(id).map(|photo| photo.develop.clone());
|
||||
let prepared = prepared_for(&session, path, 5);
|
||||
commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.develop.clone()), before);
|
||||
assert_eq!(session.catalog.photo(id).map(|photo| photo.rating), Some(5));
|
||||
assert_eq!(session.undo.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_history_archive_keeps_prepared_import_successful_with_warning() {
|
||||
let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id()));
|
||||
let mut data = empty_data();
|
||||
data.photos = (0..5_000)
|
||||
.map(|source_id| {
|
||||
let image = std::collections::BTreeMap::from([
|
||||
("id_local".into(), serde_json::json!(source_id)),
|
||||
("fileFormat".into(), serde_json::json!("JPG")),
|
||||
("fileWidth".into(), serde_json::json!(1)),
|
||||
("fileHeight".into(), serde_json::json!(1)),
|
||||
]);
|
||||
lightroom_catalog::CatalogPhoto {
|
||||
source_id,
|
||||
uuid: format!("scale-{source_id}"),
|
||||
path: format!("/missing/scale-{source_id}.jpg"),
|
||||
image,
|
||||
settings: String::new(),
|
||||
xmp: String::new(),
|
||||
keywords: Vec::new(),
|
||||
history: vec![std::collections::BTreeMap::from([(
|
||||
"text".into(),
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let archive_path = stage_archive(Some(&archive_dir), &mut data).unwrap();
|
||||
assert!(archive_path.is_none());
|
||||
let mut session = crate::Session::new();
|
||||
let token = LightroomLibraryToken::capture(&session);
|
||||
let prepared = PreparedLightroom {
|
||||
data,
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
archive_path,
|
||||
token,
|
||||
missing_sources: HashSet::new(),
|
||||
update_existing: false,
|
||||
now: "2026-01-01T00:00:00".into(),
|
||||
cancelled: false,
|
||||
};
|
||||
let completion = commit_prepared(&mut session, prepared).unwrap();
|
||||
assert_eq!(completion.report["photos"], 5_000);
|
||||
assert!(
|
||||
completion.report["warnings"]
|
||||
.as_array()
|
||||
.is_some_and(|warnings| warnings.iter().any(|warning| warning == "Lightroom source archive exceeded 32 MiB; archive skipped"))
|
||||
);
|
||||
assert_eq!(session.catalog.len(), 5_000);
|
||||
std::fs::remove_dir_all(archive_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_fn_import_does_not_rewrite_source_sidecar() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id()));
|
||||
let path = dir.join("source.jpg");
|
||||
let sidecar = dir.join("source.xmp");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"fixture").unwrap();
|
||||
std::fs::write(&sidecar, b"original sidecar").unwrap();
|
||||
let original = std::fs::read(&sidecar).unwrap();
|
||||
let mut session = crate::Session::new();
|
||||
session.xmp.auto_write = true;
|
||||
let result = session.execute_fn("library.importLightroom", |s| {
|
||||
let prepared = prepared_for(s, &path.to_string_lossy(), 5);
|
||||
let completion = commit_prepared(s, prepared)?;
|
||||
Ok(completion.report)
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,7 @@ impl Lua {
|
|||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
|
|
@ -210,6 +211,15 @@ impl LuaParser<'_> {
|
|||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
if self.depth >= 64 {
|
||||
return self.err("Lua settings exceed nesting limit");
|
||||
}
|
||||
self.depth += 1;
|
||||
let result = self.value_inner();
|
||||
self.depth -= 1;
|
||||
result
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
|
|
@ -315,7 +325,10 @@ impl LuaParser<'_> {
|
|||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
if text.len() > 16 << 20 {
|
||||
return Err("Lua settings exceed 16 MiB".into());
|
||||
}
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0, depth: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
|
|
@ -347,6 +360,12 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
|||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let (props, values) = lua_settings_props(settings)?;
|
||||
Ok((title, props, values))
|
||||
}
|
||||
|
||||
/// Data-only Lua settings shared by preset and native catalog import. Never executes Lua.
|
||||
pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_masks::Values), String> {
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
let mut values = crate::crs_masks::Values::new();
|
||||
|
|
@ -374,7 +393,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs
|
|||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props, values))
|
||||
Ok((props, values))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
|
|
|||
|
|
@ -433,3 +433,70 @@ fn write_policy_timing() {
|
|||
println!("copy {n} × {} MB: byte compare with the source {bytewise:?}, probe hash {hashed:?}", size >> 20);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #367: Import → Copy wrote and verified one file after another on one thread. The copies
|
||||
/// now run side by side, with the names, order, duplicates and reports a one-by-one copy gives:
|
||||
/// like-named files take -1, -2… in file order (also against a file already there and a literal
|
||||
/// `IMG_1-1`), and a file with the content of one copied earlier in the batch is its duplicate
|
||||
/// (also while that copy is still under way).
|
||||
#[test]
|
||||
fn copies_run_side_by_side_with_the_same_outcome() {
|
||||
let base = temp_dir("parallel-copy");
|
||||
let (card, dest) = (base.join("card"), base.join("out"));
|
||||
write_png(&card.join("a/IMG_1.png"), 1);
|
||||
write_png(&card.join("a/IMG_2.png"), 2);
|
||||
write_png(&card.join("b/IMG_1.png"), 3);
|
||||
write_png(&card.join("b/IMG_1-1.png"), 4);
|
||||
write_png(&card.join("c/IMG_1.png"), 5);
|
||||
for i in 0..8u8 {
|
||||
write_png(&card.join(format!("d/p{i}.png")), 10 + i);
|
||||
}
|
||||
// the content of a file whose copy is still under way
|
||||
std::fs::create_dir_all(card.join("e")).unwrap();
|
||||
std::fs::copy(card.join("d/p3.png"), card.join("e/dup.png")).unwrap();
|
||||
std::fs::create_dir_all(&dest).unwrap();
|
||||
std::fs::write(dest.join("IMG_2.png"), b"someone else's file").unwrap();
|
||||
|
||||
let mut s = session();
|
||||
inject(Fault::Slow);
|
||||
let r = s
|
||||
.execute(
|
||||
"library.import",
|
||||
&json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}),
|
||||
)
|
||||
.unwrap();
|
||||
inject(Fault::None);
|
||||
let threads: std::collections::HashSet<_> = crate::import_move::copy_threads().into_iter().collect();
|
||||
if std::thread::available_parallelism().map_or(1, |n| n.get()) > 1 {
|
||||
assert!(threads.len() > 1, "copies ran on {} thread(s)", threads.len());
|
||||
}
|
||||
|
||||
assert_eq!(len(&r, "imported"), 13, "{r}");
|
||||
assert_eq!(len(&r, "failed"), 0, "{r}");
|
||||
assert_eq!(len(&r, "duplicates"), 1, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["reason"], "content");
|
||||
assert!(r["duplicates"][0]["path"].as_str().unwrap().ends_with("dup.png"), "{r}");
|
||||
// the order of a one-by-one copy (files in name order), each verified
|
||||
let expect = [
|
||||
("a/IMG_1.png", "IMG_1.png"),
|
||||
("a/IMG_2.png", "IMG_2-1.png"),
|
||||
("b/IMG_1-1.png", "IMG_1-1.png"),
|
||||
("b/IMG_1.png", "IMG_1-2.png"),
|
||||
("c/IMG_1.png", "IMG_1-3.png"),
|
||||
];
|
||||
for (src, dst) in expect {
|
||||
assert_eq!(std::fs::read(dest.join(dst)).unwrap(), std::fs::read(card.join(src)).unwrap(), "{src} → {dst}");
|
||||
}
|
||||
for i in 0..8 {
|
||||
assert_eq!(std::fs::read(dest.join(format!("p{i}.png"))).unwrap(), std::fs::read(card.join(format!("d/p{i}.png"))).unwrap());
|
||||
}
|
||||
assert_eq!(std::fs::read(dest.join("IMG_2.png")).unwrap(), b"someone else's file");
|
||||
assert_eq!(files_under(&dest).len(), 14, "{:?}", files_under(&dest));
|
||||
// the duplicate names the photo copied from the same content (d/p3); ids follow the file order
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}");
|
||||
assert_eq!(r["duplicates"][0]["existing"], ids[8], "{r}");
|
||||
let first = s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap();
|
||||
assert_eq!(first.file_name, "IMG_1.png");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@
|
|||
"Label Set: {name}": "Farbmarkierungssatz: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt; Index-Warnung: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom-Import fehlgeschlagen: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom-Prüfung fehlgeschlagen: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
|
||||
"Matches: {}": "Treffer: {}",
|
||||
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
|
||||
|
|
@ -86,11 +92,13 @@
|
|||
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
|
||||
"Quick Develop: {name}": "Schnellentwicklung: {name}",
|
||||
"Rated {}": "Bewertet mit {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}",
|
||||
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
|
||||
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
|
||||
"Redo {}": "Wiederholen: {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "„{name}“ mit {count} Foto{} aus der Bibliothek entfernen?",
|
||||
"Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen",
|
||||
"Removed from {}": "Entfernt von {}",
|
||||
"Removed {what} from {name}": "{what} aus {name} entfernt",
|
||||
"Rename {n} Photos…": "{n} Fotos umbenennen…",
|
||||
"Rename “{name}”": "„{name}“ umbenennen",
|
||||
|
|
@ -99,6 +107,7 @@
|
|||
"Replace {} with:": "{} ersetzen durch:",
|
||||
"Reset {name}": "{name} zurücksetzen",
|
||||
"Reset {} Channel": "Kanal {} zurücksetzen",
|
||||
"Restore {}": "{} wiederherstellen",
|
||||
"Restore {} Photo{}": "{} Foto{} wiederherstellen",
|
||||
"Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden",
|
||||
"Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.",
|
||||
"Adding": "Wird hinzugefügt",
|
||||
"Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …",
|
||||
"Advanced search…": "Erweiterte Suche…",
|
||||
"After": "Nachher",
|
||||
"After 30 days": "Nach 30 Tagen",
|
||||
|
|
@ -165,6 +166,7 @@
|
|||
"Blue Primary": "Primärfarbe Blau",
|
||||
"Blue Saturation": "Blau – Sättigung",
|
||||
"Blue channel": "Blaukanal",
|
||||
"Blue label": "Blaue Markierung",
|
||||
"Blue/Yellow Fringe": "Blaue/gelbe Farbsäume",
|
||||
"Blurry photos below the score are flagged as rejects.": "Unscharfe Fotos unterhalb des Grenzwerts werden als abgelehnt markiert.",
|
||||
"Boundary Warp": "Randverkrümmung",
|
||||
|
|
@ -208,6 +210,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Unzulässige Zeichen in Dateinamen (/ \\ : * ? \" < > |) werden zu -; ein vorhandener Name erhält -1, -2….",
|
||||
"Check All": "Alle auswählen",
|
||||
"Check Output Path": "Ausgabepfad prüfen",
|
||||
"Check the files of this type": "Dateien dieses Typs auswählen",
|
||||
"Checking names…": "Namen werden geprüft…",
|
||||
"Choose Another Library…": "Andere Bibliothek auswählen…",
|
||||
"Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen",
|
||||
|
|
@ -229,8 +232,11 @@
|
|||
"Clear Reference": "Referenz entfernen",
|
||||
"Clear all": "Alles löschen",
|
||||
"Clear form": "Formular leeren",
|
||||
"Clear rating": "Bewertung entfernen",
|
||||
"Clear rating and advance": "Bewertung entfernen und weiter",
|
||||
"Cleared History": "Verlauf gelöscht",
|
||||
"Click Zoom Ratio": "Zoomfaktor beim Klicken",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Auf ein Tastenkürzel klicken, dann die neuen Tasten drücken (Esc bricht ab).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Klicke auf einen Platzhalter, um ihn einzufügen. Die Beispiele gelten für IMG_0042.CR3, aufgenommen am 14. Januar 2026 um 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Klicke auf Fotos im Raster, um das Stichwort umzuschalten; Esc beendet den Vorgang",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Objekt zum Auswählen anklicken · ⌥-Klick lässt einen Teil aus",
|
||||
|
|
@ -511,6 +517,7 @@
|
|||
"File Size": "Dateigröße",
|
||||
"File Type": "Dateityp",
|
||||
"File name": "Dateiname",
|
||||
"File types:": "Dateitypen:",
|
||||
"Filename": "Dateiname",
|
||||
"Fill": "Ausfüllen",
|
||||
"Fill Edges": "Ränder füllen",
|
||||
|
|
@ -536,6 +543,7 @@
|
|||
"Fit to width": "An Breite anpassen",
|
||||
"Fit visible": "Sichtbaren Bereich einpassen",
|
||||
"Five stars": "Fünf Sterne",
|
||||
"Fixed keys": "Feste Tasten",
|
||||
"Flag": "Markierung",
|
||||
"Flag as Pick": "Als ausgewählt markieren",
|
||||
"Flag as Reject": "Als abgelehnt markieren",
|
||||
|
|
@ -598,6 +606,7 @@
|
|||
"Green Primary": "Primärfarbe Grün",
|
||||
"Green Saturation": "Grün – Sättigung",
|
||||
"Green channel": "Grünkanal",
|
||||
"Green label": "Grüne Markierung",
|
||||
"Grid": "Raster",
|
||||
"Grid Info": "Rasterinformationen",
|
||||
"Gritty Street": "Raue Straße",
|
||||
|
|
@ -649,7 +658,6 @@
|
|||
"Horizontal": "Horizontal",
|
||||
"Horizontal text": "Horizontaler Text",
|
||||
"Hours": "Stunden",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Hue": "Farbton",
|
||||
"Hue Range": "Farbtonbereich",
|
||||
"ISO Speed": "ISO-Empfindlichkeit",
|
||||
|
|
@ -659,6 +667,8 @@
|
|||
"Image": "Bild",
|
||||
"Import": "Importieren",
|
||||
"Import Date": "Importdatum",
|
||||
"Import Lightroom Catalog": "Lightroom-Katalog importieren",
|
||||
"Import Lightroom Catalog…": "Lightroom-Katalog importieren …",
|
||||
"Import Photos": "Fotos importieren",
|
||||
"Import Photos…": "Fotos importieren…",
|
||||
"Import Point Curve Presets": "Punktkurvenvorgaben importieren",
|
||||
|
|
@ -678,7 +688,10 @@
|
|||
"Imported": "Importiert",
|
||||
"Imported Photos": "Importierte Fotos",
|
||||
"Importing": "Wird importiert",
|
||||
"Importing Lightroom Catalog": "Lightroom-Katalog wird importiert",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Beim Import wird die Quelle nach Fotos durchsucht; der Ordner wird nicht zu „Lokal“ hinzugefügt (verwende Lokal → Ordner durchsuchen…, um ohne Import in einem Ordner zu arbeiten).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "In Fotoraster und quadratischem Raster: als ausgewählt markieren und weiter",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen",
|
||||
"In Recently Deleted": "In „Zuletzt gelöscht“",
|
||||
"In library": "In Bibliothek",
|
||||
"In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert",
|
||||
|
|
@ -694,8 +707,9 @@
|
|||
"Info Overlay: File": "Infoüberlagerung: Datei",
|
||||
"Info overlay": "Infoüberlagerung",
|
||||
"Insert at the cursor": "An Cursorposition einfügen",
|
||||
"Inspect Lightroom Catalog": "Lightroom-Katalog prüfen",
|
||||
"Inspect Photo": "Foto untersuchen",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"Inspecting Lightroom Catalog": "Lightroom-Katalog wird geprüft",
|
||||
"Instant ’70s": "Sofortbild der 70er",
|
||||
"Interface": "Oberfläche",
|
||||
"Interface language": "Sprache der Oberfläche",
|
||||
|
|
@ -762,6 +776,9 @@
|
|||
"LightCraft on GitHub": "LightCraft auf GitHub",
|
||||
"LightCraft website": "LightCraft-Website",
|
||||
"LightCraft — Second Window": "LightCraft — zweites Fenster",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic-Katalog",
|
||||
"Lightroom import cancelled": "Lightroom-Import abgebrochen",
|
||||
"Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen",
|
||||
"Lights": "Helle Mitteltöne",
|
||||
"Limit file size (KB, 0 = off)": "Dateigröße begrenzen (KB, 0 = aus)",
|
||||
"Line delta": "Zeilendifferenz",
|
||||
|
|
@ -921,6 +938,7 @@
|
|||
"No filter presets yet": "Noch keine Filtervorgaben",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).",
|
||||
"No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.",
|
||||
"No matching commands": "Keine passenden Befehle",
|
||||
"No matching photos": "Keine passenden Fotos",
|
||||
"No metadata found": "Keine Metadaten gefunden",
|
||||
"No model credits were built into this copy.": "Diese Version enthält keine Modellangaben.",
|
||||
|
|
@ -1048,6 +1066,7 @@
|
|||
"Presets (Shift+P)": "Vorgaben (Umschalt+P)",
|
||||
"Presets and profiles": "Vorgaben und Profile",
|
||||
"Presets ▾": "Vorgaben ⌄",
|
||||
"Press keys…": "Tasten drücken …",
|
||||
"Preview": "Vorschau",
|
||||
"Preview Build Progress": "Fortschritt der Vorschauerstellung",
|
||||
"Preview only": "Nur Vorschau",
|
||||
|
|
@ -1092,6 +1111,16 @@
|
|||
"Radius": "Radius",
|
||||
"Random": "Zufällig",
|
||||
"Range": "Bereich",
|
||||
"Rate ★": "Mit ★ bewerten",
|
||||
"Rate ★ and advance": "Mit ★ bewerten und weiter",
|
||||
"Rate ★★": "Mit ★★ bewerten",
|
||||
"Rate ★★ and advance": "Mit ★★ bewerten und weiter",
|
||||
"Rate ★★★": "Mit ★★★ bewerten",
|
||||
"Rate ★★★ and advance": "Mit ★★★ bewerten und weiter",
|
||||
"Rate ★★★★": "Mit ★★★★ bewerten",
|
||||
"Rate ★★★★ and advance": "Mit ★★★★ bewerten und weiter",
|
||||
"Rate ★★★★★": "Mit ★★★★★ bewerten",
|
||||
"Rate ★★★★★ and advance": "Mit ★★★★★ bewerten und weiter",
|
||||
"Rating": "Bewertung",
|
||||
"Rating cleared": "Bewertung entfernt",
|
||||
"Ratings & flags": "Bewertungen und Markierungen",
|
||||
|
|
@ -1105,6 +1134,7 @@
|
|||
"Read mode": "Lesemodus",
|
||||
"Read the SAM License": "SAM-Lizenz lesen",
|
||||
"Reading": "Wird gelesen",
|
||||
"Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …",
|
||||
"Reading folder…": "Ordner wird gelesen…",
|
||||
"Real name": "Echter Name",
|
||||
"Recent": "Zuletzt verwendet",
|
||||
|
|
@ -1118,6 +1148,7 @@
|
|||
"Red Primary": "Primärfarbe Rot",
|
||||
"Red Saturation": "Rot – Sättigung",
|
||||
"Red channel": "Rotkanal",
|
||||
"Red label": "Rote Markierung",
|
||||
"Red/Cyan Fringe": "Rote/cyanfarbene Farbsäume",
|
||||
"Redo": "Wiederholen",
|
||||
"Reference": "Referenz",
|
||||
|
|
@ -1158,6 +1189,7 @@
|
|||
"Remove from Target Album": "Aus Zielalbum entfernen",
|
||||
"Remove from survey": "Aus Übersicht entfernen",
|
||||
"Remove location info": "Ortsinformationen entfernen",
|
||||
"Remove shortcut": "Tastenkürzel entfernen",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Diesen Gesichtsrahmen entfernen (rückgängig über Bearbeiten → Rückgängig)",
|
||||
"Remove this filter": "Diesen Filter entfernen",
|
||||
"Remove this group": "Diese Gruppe entfernen",
|
||||
|
|
@ -1184,7 +1216,9 @@
|
|||
"Rendering…": "Wird gerendert…",
|
||||
"Reorder Masks": "Masken umordnen",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Reset All": "Alle zurücksetzen",
|
||||
"Reset All Curves": "Alle Kurven zurücksetzen",
|
||||
"Reset All Keyboard Shortcuts": "Alle Tastenkürzel zurücksetzen",
|
||||
"Reset Before to Import State": "Vorher auf Importzustand zurücksetzen",
|
||||
"Reset Control": "Regler zurücksetzen",
|
||||
"Reset Crop": "Zuschnitt zurücksetzen",
|
||||
|
|
@ -1199,6 +1233,7 @@
|
|||
"Resize to": "Größe ändern auf",
|
||||
"Resolution (ppi)": "Auflösung (ppi)",
|
||||
"Restore": "Wiederherstellen",
|
||||
"Restore (no shortcut)": "Wiederherstellen (kein Tastenkürzel)",
|
||||
"Restore Library from Backup…": "Bibliothek aus Sicherung wiederherstellen…",
|
||||
"Restore Version": "Version wiederherstellen",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Wiederherstellen behält ihre Bearbeitungen; „Neu importieren“ löscht zuerst den alten Eintrag.",
|
||||
|
|
@ -1245,6 +1280,7 @@
|
|||
"Save as set": "Als Satz speichern",
|
||||
"Save as…": "Speichern unter…",
|
||||
"Save preset": "Vorgabe speichern",
|
||||
"Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …",
|
||||
"Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine",
|
||||
"Scale": "Skalierung",
|
||||
"Scan failed": "Durchsuchen fehlgeschlagen",
|
||||
|
|
@ -1253,6 +1289,7 @@
|
|||
"Screen": "Bildschirm",
|
||||
"Search": "Suche",
|
||||
"Search Photos": "Fotos suchen",
|
||||
"Search commands or keys": "Befehle oder Tasten suchen",
|
||||
"Search…": "Suchen…",
|
||||
"Seasons": "Jahreszeiten",
|
||||
"Second Window": "Zweites Fenster",
|
||||
|
|
@ -1283,6 +1320,7 @@
|
|||
"Set Develop Values": "Entwicklungswerte festlegen",
|
||||
"Set Flag": "Markierung festlegen",
|
||||
"Set Image Zoom and Pan": "Bildzoom und -ausschnitt festlegen",
|
||||
"Set Keyboard Shortcut": "Tastenkürzel festlegen",
|
||||
"Set Mask Adjustments": "Maskenkorrekturen festlegen",
|
||||
"Set Point Curve": "Punktkurve festlegen",
|
||||
"Set Profile": "Profil festlegen",
|
||||
|
|
@ -1333,6 +1371,7 @@
|
|||
"Show Pins": "Pins anzeigen",
|
||||
"Show Source": "Quelle anzeigen",
|
||||
"Show Thumbnails": "Miniaturbilder anzeigen",
|
||||
"Show commands without a shortcut": "Befehle ohne Tastenkürzel anzeigen",
|
||||
"Show destination gamut warning": "Zielfarbraumwarnung anzeigen",
|
||||
"Show display gamut warning": "Bildschirmfarbraumwarnung anzeigen",
|
||||
"Show file names": "Dateinamen anzeigen",
|
||||
|
|
@ -1443,6 +1482,7 @@
|
|||
"Text direction": "Textrichtung",
|
||||
"Texture": "Struktur",
|
||||
"That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.",
|
||||
"That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.",
|
||||
"The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek",
|
||||
"The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält",
|
||||
|
|
@ -1457,7 +1497,6 @@
|
|||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
|
||||
"Thirds": "Drittel",
|
||||
"This Computer": "Dieser Computer",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
|
||||
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
|
||||
"Threshold": "Schwellenwert",
|
||||
|
|
@ -1485,6 +1524,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "Aktiviere „Unterordner einbeziehen“ oder wähle unter „Lokal“ einen anderen Ordner",
|
||||
"Type": "Typ",
|
||||
"Uncheck All": "Alle abwählen",
|
||||
"Uncheck the files of this type": "Auswahl der Dateien dieses Typs aufheben",
|
||||
"Undo": "Rückgängig",
|
||||
"Unedited": "Unbearbeitet",
|
||||
"Unedited raws": "Unbearbeitete RAW-Fotos",
|
||||
|
|
@ -1563,6 +1603,7 @@
|
|||
"Yellow Hue": "Gelb – Farbton",
|
||||
"Yellow Luminance": "Gelb – Luminanz",
|
||||
"Yellow Saturation": "Gelb – Sättigung",
|
||||
"Yellow label": "Gelbe Markierung",
|
||||
"Yes": "Ja",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Du kannst dieses Fenster schließen: Der Download läuft weiter und wird nach Unterbrechungen fortgesetzt.",
|
||||
"Your Name": "Dein Name",
|
||||
|
|
@ -1750,5 +1791,8 @@
|
|||
"Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera",
|
||||
"Captured from": "Aufgenommen ab",
|
||||
"Captured until": "Aufgenommen bis",
|
||||
"Only": "Nur diese"
|
||||
"Only": "Nur diese",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@
|
|||
"Label Set: {name}": "Conjunto de etiquetas: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft no pudo abrir su ventana: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft no pudo abrir su ventana: {e}\n\nLos detalles están en el archivo de registro: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo de Lightroom inspeccionado: {photos} fotos, {collections} colecciones, {missing} no encontradas",
|
||||
"Lightroom import complete: {imported} photos added": "Importación de Lightroom completada: {imported} fotos añadidas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importación de Lightroom completada: {imported} fotos añadidas, {failed} con errores",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importación de Lightroom completada: {imported} fotos añadidas; aviso del índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Error al importar de Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Error al inspeccionar Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fuentes: {} (OFL). Iconos: originales.",
|
||||
"Matches: {}": "Coincidencias: {}",
|
||||
"Merging… {stage} {:.0}%": "Combinando… {stage} {:.0}%",
|
||||
|
|
@ -86,11 +92,13 @@
|
|||
"Quick Develop · {n} photos": "Revelado rápido · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelado rápido: {name}",
|
||||
"Rated {}": "Valorada con {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Leyendo catálogo de Lightroom… {done} de {total}",
|
||||
"Reading photos… {done} of {total}": "Leyendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Leyendo fotos… {} de {}",
|
||||
"Redo {}": "Rehacer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "¿Quitar “{name}” y sus {count} foto{} de la biblioteca?",
|
||||
"Remove “{}” from Favorites": "Quitar “{}” de Favoritos",
|
||||
"Removed from {}": "Quitado de {}",
|
||||
"Removed {what} from {name}": "{what} quitado de {name}",
|
||||
"Rename {n} Photos…": "Cambiar nombre de {n} fotos…",
|
||||
"Rename “{name}”": "Cambiar nombre de “{name}”",
|
||||
|
|
@ -99,6 +107,7 @@
|
|||
"Replace {} with:": "Reemplazar {} por:",
|
||||
"Reset {name}": "Restablecer {name}",
|
||||
"Reset {} Channel": "Restablecer canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{}",
|
||||
"Saved export preset “{}”": "Ajuste preestablecido de exportación “{}” guardado",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Puntúa el enfoque y la exposición de {scope} y busca tomas similares hechas con pocos segundos de diferencia (ráfagas).",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"Add/Subtract/Intersect Mask": "Añadir/restar/intersecar máscara",
|
||||
"Added to photos you import that don't already have it.": "Se añade a las fotos importadas que aún no lo tienen.",
|
||||
"Adding": "Añadiendo",
|
||||
"Adding Lightroom photos…": "Añadiendo fotos de Lightroom…",
|
||||
"Advanced search…": "Búsqueda avanzada…",
|
||||
"After": "Después",
|
||||
"After 30 days": "Después de 30 días",
|
||||
|
|
@ -148,6 +149,7 @@
|
|||
"Blue Primary": "Azul primario",
|
||||
"Blue Saturation": "Saturación azul",
|
||||
"Blue channel": "Canal azul",
|
||||
"Blue label": "Etiqueta azul",
|
||||
"Blue/Yellow Fringe": "Franja azul/amarilla",
|
||||
"Blurry photos below the score are flagged as rejects.": "Las fotos borrosas por debajo de la puntuación se marcan como rechazadas.",
|
||||
"Boundary Warp": "Deformación del contorno",
|
||||
|
|
@ -188,6 +190,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Los caracteres no permitidos en nombres de archivo (/ \\ : * ? \" < > |) se convierten en -; un nombre existente recibe -1, -2….",
|
||||
"Check All": "Marcar todo",
|
||||
"Check Output Path": "Comprobar ruta de salida",
|
||||
"Check the files of this type": "Marcar los archivos de este tipo",
|
||||
"Checking names…": "Comprobando nombres…",
|
||||
"Choose Another Library…": "Elegir otra biblioteca…",
|
||||
"Choose Edit Settings to Copy": "Elegir ajustes de edición para copiar",
|
||||
|
|
@ -209,8 +212,11 @@
|
|||
"Clear Reference": "Borrar referencia",
|
||||
"Clear all": "Borrar todo",
|
||||
"Clear form": "Borrar formulario",
|
||||
"Clear rating": "Borrar valoración",
|
||||
"Clear rating and advance": "Borrar valoración y avanzar",
|
||||
"Cleared History": "Historial borrado",
|
||||
"Click Zoom Ratio": "Proporción de zoom al hacer clic",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Hacer clic en un método abreviado y pulsar las nuevas teclas (Esc cancela).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Hacer clic en una etiqueta para insertarla. Los ejemplos corresponden a IMG_0042.CR3, tomada el 14 ene 2026, 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Hacer clic en las fotos de la cuadrícula para activar o desactivar la palabra clave; Esc para terminar",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Hacer clic en el objeto para seleccionarlo · ⌥-clic excluye una parte",
|
||||
|
|
@ -478,6 +484,7 @@
|
|||
"File Size": "Tamaño del archivo",
|
||||
"File Type": "Tipo de archivo",
|
||||
"File name": "Nombre de archivo",
|
||||
"File types:": "Tipos de archivo:",
|
||||
"Filename": "Nombre de archivo",
|
||||
"Fill": "Rellenar",
|
||||
"Fill Edges": "Rellenar bordes",
|
||||
|
|
@ -503,6 +510,7 @@
|
|||
"Fit to width": "Ajustar al ancho",
|
||||
"Fit visible": "Ajustar a lo visible",
|
||||
"Five stars": "Cinco estrellas",
|
||||
"Fixed keys": "Teclas fijas",
|
||||
"Flag": "Marca",
|
||||
"Flag as Pick": "Marcar como seleccionada",
|
||||
"Flag as Reject": "Marcar como rechazada",
|
||||
|
|
@ -565,6 +573,7 @@
|
|||
"Green Primary": "Verde primario",
|
||||
"Green Saturation": "Saturación verde",
|
||||
"Green channel": "Canal verde",
|
||||
"Green label": "Etiqueta verde",
|
||||
"Grid": "Cuadrícula",
|
||||
"Grid Info": "Información de la cuadrícula",
|
||||
"Gritty Street": "Calle cruda",
|
||||
|
|
@ -625,6 +634,8 @@
|
|||
"Image": "Imagen",
|
||||
"Import": "Importar",
|
||||
"Import Date": "Fecha de importación",
|
||||
"Import Lightroom Catalog": "Importar catálogo de Lightroom",
|
||||
"Import Lightroom Catalog…": "Importar catálogo de Lightroom…",
|
||||
"Import Photos": "Importar fotos",
|
||||
"Import Photos…": "Importar fotos…",
|
||||
"Import Point Curve Presets": "Importar ajustes preestablecidos de curva de puntos",
|
||||
|
|
@ -640,7 +651,10 @@
|
|||
"Imported": "Importadas",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
"Importing Lightroom Catalog": "Importando catálogo de Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "La importación busca fotos en el origen; no añade la carpeta a Local (Local → Explorar carpeta… permite trabajar en una carpeta sin importar).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "En Cuadrícula de fotos y Cuadrícula de cuadrados: marcar como seleccionada y avanzar",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "En Cuadrícula de fotos y Cuadrícula de cuadrados: expandir o contraer la pila",
|
||||
"In Recently Deleted": "En Eliminadas recientemente",
|
||||
"In library": "En la biblioteca",
|
||||
"In memory — nothing is saved": "En memoria: no se guarda nada",
|
||||
|
|
@ -656,7 +670,9 @@
|
|||
"Info Overlay: File": "Superposición de información: archivo",
|
||||
"Info overlay": "Superposición de información",
|
||||
"Insert at the cursor": "Insertar en el cursor",
|
||||
"Inspect Lightroom Catalog": "Inspeccionar catálogo de Lightroom",
|
||||
"Inspect Photo": "Inspeccionar foto",
|
||||
"Inspecting Lightroom Catalog": "Inspeccionando catálogo de Lightroom",
|
||||
"Install the SAM 3 Model": "Instalar el modelo SAM 3",
|
||||
"Instant ’70s": "Instantánea de los 70",
|
||||
"Interface": "Interfaz",
|
||||
|
|
@ -721,6 +737,9 @@
|
|||
"LightCraft on GitHub": "LightCraft en GitHub",
|
||||
"LightCraft website": "Sitio web de LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft: segunda ventana",
|
||||
"Lightroom Classic Catalog": "Catálogo de Lightroom Classic",
|
||||
"Lightroom import cancelled": "Importación de Lightroom cancelada",
|
||||
"Lightroom inspection cancelled": "Inspección de Lightroom cancelada",
|
||||
"Lights": "Claros",
|
||||
"Limit file size (KB, 0 = off)": "Limitar tamaño de archivo (KB, 0 = desactivado)",
|
||||
"Line delta": "Diferencia de líneas",
|
||||
|
|
@ -876,6 +895,7 @@
|
|||
"No filter presets yet": "Aún no hay ajustes preestablecidos de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.",
|
||||
"No matching commands": "Ningún comando coincide",
|
||||
"No matching photos": "No hay fotos coincidentes",
|
||||
"No metadata found": "No se encontraron metadatos",
|
||||
"No model credits were built into this copy.": "Esta copia no incluye créditos de modelos.",
|
||||
|
|
@ -999,6 +1019,7 @@
|
|||
"Presets (Shift+P)": "Ajustes preestablecidos (Mayús+P)",
|
||||
"Presets and profiles": "Ajustes preestablecidos y perfiles",
|
||||
"Presets ▾": "Ajustes preestablecidos ▾",
|
||||
"Press keys…": "Pulsar teclas…",
|
||||
"Preview": "Vista previa",
|
||||
"Preview Build Progress": "Progreso de generación de vistas previas",
|
||||
"Preview only": "Solo vista previa",
|
||||
|
|
@ -1042,6 +1063,16 @@
|
|||
"Radius": "Radio",
|
||||
"Random": "Aleatorio",
|
||||
"Range": "Rango",
|
||||
"Rate ★": "Valorar con ★",
|
||||
"Rate ★ and advance": "Valorar con ★ y avanzar",
|
||||
"Rate ★★": "Valorar con ★★",
|
||||
"Rate ★★ and advance": "Valorar con ★★ y avanzar",
|
||||
"Rate ★★★": "Valorar con ★★★",
|
||||
"Rate ★★★ and advance": "Valorar con ★★★ y avanzar",
|
||||
"Rate ★★★★": "Valorar con ★★★★",
|
||||
"Rate ★★★★ and advance": "Valorar con ★★★★ y avanzar",
|
||||
"Rate ★★★★★": "Valorar con ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Valorar con ★★★★★ y avanzar",
|
||||
"Rating": "Valoración",
|
||||
"Rating cleared": "Valoración eliminada",
|
||||
"Ratings & flags": "Valoraciones y marcas",
|
||||
|
|
@ -1055,6 +1086,7 @@
|
|||
"Read mode": "Modo de lectura",
|
||||
"Read the SAM License": "Leer la licencia de SAM",
|
||||
"Reading": "Leyendo",
|
||||
"Reading Lightroom catalog…": "Leyendo catálogo de Lightroom…",
|
||||
"Reading folder…": "Leyendo carpeta…",
|
||||
"Real name": "Nombre real",
|
||||
"Recent": "Recientes",
|
||||
|
|
@ -1068,6 +1100,7 @@
|
|||
"Red Primary": "Rojo primario",
|
||||
"Red Saturation": "Saturación roja",
|
||||
"Red channel": "Canal rojo",
|
||||
"Red label": "Etiqueta roja",
|
||||
"Red/Cyan Fringe": "Franja roja/cian",
|
||||
"Redo": "Rehacer",
|
||||
"Reference": "Referencia",
|
||||
|
|
@ -1108,6 +1141,7 @@
|
|||
"Remove from Target Album": "Quitar del álbum de destino",
|
||||
"Remove from survey": "Quitar de Encuesta",
|
||||
"Remove location info": "Quitar información de ubicación",
|
||||
"Remove shortcut": "Quitar método abreviado",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Quitar este recuadro de cara (deshacer con Editar ▸ Deshacer)",
|
||||
"Remove this filter": "Quitar este filtro",
|
||||
"Remove this group": "Quitar este grupo",
|
||||
|
|
@ -1131,7 +1165,9 @@
|
|||
"Rendering…": "Renderizando…",
|
||||
"Reorder Masks": "Reordenar máscaras",
|
||||
"Reset": "Restablecer",
|
||||
"Reset All": "Restablecer todo",
|
||||
"Reset All Curves": "Restablecer todas las curvas",
|
||||
"Reset All Keyboard Shortcuts": "Restablecer todos los métodos abreviados de teclado",
|
||||
"Reset Before to Import State": "Restablecer Antes al estado de importación",
|
||||
"Reset Control": "Restablecer control",
|
||||
"Reset Crop": "Restablecer recorte",
|
||||
|
|
@ -1146,6 +1182,7 @@
|
|||
"Resize to": "Cambiar tamaño a",
|
||||
"Resolution (ppi)": "Resolución (ppp)",
|
||||
"Restore": "Restaurar",
|
||||
"Restore (no shortcut)": "Restaurar (sin método abreviado)",
|
||||
"Restore Library from Backup…": "Restaurar biblioteca desde copia de seguridad…",
|
||||
"Restore Version": "Restaurar versión",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Restaurar conserva sus ediciones; Importar como nuevas elimina antes el registro anterior.",
|
||||
|
|
@ -1192,6 +1229,7 @@
|
|||
"Save as set": "Guardar como conjunto",
|
||||
"Save as…": "Guardar como…",
|
||||
"Save preset": "Guardar ajuste preestablecido",
|
||||
"Saving Lightroom import index…": "Guardando índice de importación de Lightroom…",
|
||||
"Scale": "Escala",
|
||||
"Scan failed": "Error en la exploración",
|
||||
"Scanning": "Explorando",
|
||||
|
|
@ -1199,6 +1237,7 @@
|
|||
"Screen": "Pantalla",
|
||||
"Search": "Buscar",
|
||||
"Search Photos": "Buscar fotos",
|
||||
"Search commands or keys": "Buscar comandos o teclas",
|
||||
"Search…": "Buscar…",
|
||||
"Seasons": "Estaciones",
|
||||
"Second Window": "Segunda ventana",
|
||||
|
|
@ -1229,6 +1268,7 @@
|
|||
"Set Develop Values": "Definir valores de revelado",
|
||||
"Set Flag": "Definir marca",
|
||||
"Set Image Zoom and Pan": "Definir zoom y desplazamiento de la imagen",
|
||||
"Set Keyboard Shortcut": "Definir método abreviado de teclado",
|
||||
"Set Mask Adjustments": "Definir ajustes de máscara",
|
||||
"Set Point Curve": "Definir curva de puntos",
|
||||
"Set Profile": "Definir perfil",
|
||||
|
|
@ -1278,6 +1318,7 @@
|
|||
"Show Pins": "Mostrar chinchetas",
|
||||
"Show Source": "Mostrar origen",
|
||||
"Show Thumbnails": "Mostrar miniaturas",
|
||||
"Show commands without a shortcut": "Mostrar comandos sin método abreviado",
|
||||
"Show destination gamut warning": "Mostrar aviso de gama de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gama de la pantalla",
|
||||
"Show file names": "Mostrar nombres de archivo",
|
||||
|
|
@ -1382,6 +1423,7 @@
|
|||
"Text direction": "Dirección del texto",
|
||||
"Texture": "Textura",
|
||||
"That includes the photos in the folders inside it.": "Esto incluye las fotos de las carpetas que contiene.",
|
||||
"That key can't be used as a shortcut": "Esa tecla no se puede usar como método abreviado",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "El modelo SAM 3 está instalado: las máscaras Objeto y Describir están listas.",
|
||||
"The download didn't work:": "La descarga no funcionó:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "El número al final del nombre original (IMG_0042 → 0042)",
|
||||
|
|
@ -1421,6 +1463,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "Activar Incluir subcarpetas o elegir otra carpeta en Local",
|
||||
"Type": "Tipo",
|
||||
"Uncheck All": "Desmarcar todo",
|
||||
"Uncheck the files of this type": "Desmarcar los archivos de este tipo",
|
||||
"Undo": "Deshacer",
|
||||
"Unedited": "Sin editar",
|
||||
"Unedited raws": "RAW sin editar",
|
||||
|
|
@ -1497,6 +1540,7 @@
|
|||
"Yellow Hue": "Tono amarillo",
|
||||
"Yellow Luminance": "Luminancia amarilla",
|
||||
"Yellow Saturation": "Saturación amarilla",
|
||||
"Yellow label": "Etiqueta amarilla",
|
||||
"Yes": "Sí",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Esta ventana se puede cerrar: la descarga continúa y se reanuda si se interrumpe.",
|
||||
"Your Name": "Nombre y apellidos",
|
||||
|
|
|
|||
|
|
@ -50,13 +50,16 @@
|
|||
"Proof Preview · {}": "校正プレビュー・{}",
|
||||
"Quick Develop · {n} photos": "クイック現像・{n}枚",
|
||||
"Rated {}": "評価:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lightroom カタログを読み取り中… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
|
||||
"Redo {}": "やり直す:{}",
|
||||
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
|
||||
"Removed from {}": "{} から削除しました",
|
||||
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Restore {}": "{} に戻す",
|
||||
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
|
|
@ -114,6 +117,12 @@
|
|||
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
|
||||
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom カタログを確認しました:写真 {photos} 枚、コレクション {collections} 件、見つからないファイル {missing} 件",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom の読み込みが完了しました:{imported} 枚の写真を追加",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom の読み込みが完了しました:{imported} 枚を追加、{failed} 枚が失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom の読み込みが完了しました:{imported} 枚を追加。インデックスの警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom の読み込みに失敗しました:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom の確認に失敗しました:{e}{warning}",
|
||||
"Label Set: {name}": "ラベルセット:{name}",
|
||||
"Quick Develop: {name}": "クイック現像: {name}",
|
||||
"Reset {name}": "{name}をリセット",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"Add/Subtract/Intersect Mask": "マスクを追加/減算/交差",
|
||||
"Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します",
|
||||
"Adding": "追加中",
|
||||
"Adding Lightroom photos…": "Lightroom の写真を追加中…",
|
||||
"After": "編集後",
|
||||
"After 30 days": "30日後",
|
||||
"After 7 days": "7日後",
|
||||
|
|
@ -149,6 +150,7 @@
|
|||
"Blue Primary": "ブルー原色",
|
||||
"Blue Saturation": "ブルーの彩度",
|
||||
"Blue channel": "ブルーチャンネル",
|
||||
"Blue label": "ブルーラベル",
|
||||
"Blue/Yellow Fringe": "青/黄のフリンジ",
|
||||
"Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます",
|
||||
"Boundary Warp": "境界の変形",
|
||||
|
|
@ -190,6 +192,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ファイル名に使えない文字(/ \\ : * ? \" < > |)は - に置き換え、既存の名前と重なる場合は -1、-2… を付けます",
|
||||
"Check All": "すべて選択",
|
||||
"Check Output Path": "書き出し先のパスを確認",
|
||||
"Check the files of this type": "この種類のファイルをすべてチェック",
|
||||
"Checking names…": "名前を確認中…",
|
||||
"Choose Edit Settings to Copy": "コピーする編集設定を選択",
|
||||
"Choose Edit Settings to Copy…": "コピーする編集設定を選択…",
|
||||
|
|
@ -209,8 +212,11 @@
|
|||
"Clear Quick Collection": "クイックコレクションを空にする",
|
||||
"Clear Reference": "参照写真を解除",
|
||||
"Clear all": "すべて解除",
|
||||
"Clear rating": "評価を解除",
|
||||
"Clear rating and advance": "評価を解除して次へ",
|
||||
"Cleared History": "履歴を消去しました",
|
||||
"Click Zoom Ratio": "クリック時の拡大率",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "ショートカットをクリックして新しいキーを押します(Esc でキャンセル)。",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "タグをクリックすると挿入できます。例は2026年1月14日05:58:48撮影のIMG_0042.CR3です",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "グリッドの写真をクリックしてキーワードを付け外し(Escで終了)",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "オブジェクトをクリックして選択(⌥クリックで一部を除外)",
|
||||
|
|
@ -458,6 +464,7 @@
|
|||
"File Path": "ファイルの場所",
|
||||
"File Size": "ファイルサイズ",
|
||||
"File Type": "ファイルの種類",
|
||||
"File types:": "ファイルの種類:",
|
||||
"Filename": "ファイル名",
|
||||
"Fill": "画面いっぱい",
|
||||
"Film": "フィルム",
|
||||
|
|
@ -466,6 +473,7 @@
|
|||
"First": "最初",
|
||||
"First commit": "最初のコミット",
|
||||
"Five stars": "星5つ",
|
||||
"Fixed keys": "固定キー",
|
||||
"Flagged as Pick": "採用フラグ付き",
|
||||
"Flagged as Reject": "不採用フラグ付き",
|
||||
"Focal Length": "焦点距離",
|
||||
|
|
@ -483,6 +491,7 @@
|
|||
"Golden Glow": "ゴールデングロー",
|
||||
"Golden Hour": "ゴールデンアワー",
|
||||
"Grab bag": "名前の一覧",
|
||||
"Green label": "グリーンラベル",
|
||||
"Gritty Street": "ざらついたストリート",
|
||||
"Group by Date: Automatic": "日付でグループ化:自動",
|
||||
"Group name": "グループ名",
|
||||
|
|
@ -499,21 +508,26 @@
|
|||
"High Contrast B&W": "ハイコントラスト白黒",
|
||||
"Home": "ホーム",
|
||||
"Hours": "時間",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"ISO Speed": "ISO感度",
|
||||
"ISO speed": "ISO感度",
|
||||
"Import Lightroom Catalog": "Lightroom カタログを読み込む",
|
||||
"Import Lightroom Catalog…": "Lightroom カタログを読み込む…",
|
||||
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
|
||||
"Import Profiles": "プロファイルを読み込む",
|
||||
"Import as new": "新規として読み込む",
|
||||
"Imported": "読み込み済み",
|
||||
"Importing Lightroom Catalog": "Lightroom カタログを読み込み中",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "写真グリッドと正方形グリッドでは:採用フラグを付けて次へ",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "写真グリッドと正方形グリッドでは:スタックを展開または折りたたむ",
|
||||
"Increase Brush Feather": "ブラシのぼかしを大きく",
|
||||
"Increase Brush Size": "ブラシのサイズを大きく",
|
||||
"Info Overlay Off": "情報オーバーレイなし",
|
||||
"Info Overlay: Exposure": "情報オーバーレイ:露出",
|
||||
"Info Overlay: File": "情報オーバーレイ:ファイル",
|
||||
"Insert at the cursor": "カーソル位置に挿入",
|
||||
"Inspect Lightroom Catalog": "Lightroom カタログを確認",
|
||||
"Inspect Photo": "写真を調べる",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"Inspecting Lightroom Catalog": "Lightroom カタログを確認中",
|
||||
"Instant ’70s": "インスタント’70s",
|
||||
"Invert Component": "構成要素を反転",
|
||||
"JPEG (Large)": "JPEG(大)",
|
||||
|
|
@ -534,6 +548,9 @@
|
|||
"Library saved": "ライブラリを保存しました",
|
||||
"Licence:": "ライセンス:",
|
||||
"LightCraft could not start": "LightCraftを起動できませんでした",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic カタログ",
|
||||
"Lightroom import cancelled": "Lightroom の読み込みをキャンセルしました",
|
||||
"Lightroom inspection cancelled": "Lightroom の確認をキャンセルしました",
|
||||
"Line delta": "行数の増減",
|
||||
"Linear": "リニア",
|
||||
"Lines +/−": "行数 +/−",
|
||||
|
|
@ -590,6 +607,7 @@
|
|||
"Next Photo": "次の写真",
|
||||
"Night Blue": "ナイトブルー",
|
||||
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
|
||||
"No matching commands": "一致するコマンドはありません",
|
||||
"No model credits were built into this copy.": "このビルドにはモデルのクレジットが含まれていません",
|
||||
"No named people in these photos": "これらの写真に名前付きの人物はいません",
|
||||
"No named people yet": "名前付きの人物はまだいません",
|
||||
|
|
@ -619,6 +637,7 @@
|
|||
"Pictures": "ピクチャ",
|
||||
"Prepare AI Masks": "AIマスクを準備",
|
||||
"Presets and profiles": "プリセットとプロファイル",
|
||||
"Press keys…": "キーを押してください…",
|
||||
"Preview Build Progress": "プレビュー作成の進行状況",
|
||||
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
|
||||
"Previous Photo": "前の写真",
|
||||
|
|
@ -632,12 +651,24 @@
|
|||
"RAW decoding": "RAWデコード",
|
||||
"Radial": "円形",
|
||||
"Random": "ランダム",
|
||||
"Rate ★": "評価 ★",
|
||||
"Rate ★ and advance": "評価 ★ にして次へ",
|
||||
"Rate ★★": "評価 ★★",
|
||||
"Rate ★★ and advance": "評価 ★★ にして次へ",
|
||||
"Rate ★★★": "評価 ★★★",
|
||||
"Rate ★★★ and advance": "評価 ★★★ にして次へ",
|
||||
"Rate ★★★★": "評価 ★★★★",
|
||||
"Rate ★★★★ and advance": "評価 ★★★★ にして次へ",
|
||||
"Rate ★★★★★": "評価 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評価 ★★★★★ にして次へ",
|
||||
"Rating cleared": "評価を解除しました",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "SAMライセンスを読む",
|
||||
"Reading Lightroom catalog…": "Lightroom カタログを読み取り中…",
|
||||
"Real name": "本名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用したキーワード",
|
||||
"Red label": "レッドラベル",
|
||||
"Refine": "微調整",
|
||||
"Refine AI Mask Detail": "AIマスクの細部を微調整",
|
||||
"Refine Edges": "境界を微調整",
|
||||
|
|
@ -652,6 +683,7 @@
|
|||
"Remove Face Box": "顔の枠を除去",
|
||||
"Remove a filter above, or choose Clear all": "上のフィルターを解除するか、「すべて解除」を選択してください",
|
||||
"Remove from Target Album": "追加先アルバムから除去",
|
||||
"Remove shortcut": "ショートカットを削除",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "この顔の枠を除去(「編集 ▸ 取り消す」で元に戻せます)",
|
||||
"Rename Component": "構成要素の名前を変更",
|
||||
"Rename Folder": "フォルダ名を変更",
|
||||
|
|
@ -660,6 +692,8 @@
|
|||
"Rename Preview": "名前変更のプレビュー",
|
||||
"Rename Template Tags": "名前変更テンプレートのタグ",
|
||||
"Reorder Masks": "マスクを並べ替え",
|
||||
"Reset All": "すべてリセット",
|
||||
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
|
||||
"Reshuffle": "シャッフルし直す",
|
||||
"File name": "ファイル名",
|
||||
"Fill Edges": "境界を補完",
|
||||
|
|
@ -1090,6 +1124,7 @@
|
|||
"Resize to": "サイズ変更",
|
||||
"Resolution (ppi)": "解像度(ppi)",
|
||||
"Restore": "復元",
|
||||
"Restore (no shortcut)": "元に戻す(ショートカットなし)",
|
||||
"Restore Version": "バージョンを復元",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。",
|
||||
"Restore them": "復元する",
|
||||
|
|
@ -1131,6 +1166,7 @@
|
|||
"Save as Smart Album…": "スマートアルバムとして保存…",
|
||||
"Save as set": "セットとして保存",
|
||||
"Save preset": "プリセットを保存",
|
||||
"Saving Lightroom import index…": "Lightroom 読み込みインデックスを保存中…",
|
||||
"Scale": "拡大率",
|
||||
"Scan failed": "スキャンに失敗しました",
|
||||
"Scanning": "検索中",
|
||||
|
|
@ -1138,6 +1174,7 @@
|
|||
"Screen": "画面用",
|
||||
"Search": "検索",
|
||||
"Search Photos": "写真を検索",
|
||||
"Search commands or keys": "コマンドまたはキーを検索",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第2ウィンドウ",
|
||||
"Select": "選択",
|
||||
|
|
@ -1167,6 +1204,7 @@
|
|||
"Set Develop Values": "現像設定を変更",
|
||||
"Set Flag": "フラグを設定",
|
||||
"Set Image Zoom and Pan": "画像のズームとパンを設定",
|
||||
"Set Keyboard Shortcut": "キーボードショートカットを設定",
|
||||
"Set Mask Adjustments": "マスクの調整を設定",
|
||||
"Set Point Curve": "ポイントカーブを変更",
|
||||
"Set Profile": "プロファイルを設定",
|
||||
|
|
@ -1216,6 +1254,7 @@
|
|||
"Show Pins": "ピンを表示",
|
||||
"Show Source": "コピー元を表示",
|
||||
"Show Thumbnails": "サムネイルを表示",
|
||||
"Show commands without a shortcut": "ショートカットのないコマンドも表示",
|
||||
"Show destination gamut warning": "出力先の色域警告を表示",
|
||||
"Show display gamut warning": "ディスプレイの色域警告を表示",
|
||||
"Show file names": "ファイル名を表示",
|
||||
|
|
@ -1313,6 +1352,7 @@
|
|||
"Template": "テンプレート",
|
||||
"Text": "文字",
|
||||
"Texture": "テクスチャ",
|
||||
"That key can't be used as a shortcut": "そのキーはショートカットに使えません",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3モデルはインストール済みです。オブジェクトマスクと説明マスクを使用できます",
|
||||
"The download didn't work:": "ダウンロードに失敗しました:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "元の名前の末尾の番号(IMG_0042 → 0042)",
|
||||
|
|
@ -1321,7 +1361,6 @@
|
|||
"The photo has these settings": "この写真に適用されている設定",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
|
||||
"Thirds": "三分割",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません",
|
||||
"This photo can't be opened": "この写真を開けません",
|
||||
"Threshold": "しきい値",
|
||||
|
|
@ -1345,6 +1384,7 @@
|
|||
"Turn Off": "無効にする",
|
||||
"Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください",
|
||||
"Uncheck All": "すべて選択解除",
|
||||
"Uncheck the files of this type": "この種類のファイルのチェックをすべて外す",
|
||||
"Undo": "取り消す",
|
||||
"Unedited": "未編集",
|
||||
"Unedited raws": "未編集のRAW",
|
||||
|
|
@ -1418,6 +1458,7 @@
|
|||
"Yellow Hue": "イエローの色相",
|
||||
"Yellow Luminance": "イエローの輝度",
|
||||
"Yellow Saturation": "イエローの彩度",
|
||||
"Yellow label": "イエローラベル",
|
||||
"Yes": "はい",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "このウィンドウは閉じてもかまいません。ダウンロードは続行され、中断しても再開します",
|
||||
"Your Name": "あなたの名前",
|
||||
|
|
@ -1595,5 +1636,8 @@
|
|||
"≤ at most": "≤ 以下",
|
||||
"≥ at least": "≥ 以上",
|
||||
"日本語": "日本語",
|
||||
"繁體中文(台灣)": "繁體中文(台灣)"
|
||||
"繁體中文(台灣)": "繁體中文(台灣)",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@
|
|||
"Imported {n} preset{}": "Importadas {n} predefinição{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível",
|
||||
"Imported {} photo{}": "Importadas {} foto{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catálogo do Lightroom inspecionado: {photos} fotos, {collections} coleções, {missing} ausentes",
|
||||
"Lightroom import complete: {imported} photos added": "Importação do Lightroom concluída: {imported} fotos adicionadas",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Importação do Lightroom concluída: {imported} fotos adicionadas, {failed} com falha",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Importação do Lightroom concluída: {imported} fotos adicionadas; aviso do índice: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Falha na importação do Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Falha na inspeção do Lightroom: {e}{warning}",
|
||||
"Label Set: {name}": "Conjunto de rótulos: {name}",
|
||||
"LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
|
|
@ -62,16 +68,19 @@
|
|||
"Quick Develop · {n} photos": "Revelação rápida · {n} fotos",
|
||||
"Quick Develop: {name}": "Revelação rápida: {name}",
|
||||
"Rated {}": "Avaliada {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Lendo catálogo do Lightroom… {done} de {total}",
|
||||
"Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Lendo fotos… {} de {}",
|
||||
"Redo {}": "Refazer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?",
|
||||
"Remove “{}” from Favorites": "Remover “{}” dos favoritos",
|
||||
"Removed from {}": "Removido de {}",
|
||||
"Rename {n} Photos…": "Renomear {n} fotos…",
|
||||
"Rename “{name}”": "Renomear “{name}”",
|
||||
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
|
||||
"Reset {name}": "Redefinir {name}",
|
||||
"Reset {} Channel": "Redefinir canal {}",
|
||||
"Restore {}": "Restaurar {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
|
||||
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
|
||||
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"Add/Subtract/Intersect Mask": "Adicionar/Subtrair/Interseção de máscara",
|
||||
"Added to photos you import that don't already have it.": "Adicionado às fotos importadas que ainda não têm isso.",
|
||||
"Adding": "Adicionando",
|
||||
"Adding Lightroom photos…": "Adicionando fotos do Lightroom…",
|
||||
"Advanced search…": "Busca avançada…",
|
||||
"After": "Depois",
|
||||
"After 30 days": "Após 30 dias",
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
"Blue Primary": "Azul primário",
|
||||
"Blue Saturation": "Saturação do azul",
|
||||
"Blue channel": "Canal azul",
|
||||
"Blue label": "Rótulo azul",
|
||||
"Blue/Yellow Fringe": "Franja azul/amarela",
|
||||
"Blurry photos below the score are flagged as rejects.": "Fotos desfocadas abaixo da pontuação são marcadas como rejeitadas.",
|
||||
"Boundary Warp": "Deformação de borda",
|
||||
|
|
@ -195,6 +197,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Caracteres não permitidos em nomes de arquivo (/ \\ : * ? \" < > |) viram -; um nome já existente recebe -1, -2….",
|
||||
"Check All": "Marcar todos",
|
||||
"Check Output Path": "Verificar caminho de saída",
|
||||
"Check the files of this type": "Marcar os arquivos deste tipo",
|
||||
"Checking names…": "Verificando nomes…",
|
||||
"Choose Another Library…": "Escolher outra biblioteca…",
|
||||
"Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar",
|
||||
|
|
@ -216,8 +219,11 @@
|
|||
"Clear Reference": "Limpar referência",
|
||||
"Clear all": "Limpar tudo",
|
||||
"Clear form": "Limpar formulário",
|
||||
"Clear rating": "Limpar avaliação",
|
||||
"Clear rating and advance": "Limpar avaliação e avançar",
|
||||
"Cleared History": "Histórico limpo",
|
||||
"Click Zoom Ratio": "Proporção de zoom por clique",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Clique em um atalho e pressione as novas teclas (Esc cancela).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Clique em uma tag para inseri-la. Os exemplos são de IMG_0042.CR3 tirada em 14 de jan. de 2026, 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Clique nas fotos da grade para alternar a palavra-chave; Esc encerra",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "Clique no objeto para selecioná-lo · ⌥-clique deixa uma parte de fora",
|
||||
|
|
@ -485,6 +491,7 @@
|
|||
"File Size": "Tamanho do arquivo",
|
||||
"File Type": "Tipo de arquivo",
|
||||
"File name": "Nome do arquivo",
|
||||
"File types:": "Tipos de arquivo:",
|
||||
"Filename": "Nome do arquivo",
|
||||
"Fill": "Preencher",
|
||||
"Fill Edges": "Preencher bordas",
|
||||
|
|
@ -510,6 +517,7 @@
|
|||
"Fit to width": "Ajustar à largura",
|
||||
"Fit visible": "Ajustar ao visível",
|
||||
"Five stars": "Cinco estrelas",
|
||||
"Fixed keys": "Teclas fixas",
|
||||
"Flag": "Marcar",
|
||||
"Flag as Pick": "Marcar como seleção",
|
||||
"Flag as Reject": "Marcar como rejeitada",
|
||||
|
|
@ -572,6 +580,7 @@
|
|||
"Green Primary": "Primária verde",
|
||||
"Green Saturation": "Saturação do verde",
|
||||
"Green channel": "Canal verde",
|
||||
"Green label": "Rótulo verde",
|
||||
"Grid": "Grade",
|
||||
"Grid Info": "Informações da grade",
|
||||
"Gritty Street": "Urbano cru",
|
||||
|
|
@ -632,6 +641,8 @@
|
|||
"Image": "Imagem",
|
||||
"Import": "Importar",
|
||||
"Import Date": "Data de importação",
|
||||
"Import Lightroom Catalog": "Importar catálogo do Lightroom",
|
||||
"Import Lightroom Catalog…": "Importar catálogo do Lightroom…",
|
||||
"Import Photos": "Importar fotos",
|
||||
"Import Photos…": "Importar fotos…",
|
||||
"Import Point Curve Presets": "Importar predefinições de curva de pontos",
|
||||
|
|
@ -647,7 +658,10 @@
|
|||
"Imported": "Importados",
|
||||
"Imported Photos": "Fotos importadas",
|
||||
"Importing": "Importando",
|
||||
"Importing Lightroom Catalog": "Importando catálogo do Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "Na Grade de fotos e na Grade quadrada: marcar como seleção e avançar",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "Na Grade de fotos e na Grade quadrada: expandir ou recolher a pilha",
|
||||
"In Recently Deleted": "Em Recentemente excluídos",
|
||||
"In library": "Na biblioteca",
|
||||
"In memory — nothing is saved": "Na memória — nada é salvo",
|
||||
|
|
@ -663,7 +677,9 @@
|
|||
"Info Overlay: File": "Sobreposição de informações: arquivo",
|
||||
"Info overlay": "Sobreposição de informações",
|
||||
"Insert at the cursor": "Inserir no cursor",
|
||||
"Inspect Lightroom Catalog": "Inspecionar catálogo do Lightroom",
|
||||
"Inspect Photo": "Inspecionar foto",
|
||||
"Inspecting Lightroom Catalog": "Inspecionando catálogo do Lightroom",
|
||||
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
|
||||
"Instant ’70s": "Instantâneo anos 70",
|
||||
"Interface": "Interface",
|
||||
|
|
@ -729,6 +745,9 @@
|
|||
"LightCraft on GitHub": "LightCraft no GitHub",
|
||||
"LightCraft website": "Site do LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — segunda janela",
|
||||
"Lightroom Classic Catalog": "Catálogo do Lightroom Classic",
|
||||
"Lightroom import cancelled": "Importação do Lightroom cancelada",
|
||||
"Lightroom inspection cancelled": "Inspeção do Lightroom cancelada",
|
||||
"Lights": "Luzes",
|
||||
"Limit file size (KB, 0 = off)": "Limitar tamanho do arquivo (KB, 0 = desativado)",
|
||||
"Line delta": "Diferença de linhas",
|
||||
|
|
@ -884,6 +903,7 @@
|
|||
"No filter presets yet": "Ainda não há predefinições de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.",
|
||||
"No matching commands": "Nenhum comando corresponde",
|
||||
"No matching photos": "Nenhuma foto correspondente",
|
||||
"No metadata found": "Nenhum metadado encontrado",
|
||||
"No model credits were built into this copy.": "Esta cópia não inclui créditos de modelos.",
|
||||
|
|
@ -1008,6 +1028,7 @@
|
|||
"Presets (Shift+P)": "Predefinições (Shift+P)",
|
||||
"Presets and profiles": "Predefinições e perfis",
|
||||
"Presets ▾": "Predefinições",
|
||||
"Press keys…": "Pressione as teclas…",
|
||||
"Preview": "Pré-visualização",
|
||||
"Preview Build Progress": "Progresso da geração de pré-visualizações",
|
||||
"Preview only": "Somente pré-visualização",
|
||||
|
|
@ -1052,6 +1073,16 @@
|
|||
"Radius": "Raio",
|
||||
"Random": "Aleatória",
|
||||
"Range": "Faixa",
|
||||
"Rate ★": "Avaliar ★",
|
||||
"Rate ★ and advance": "Avaliar ★ e avançar",
|
||||
"Rate ★★": "Avaliar ★★",
|
||||
"Rate ★★ and advance": "Avaliar ★★ e avançar",
|
||||
"Rate ★★★": "Avaliar ★★★",
|
||||
"Rate ★★★ and advance": "Avaliar ★★★ e avançar",
|
||||
"Rate ★★★★": "Avaliar ★★★★",
|
||||
"Rate ★★★★ and advance": "Avaliar ★★★★ e avançar",
|
||||
"Rate ★★★★★": "Avaliar ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Avaliar ★★★★★ e avançar",
|
||||
"Rating": "Avaliação",
|
||||
"Rating cleared": "Avaliação removida",
|
||||
"Ratings & flags": "Avaliações e marcações",
|
||||
|
|
@ -1065,6 +1096,7 @@
|
|||
"Read mode": "Modo de leitura",
|
||||
"Read the SAM License": "Ler a licença do SAM",
|
||||
"Reading": "Lendo",
|
||||
"Reading Lightroom catalog…": "Lendo catálogo do Lightroom…",
|
||||
"Reading folder…": "Lendo pasta…",
|
||||
"Real name": "Nome real",
|
||||
"Recent": "Recentes",
|
||||
|
|
@ -1078,6 +1110,7 @@
|
|||
"Red Primary": "Primária vermelha",
|
||||
"Red Saturation": "Saturação do vermelho",
|
||||
"Red channel": "Canal vermelho",
|
||||
"Red label": "Rótulo vermelho",
|
||||
"Red/Cyan Fringe": "Franja vermelha/ciano",
|
||||
"Redo": "Refazer",
|
||||
"Reference": "Referência",
|
||||
|
|
@ -1118,6 +1151,7 @@
|
|||
"Remove from Target Album": "Remover do álbum de destino",
|
||||
"Remove from survey": "Remover da inspeção",
|
||||
"Remove location info": "Remover informações de local",
|
||||
"Remove shortcut": "Remover atalho",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "Remover esta caixa de rosto (desfaça com Editar ▸ Desfazer)",
|
||||
"Remove this filter": "Remover este filtro",
|
||||
"Remove this group": "Remover este grupo",
|
||||
|
|
@ -1141,7 +1175,9 @@
|
|||
"Rendering…": "Renderizando…",
|
||||
"Reorder Masks": "Reordenar máscaras",
|
||||
"Reset": "Redefinir",
|
||||
"Reset All": "Redefinir tudo",
|
||||
"Reset All Curves": "Redefinir todas as curvas",
|
||||
"Reset All Keyboard Shortcuts": "Redefinir todos os atalhos de teclado",
|
||||
"Reset Before to Import State": "Redefinir Antes para o estado de importação",
|
||||
"Reset Control": "Redefinir controle",
|
||||
"Reset Crop": "Redefinir recorte",
|
||||
|
|
@ -1156,6 +1192,7 @@
|
|||
"Resize to": "Redimensionar para",
|
||||
"Resolution (ppi)": "Resolução (ppi)",
|
||||
"Restore": "Restaurar",
|
||||
"Restore (no shortcut)": "Restaurar (sem atalho)",
|
||||
"Restore Library from Backup…": "Restaurar biblioteca do backup…",
|
||||
"Restore Version": "Restaurar versão",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Restaurar mantém as edições; Importar como novas apaga antes o registro antigo.",
|
||||
|
|
@ -1202,6 +1239,7 @@
|
|||
"Save as set": "Salvar como conjunto",
|
||||
"Save as…": "Salvar como…",
|
||||
"Save preset": "Salvar predefinição",
|
||||
"Saving Lightroom import index…": "Salvando índice de importação do Lightroom…",
|
||||
"Scale": "Escala",
|
||||
"Scan failed": "Falha na verificação",
|
||||
"Scanning": "Examinando",
|
||||
|
|
@ -1209,6 +1247,7 @@
|
|||
"Screen": "Tela",
|
||||
"Search": "Pesquisar",
|
||||
"Search Photos": "Pesquisar fotos",
|
||||
"Search commands or keys": "Pesquisar comandos ou teclas",
|
||||
"Search…": "Pesquisar…",
|
||||
"Seasons": "Estações",
|
||||
"Second Window": "Segunda janela",
|
||||
|
|
@ -1239,6 +1278,7 @@
|
|||
"Set Develop Values": "Definir valores de revelação",
|
||||
"Set Flag": "Definir marcação",
|
||||
"Set Image Zoom and Pan": "Definir zoom e posição da imagem",
|
||||
"Set Keyboard Shortcut": "Definir atalho de teclado",
|
||||
"Set Mask Adjustments": "Definir ajustes da máscara",
|
||||
"Set Point Curve": "Definir curva de pontos",
|
||||
"Set Profile": "Definir perfil",
|
||||
|
|
@ -1288,6 +1328,7 @@
|
|||
"Show Pins": "Mostrar pinos",
|
||||
"Show Source": "Mostrar origem",
|
||||
"Show Thumbnails": "Mostrar miniaturas",
|
||||
"Show commands without a shortcut": "Mostrar comandos sem atalho",
|
||||
"Show destination gamut warning": "Mostrar aviso de gamut de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gamut da tela",
|
||||
"Show file names": "Mostrar nomes dos arquivos",
|
||||
|
|
@ -1392,6 +1433,7 @@
|
|||
"Text direction": "Direção do texto",
|
||||
"Texture": "Textura",
|
||||
"That includes the photos in the folders inside it.": "Isso inclui as fotos das pastas dentro dela.",
|
||||
"That key can't be used as a shortcut": "Essa tecla não pode ser usada como atalho",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.",
|
||||
"The download didn't work:": "O download não funcionou:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "O número no fim do nome original (IMG_0042 → 0042)",
|
||||
|
|
@ -1431,6 +1473,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "Ative Incluir subpastas ou escolha outra pasta em Local",
|
||||
"Type": "Tipo",
|
||||
"Uncheck All": "Desmarcar tudo",
|
||||
"Uncheck the files of this type": "Desmarcar os arquivos deste tipo",
|
||||
"Undo": "Desfazer",
|
||||
"Unedited": "Não editada",
|
||||
"Unedited raws": "RAWs não editados",
|
||||
|
|
@ -1507,6 +1550,7 @@
|
|||
"Yellow Hue": "Matiz do amarelo",
|
||||
"Yellow Luminance": "Luminância do amarelo",
|
||||
"Yellow Saturation": "Saturação do amarelo",
|
||||
"Yellow label": "Rótulo amarelo",
|
||||
"Yes": "Sim",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Você pode fechar isto: o download continua e retoma se for interrompido.",
|
||||
"Your Name": "Seu nome",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@
|
|||
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft пока не умеет декодировать этот вариант RAW ({}). Вы редактируете встроенный в файл JPEG-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
|
||||
"LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom проверен: фото — {photos}, коллекций — {collections}, отсутствует — {missing}",
|
||||
"Lightroom import complete: {imported} photos added": "Импорт из Lightroom завершён: добавлено фото — {imported}",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Импорт из Lightroom завершён: добавлено фото — {imported}, ошибок — {failed}",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Импорт из Lightroom завершён: добавлено фото — {imported}; предупреждение индекса: {warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Ошибка импорта из Lightroom: {e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Ошибка проверки Lightroom: {e}{warning}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
|
||||
"Matches: {}": "Совпадения: {}",
|
||||
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
|
||||
|
|
@ -91,11 +97,13 @@
|
|||
"Quick Develop · {n} photos": "Быстрая коррекция · фото: {n}",
|
||||
"Quick Develop: {name}": "Быстрая коррекция: {name}",
|
||||
"Rated {}": "Оценка: {}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "Чтение каталога Lightroom… {done} из {total}",
|
||||
"Reading photos… {done} of {total}": "Чтение фото… {done} из {total}",
|
||||
"Reading photos… {} of {}": "Чтение фото… {} из {}",
|
||||
"Redo {}": "Повторить {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "Удалить «{name}» и фото ({count}){:.0} из библиотеки?",
|
||||
"Remove “{}” from Favorites": "Убрать «{}» из избранного",
|
||||
"Removed from {}": "Удалено из: {}",
|
||||
"Removed {what} from {name}": "{what}: удалено из {name}",
|
||||
"Rename {n} Photos…": "Переименовать фото: {n}…",
|
||||
"Rename “{name}”": "Переименовать «{name}»",
|
||||
|
|
@ -104,6 +112,7 @@
|
|||
"Replace {} with:": "Заменить {} на:",
|
||||
"Reset {name}": "Сбросить: {name}",
|
||||
"Reset {} Channel": "Сбросить канал: {}",
|
||||
"Restore {}": "Восстановить {}",
|
||||
"Restore {} Photo{}": "Восстановить фото: {}{:.0}",
|
||||
"Saved export preset “{}”": "Пресет экспорта «{}» сохранён",
|
||||
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оценивает резкость и экспозицию ({scope}) и находит похожие снимки, сделанные с разницей в несколько секунд (серии).",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"Add/Subtract/Intersect Mask": "Добавить/вычесть/пересечь маску",
|
||||
"Added to photos you import that don't already have it.": "Добавляется к импортируемым фото, у которых этого ещё нет.",
|
||||
"Adding": "Добавление",
|
||||
"Adding Lightroom photos…": "Добавление фото из Lightroom…",
|
||||
"Advanced search…": "Расширенный поиск…",
|
||||
"After": "После",
|
||||
"After 30 days": "Через 30 дней",
|
||||
|
|
@ -124,6 +125,7 @@
|
|||
"Blue Primary": "Основной синий",
|
||||
"Blue Saturation": "Насыщенность синего",
|
||||
"Blue channel": "Синий канал",
|
||||
"Blue label": "Синяя метка",
|
||||
"Blue/Yellow Fringe": "Сине-жёлтая кайма",
|
||||
"Blurry photos below the score are flagged as rejects.": "Размытые фото с оценкой ниже порога помечаются как отклонённые.",
|
||||
"Boundary Warp": "Деформация границ",
|
||||
|
|
@ -155,6 +157,7 @@
|
|||
"Catalog Statistics": "Статистика каталога",
|
||||
"Check All": "Отметить все",
|
||||
"Check Output Path": "Проверить путь вывода",
|
||||
"Check the files of this type": "Отметить файлы этого типа",
|
||||
"Choose Another Library…": "Выбрать другую библиотеку…",
|
||||
"Choose Edit Settings to Copy": "Выбрать настройки для копирования",
|
||||
"Choose Edit Settings to Copy…": "Выбрать настройки для копирования…",
|
||||
|
|
@ -175,8 +178,11 @@
|
|||
"Clear Reference": "Убрать эталон",
|
||||
"Clear all": "Очистить всё",
|
||||
"Clear form": "Очистить форму",
|
||||
"Clear rating": "Снять оценку",
|
||||
"Clear rating and advance": "Снять оценку и перейти дальше",
|
||||
"Cleared History": "История очищена",
|
||||
"Click Zoom Ratio": "Масштаб по щелчку",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "Щёлкните сочетание и нажмите новые клавиши (Esc — отмена).",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Щёлкните по тегу, чтобы вставить его. Примеры приведены для IMG_0042.CR3, снятого 14 января 2026 в 05:58:48.",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "Щёлкайте по фото в сетке, чтобы переключать ключевое слово; Esc — остановить",
|
||||
"Click the photo to pick a colour · ⇧-click adds more": "Щёлкните по фото, чтобы выбрать цвет · ⇧+щелчок добавляет ещё",
|
||||
|
|
@ -395,6 +401,7 @@
|
|||
"File Path": "Путь к файлу",
|
||||
"File Size": "Размер файла",
|
||||
"File name": "Имя файла",
|
||||
"File types:": "Типы файлов:",
|
||||
"Fill Edges": "Заполнить края",
|
||||
"Film": "Плёнка",
|
||||
"Filmstrip": "Лента",
|
||||
|
|
@ -415,6 +422,7 @@
|
|||
"Fit on Screen": "Вписать в экран",
|
||||
"Fit to width": "По ширине",
|
||||
"Fit visible": "Вписать видимое",
|
||||
"Fixed keys": "Фиксированные клавиши",
|
||||
"Flag": "Флаг",
|
||||
"Flag as Pick": "Пометить как выбранное",
|
||||
"Flag as Reject": "Пометить как отклонённое",
|
||||
|
|
@ -468,6 +476,7 @@
|
|||
"Green Primary": "Основной зелёный",
|
||||
"Green Saturation": "Насыщенность зелёного",
|
||||
"Green channel": "Зелёный канал",
|
||||
"Green label": "Зелёная метка",
|
||||
"Grid": "Сетка",
|
||||
"Grid Info": "Информация сетки",
|
||||
"Gritty Street": "Жёсткая улица",
|
||||
|
|
@ -516,6 +525,8 @@
|
|||
"Image": "Изображение",
|
||||
"Import": "Импорт",
|
||||
"Import Date": "Дата импорта",
|
||||
"Import Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Import Lightroom Catalog…": "Импорт каталога Lightroom…",
|
||||
"Import Photos": "Импорт фото",
|
||||
"Import Photos…": "Импорт фото…",
|
||||
"Import Point Curve Presets": "Импортировать пресеты точечной кривой",
|
||||
|
|
@ -529,7 +540,10 @@
|
|||
"Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Импортируйте фото через Файл → Импорт фото… (Cmd+Shift+I) или перетащите их сюда",
|
||||
"Imported Photos": "Импортированные фото",
|
||||
"Importing": "Импорт",
|
||||
"Importing Lightroom Catalog": "Импорт каталога Lightroom",
|
||||
"Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "При импорте источник сканируется на фото; папка не добавляется в «Локальное» (чтобы работать с папкой без импорта, используйте Локальное → Выбрать папку…).",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "В сетке фото и квадратной сетке: пометить как выбранное и перейти дальше",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "В сетке фото и квадратной сетке: развернуть или свернуть стопку",
|
||||
"In Recently Deleted": "В «Недавно удалённых»",
|
||||
"In library": "В библиотеке",
|
||||
"In memory — nothing is saved": "В памяти — ничего не сохраняется",
|
||||
|
|
@ -541,7 +555,9 @@
|
|||
"Info": "Информация",
|
||||
"Info (I)": "Информация (I)",
|
||||
"Info overlay": "Информационная накладка",
|
||||
"Inspect Lightroom Catalog": "Проверить каталог Lightroom",
|
||||
"Inspect Photo": "Просмотр фото",
|
||||
"Inspecting Lightroom Catalog": "Проверка каталога Lightroom",
|
||||
"Install the SAM 3 Model": "Установка модели SAM 3",
|
||||
"Instant ’70s": "Мгновенный стиль 70-х",
|
||||
"Interface": "Интерфейс",
|
||||
|
|
@ -598,6 +614,9 @@
|
|||
"LightCraft on GitHub": "LightCraft на GitHub",
|
||||
"LightCraft website": "Сайт LightCraft",
|
||||
"LightCraft — Second Window": "LightCraft — второе окно",
|
||||
"Lightroom Classic Catalog": "Каталог Lightroom Classic",
|
||||
"Lightroom import cancelled": "Импорт из Lightroom отменён",
|
||||
"Lightroom inspection cancelled": "Проверка Lightroom отменена",
|
||||
"Lights": "Светлые тона",
|
||||
"Limit file size (KB, 0 = off)": "Ограничить размер файла (КБ, 0 = выкл.)",
|
||||
"Linear Gradient": "Линейный градиент",
|
||||
|
|
@ -726,6 +745,7 @@
|
|||
"No filter presets yet": "Пресетов фильтров пока нет",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).",
|
||||
"No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.",
|
||||
"No matching commands": "Нет подходящих команд",
|
||||
"No matching photos": "Нет подходящих фото",
|
||||
"No metadata found": "Метаданные не найдены",
|
||||
"No new presets": "Новых пресетов нет",
|
||||
|
|
@ -828,6 +848,7 @@
|
|||
"Presets & Profiles": "Пресеты и профили",
|
||||
"Presets (Shift+P)": "Пресеты (Shift+P)",
|
||||
"Presets ▾": "Пресеты ⌄",
|
||||
"Press keys…": "Нажмите клавиши…",
|
||||
"Preview": "Просмотр",
|
||||
"Preview Build Progress": "Ход создания просмотров",
|
||||
"Preview only": "Только просмотр",
|
||||
|
|
@ -866,6 +887,16 @@
|
|||
"Radius": "Радиус",
|
||||
"Random": "Случайно",
|
||||
"Range": "Диапазон",
|
||||
"Rate ★": "Оценка ★",
|
||||
"Rate ★ and advance": "Оценка ★ и перейти дальше",
|
||||
"Rate ★★": "Оценка ★★",
|
||||
"Rate ★★ and advance": "Оценка ★★ и перейти дальше",
|
||||
"Rate ★★★": "Оценка ★★★",
|
||||
"Rate ★★★ and advance": "Оценка ★★★ и перейти дальше",
|
||||
"Rate ★★★★": "Оценка ★★★★",
|
||||
"Rate ★★★★ and advance": "Оценка ★★★★ и перейти дальше",
|
||||
"Rate ★★★★★": "Оценка ★★★★★",
|
||||
"Rate ★★★★★ and advance": "Оценка ★★★★★ и перейти дальше",
|
||||
"Rating": "Оценка",
|
||||
"Ratings & flags": "Оценки и флаги",
|
||||
"Raw default": "Стандарт RAW",
|
||||
|
|
@ -877,6 +908,7 @@
|
|||
"Read mode": "Режим чтения",
|
||||
"Read the SAM License": "Прочитать лицензию SAM",
|
||||
"Reading": "Чтение",
|
||||
"Reading Lightroom catalog…": "Чтение каталога Lightroom…",
|
||||
"Reading folder…": "Чтение папки…",
|
||||
"Recently Added": "Недавно добавленные",
|
||||
"Recently Deleted": "Недавно удалённые",
|
||||
|
|
@ -887,6 +919,7 @@
|
|||
"Red Primary": "Основной красный",
|
||||
"Red Saturation": "Насыщенность красного",
|
||||
"Red channel": "Красный канал",
|
||||
"Red label": "Красная метка",
|
||||
"Red/Cyan Fringe": "Красно-голубая кайма",
|
||||
"Redo": "Повторить",
|
||||
"Reference": "Эталон",
|
||||
|
|
@ -912,6 +945,7 @@
|
|||
"Remove from Stack": "Убрать из стопки",
|
||||
"Remove from survey": "Убрать из обзора",
|
||||
"Remove location info": "Удалить сведения о местоположении",
|
||||
"Remove shortcut": "Удалить сочетание",
|
||||
"Remove this filter": "Убрать этот фильтр",
|
||||
"Remove this group": "Убрать эту группу",
|
||||
"Remove this rule": "Убрать это условие",
|
||||
|
|
@ -932,7 +966,9 @@
|
|||
"Rendering": "Рендеринг",
|
||||
"Rendering…": "Рендеринг…",
|
||||
"Reset": "Сбросить",
|
||||
"Reset All": "Сбросить всё",
|
||||
"Reset All Curves": "Сбросить все кривые",
|
||||
"Reset All Keyboard Shortcuts": "Сбросить все сочетания клавиш",
|
||||
"Reset Before to Import State": "Сбросить «до» к состоянию при импорте",
|
||||
"Reset Control": "Сбросить элемент управления",
|
||||
"Reset Crop": "Сбросить кадрирование",
|
||||
|
|
@ -946,6 +982,7 @@
|
|||
"Resize to": "Изменить размер до",
|
||||
"Resolution (ppi)": "Разрешение (ppi)",
|
||||
"Restore": "Восстановить",
|
||||
"Restore (no shortcut)": "Восстановить (без сочетания)",
|
||||
"Restore Library from Backup…": "Восстановить библиотеку из резервной копии…",
|
||||
"Restore Version": "Восстановить версию",
|
||||
"Retouch": "Ретушь",
|
||||
|
|
@ -985,11 +1022,13 @@
|
|||
"Save as set": "Сохранить как набор",
|
||||
"Save as…": "Сохранить как…",
|
||||
"Save preset": "Сохранить пресет",
|
||||
"Saving Lightroom import index…": "Сохранение индекса импорта Lightroom…",
|
||||
"Scale": "Масштаб",
|
||||
"Scanning": "Сканирование",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
|
||||
"Screen": "Экран",
|
||||
"Search Photos": "Поиск фото",
|
||||
"Search commands or keys": "Поиск команд или клавиш",
|
||||
"Search…": "Поиск…",
|
||||
"Seasons": "Времена года",
|
||||
"Second Window": "Второе окно",
|
||||
|
|
@ -1014,6 +1053,7 @@
|
|||
"Set Crop": "Задать кадрирование",
|
||||
"Set Develop Values": "Задать значения коррекции",
|
||||
"Set Flag": "Задать флаг",
|
||||
"Set Keyboard Shortcut": "Задать сочетание клавиш",
|
||||
"Set Mask Adjustments": "Задать настройки маски",
|
||||
"Set Point Curve": "Задать точечную кривую",
|
||||
"Set Profile": "Задать профиль",
|
||||
|
|
@ -1055,6 +1095,7 @@
|
|||
"Show Pins": "Показать булавки",
|
||||
"Show Source": "Показать источник",
|
||||
"Show Thumbnails": "Показывать миниатюры",
|
||||
"Show commands without a shortcut": "Показывать команды без сочетаний",
|
||||
"Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода",
|
||||
"Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея",
|
||||
"Show file names": "Показывать имена файлов",
|
||||
|
|
@ -1146,6 +1187,7 @@
|
|||
"Text": "Текст",
|
||||
"Text direction": "Направление текста",
|
||||
"Texture": "Текстура",
|
||||
"That key can't be used as a shortcut": "Эту клавишу нельзя использовать в сочетании",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "Модель SAM 3 установлена: маски «Объект» и «Описать» готовы.",
|
||||
"The download didn't work:": "Загрузка не удалась:",
|
||||
"The original files, unchanged, each with an XMP sidecar holding its edits.": "Исходные файлы без изменений, у каждого — XMP-файл с его правками.",
|
||||
|
|
@ -1177,6 +1219,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "Включите «Включая вложенные папки» или выберите другую папку в «Локальном»",
|
||||
"Type": "Тип",
|
||||
"Uncheck All": "Снять все отметки",
|
||||
"Uncheck the files of this type": "Снять отметку с файлов этого типа",
|
||||
"Undo": "Отменить",
|
||||
"Unedited": "Без правок",
|
||||
"Unflag": "Снять флаг",
|
||||
|
|
@ -1246,6 +1289,7 @@
|
|||
"Yellow Hue": "Тон жёлтого",
|
||||
"Yellow Luminance": "Яркость жёлтого",
|
||||
"Yellow Saturation": "Насыщенность жёлтого",
|
||||
"Yellow label": "Жёлтая метка",
|
||||
"Yes": "Да",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "Это окно можно закрыть: загрузка продолжится и возобновится после обрыва.",
|
||||
"Your library couldn't be opened": "Не удалось открыть вашу библиотеку",
|
||||
|
|
|
|||
|
|
@ -50,13 +50,16 @@
|
|||
"Proof Preview · {}": "校样预览 · {0}",
|
||||
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
|
||||
"Rated {}": "已评级 {0}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在读取 Lightroom 目录… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
|
||||
"Redo {}": "重做:{0}",
|
||||
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
|
||||
"Removed from {}": "已从 {} 移除",
|
||||
"Rename {n} Photos…": "重命名 {n} 张照片…",
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {0} 通道",
|
||||
"Restore {}": "恢复为 {}",
|
||||
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
|
||||
"Saved export preset “{}”": "已保存导出预设“{0}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
|
||||
|
|
@ -114,6 +117,12 @@
|
|||
"Build Smart Previews ({scope})": "生成智能预览({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
|
||||
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已检查 Lightroom 目录:{photos} 张照片,{collections} 个收藏夹,{missing} 个缺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 导入完成:已添加 {imported} 张照片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 导入完成:已添加 {imported} 张照片,{failed} 张失败",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 导入完成:已添加 {imported} 张照片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 导入失败:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 检查失败:{e}{warning}",
|
||||
"Label Set: {name}": "标签集:{name}",
|
||||
"Quick Develop: {name}": "快速编辑:{name}",
|
||||
"Reset {name}": "重置{name}",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"Add/Subtract/Intersect Mask": "添加/减去/相交蒙版",
|
||||
"Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。",
|
||||
"Adding": "正在添加",
|
||||
"Adding Lightroom photos…": "正在添加 Lightroom 照片…",
|
||||
"Advanced search…": "高级搜索…",
|
||||
"After": "修改后",
|
||||
"After 30 days": "30 天后",
|
||||
|
|
@ -152,6 +153,7 @@
|
|||
"Blue Primary": "蓝色原色",
|
||||
"Blue Saturation": "蓝色饱和度",
|
||||
"Blue channel": "蓝色通道",
|
||||
"Blue label": "蓝色标签",
|
||||
"Blue/Yellow Fringe": "蓝/黄边缘",
|
||||
"Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。",
|
||||
"Boundary Warp": "边界变形",
|
||||
|
|
@ -194,6 +196,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "文件名中不允许的字符(/ \\ : * ? \" < > |)会替换为 -;与现有名称重复时添加 -1、-2…。",
|
||||
"Check All": "全部选中",
|
||||
"Check Output Path": "检查输出路径",
|
||||
"Check the files of this type": "勾选此类型的所有文件",
|
||||
"Checking names…": "正在检查名称…",
|
||||
"Choose Another Library…": "选择其他图库…",
|
||||
"Choose Edit Settings to Copy": "选择要复制的编辑设置",
|
||||
|
|
@ -215,8 +218,11 @@
|
|||
"Clear Reference": "清除参考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表单",
|
||||
"Clear rating": "清除星级",
|
||||
"Clear rating and advance": "清除星级并前进",
|
||||
"Cleared History": "已清除历史记录",
|
||||
"Click Zoom Ratio": "单击缩放比例",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "点击快捷键,然后按下新按键(Esc 取消)。",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "单击对象将其选中 · 按住 ⌥ 单击可排除某一部分",
|
||||
|
|
@ -481,11 +487,13 @@
|
|||
"File Path": "文件路径",
|
||||
"File Size": "文件大小",
|
||||
"File Type": "文件类型",
|
||||
"File types:": "文件类型:",
|
||||
"Filename": "文件名",
|
||||
"Fill": "填满",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五星",
|
||||
"Fixed keys": "固定按键",
|
||||
"Flagged as Pick": "已标记为留用",
|
||||
"Flagged as Reject": "已标记为排除",
|
||||
"Focal Length": "焦距",
|
||||
|
|
@ -495,6 +503,7 @@
|
|||
"GPU rendering": "GPU 渲染",
|
||||
"Generated demo photo": "生成的演示照片",
|
||||
"Grab bag": "名单",
|
||||
"Green label": "绿色标签",
|
||||
"Group name": "分组名称",
|
||||
"HDR Merge Preview": "HDR 合并预览",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合并预览",
|
||||
|
|
@ -505,16 +514,21 @@
|
|||
"Has GPS": "有 GPS",
|
||||
"Home": "主文件夹",
|
||||
"Hours": "小时",
|
||||
"How to install the model": "如何安装模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "导入 Lightroom 目录",
|
||||
"Import Lightroom Catalog…": "导入 Lightroom 目录…",
|
||||
"Import as new": "作为新照片导入",
|
||||
"Imported": "已导入",
|
||||
"Importing Lightroom Catalog": "正在导入 Lightroom 目录",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片网格和正方形网格中:标记为留用并前进",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片网格和正方形网格中:展开或折叠堆叠",
|
||||
"Info Overlay Off": "关闭信息叠加",
|
||||
"Info Overlay: Exposure": "信息叠加:曝光",
|
||||
"Info Overlay: File": "信息叠加:文件",
|
||||
"Insert at the cursor": "在光标处插入",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"Inspect Lightroom Catalog": "检查 Lightroom 目录",
|
||||
"Inspecting Lightroom Catalog": "正在检查 Lightroom 目录",
|
||||
"Invert Component": "反相组件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
|
||||
"Label": "标签",
|
||||
|
|
@ -526,6 +540,9 @@
|
|||
"Library saved": "图库已保存",
|
||||
"Licence:": "许可证:",
|
||||
"LightCraft could not start": "LightCraft 无法启动",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目录",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 导入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 检查",
|
||||
"Line delta": "行数差",
|
||||
"Linear": "线性",
|
||||
"Lines +/−": "行数 +/−",
|
||||
|
|
@ -552,6 +569,7 @@
|
|||
"Nested group": "嵌套组",
|
||||
"New time": "新时间",
|
||||
"No contributor data was built into this copy.": "此版本未内置贡献者数据。",
|
||||
"No matching commands": "没有匹配的命令",
|
||||
"No model credits were built into this copy.": "此版本未内置模型致谢信息。",
|
||||
"No named people in these photos": "这些照片中没有已命名的人物",
|
||||
"No named people yet": "还没有已命名的人物",
|
||||
|
|
@ -574,6 +592,7 @@
|
|||
"Pictures": "图片",
|
||||
"Prepare AI Masks": "准备 AI 蒙版",
|
||||
"Presets and profiles": "预设和配置文件",
|
||||
"Press keys…": "请按键…",
|
||||
"Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG",
|
||||
"Proof copy created": "已创建校样副本",
|
||||
"Quit Anyway": "仍然退出",
|
||||
|
|
@ -581,12 +600,24 @@
|
|||
"RAW decoding": "RAW 解码",
|
||||
"Radial": "径向",
|
||||
"Random": "随机",
|
||||
"Rate ★": "评为 ★",
|
||||
"Rate ★ and advance": "评为 ★ 并前进",
|
||||
"Rate ★★": "评为 ★★",
|
||||
"Rate ★★ and advance": "评为 ★★ 并前进",
|
||||
"Rate ★★★": "评为 ★★★",
|
||||
"Rate ★★★ and advance": "评为 ★★★ 并前进",
|
||||
"Rate ★★★★": "评为 ★★★★",
|
||||
"Rate ★★★★ and advance": "评为 ★★★★ 并前进",
|
||||
"Rate ★★★★★": "评为 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "评为 ★★★★★ 并前进",
|
||||
"Rating cleared": "已清除星级",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "阅读 SAM 许可证",
|
||||
"Reading Lightroom catalog…": "正在读取 Lightroom 目录…",
|
||||
"Real name": "真实姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的关键字",
|
||||
"Red label": "红色标签",
|
||||
"Refine": "细化",
|
||||
"Refine AI Mask Detail": "细化 AI 蒙版细节",
|
||||
"Refine Edges": "细化边缘",
|
||||
|
|
@ -598,9 +629,12 @@
|
|||
"Remove Face Box": "移除人脸框",
|
||||
"Remove a filter above, or choose Clear all": "请移除上方的某个筛选条件,或选择“全部清除”",
|
||||
"Remove from Target Album": "从目标相册中移除",
|
||||
"Remove shortcut": "移除快捷键",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此人脸框(可通过“编辑 ▸ 撤销”恢复)",
|
||||
"Rename Component": "重命名组件",
|
||||
"Reorder Masks": "重新排序蒙版",
|
||||
"Reset All": "全部重置",
|
||||
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
|
||||
"Reshuffle": "重新随机排序",
|
||||
"File name": "文件名",
|
||||
"Fill Edges": "填充边缘",
|
||||
|
|
@ -1146,6 +1180,7 @@
|
|||
"Resize to": "调整大小为",
|
||||
"Resolution (ppi)": "分辨率 (ppi)",
|
||||
"Restore": "恢复",
|
||||
"Restore (no shortcut)": "恢复(无快捷键)",
|
||||
"Restore Library from Backup…": "从备份恢复图库…",
|
||||
"Restore Version": "恢复版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
|
||||
|
|
@ -1192,6 +1227,7 @@
|
|||
"Save as set": "保存为集",
|
||||
"Save as…": "另存为…",
|
||||
"Save preset": "保存预设",
|
||||
"Saving Lightroom import index…": "正在保存 Lightroom 导入索引…",
|
||||
"Scale": "缩放",
|
||||
"Scan failed": "扫描失败",
|
||||
"Scanning": "正在扫描",
|
||||
|
|
@ -1199,6 +1235,7 @@
|
|||
"Screen": "屏幕",
|
||||
"Search": "搜索",
|
||||
"Search Photos": "搜索照片",
|
||||
"Search commands or keys": "搜索命令或按键",
|
||||
"Search…": "搜索…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二个窗口",
|
||||
|
|
@ -1229,6 +1266,7 @@
|
|||
"Set Develop Values": "设置编辑参数",
|
||||
"Set Flag": "设置旗标",
|
||||
"Set Image Zoom and Pan": "设置图像缩放和平移",
|
||||
"Set Keyboard Shortcut": "设置键盘快捷键",
|
||||
"Set Mask Adjustments": "设置蒙版调整",
|
||||
"Set Point Curve": "设置点曲线",
|
||||
"Set Profile": "设置配置文件",
|
||||
|
|
@ -1278,6 +1316,7 @@
|
|||
"Show Pins": "显示图钉",
|
||||
"Show Source": "显示来源",
|
||||
"Show Thumbnails": "显示缩略图",
|
||||
"Show commands without a shortcut": "显示没有快捷键的命令",
|
||||
"Show destination gamut warning": "显示目标色域警告",
|
||||
"Show display gamut warning": "显示显示器色域警告",
|
||||
"Show file names": "显示文件名",
|
||||
|
|
@ -1381,6 +1420,7 @@
|
|||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "纹理",
|
||||
"That key can't be used as a shortcut": "该按键不能用作快捷键",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安装:对象蒙版和描述蒙版可以使用了。",
|
||||
"The download didn't work:": "下载失败:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "原始名称末尾的数字(IMG_0042 → 0042)",
|
||||
|
|
@ -1390,7 +1430,6 @@
|
|||
"Theme": "主题",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
|
||||
"Thirds": "三分法",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
|
||||
"This photo can't be opened": "无法打开此照片",
|
||||
"Threshold": "阈值",
|
||||
|
|
@ -1418,6 +1457,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹",
|
||||
"Type": "类型",
|
||||
"Uncheck All": "全部取消选中",
|
||||
"Uncheck the files of this type": "取消勾选此类型的所有文件",
|
||||
"Undo": "撤销",
|
||||
"Unedited": "未编辑",
|
||||
"Unedited raws": "未编辑的 RAW",
|
||||
|
|
@ -1494,6 +1534,7 @@
|
|||
"Yellow Hue": "黄色色相",
|
||||
"Yellow Luminance": "黄色明度",
|
||||
"Yellow Saturation": "黄色饱和度",
|
||||
"Yellow label": "黄色标签",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以关闭此窗口:下载会继续进行,中断后也会继续。",
|
||||
"Your Name": "你的名字",
|
||||
|
|
@ -1595,5 +1636,8 @@
|
|||
"Expand": "展开",
|
||||
"Remove Disk from Library": "从图库中移除磁盘",
|
||||
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。",
|
||||
"How to install the model": "如何安装模型",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@
|
|||
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "已檢查 Lightroom 目錄:{photos} 張相片,{collections} 個收藏集,{missing} 個遺失",
|
||||
"Lightroom import complete: {imported} photos added": "Lightroom 匯入完成:已加入 {imported} 張相片",
|
||||
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom 匯入完成:已加入 {imported} 張相片,{failed} 張失敗",
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom 匯入完成:已加入 {imported} 張相片;索引警告:{warning}",
|
||||
"Lightroom import failed: {e}{warning}": "Lightroom 匯入失敗:{e}{warning}",
|
||||
"Lightroom inspection failed: {e}{warning}": "Lightroom 檢查失敗:{e}{warning}",
|
||||
"Label Set: {name}": "標籤集:{name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
|
|
@ -59,15 +65,18 @@
|
|||
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
|
||||
"Quick Develop: {name}": "快速顯像:{name}",
|
||||
"Rated {}": "評分:{}",
|
||||
"Reading Lightroom catalog… {done} of {total}": "正在讀取 Lightroom 目錄… {done} / {total}",
|
||||
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
|
||||
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
|
||||
"Redo {}": "重做 {}",
|
||||
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
|
||||
"Removed from {}": "已從 {} 移除",
|
||||
"Rename {n} Photos…": "重新命名 {n} 張照片…",
|
||||
"Rename “{name}”": "重新命名「{name}」",
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Restore {}": "還原為 {}",
|
||||
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
|
||||
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
|
||||
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
|
||||
"Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
|
||||
"Adding": "正在新增",
|
||||
"Adding Lightroom photos…": "正在加入 Lightroom 相片…",
|
||||
"Advanced search…": "進階搜尋…",
|
||||
"After": "編輯後",
|
||||
"After 30 days": "30 天後",
|
||||
|
|
@ -152,6 +153,7 @@
|
|||
"Blue Primary": "藍色原色",
|
||||
"Blue Saturation": "藍色飽和度",
|
||||
"Blue channel": "藍色通道",
|
||||
"Blue label": "藍色標籤",
|
||||
"Blue/Yellow Fringe": "藍/黃色邊緣",
|
||||
"Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
|
||||
"Boundary Warp": "邊界變形",
|
||||
|
|
@ -194,6 +196,7 @@
|
|||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "檔名中不允許的字元(/ \\ : * ? \" < > |)會改為 -;與現有名稱重複時加上 -1、-2…。",
|
||||
"Check All": "全部勾選",
|
||||
"Check Output Path": "檢驗輸出路徑",
|
||||
"Check the files of this type": "勾選此類型的所有檔案",
|
||||
"Checking names…": "正在檢查名稱…",
|
||||
"Choose Another Library…": "選擇其他照片庫…",
|
||||
"Choose Edit Settings to Copy": "選擇要複製的編輯設定",
|
||||
|
|
@ -215,8 +218,11 @@
|
|||
"Clear Reference": "清除參考照片",
|
||||
"Clear all": "全部清除",
|
||||
"Clear form": "清除表單",
|
||||
"Clear rating": "清除評分",
|
||||
"Clear rating and advance": "清除評分並前進",
|
||||
"Cleared History": "已清除歷史紀錄",
|
||||
"Click Zoom Ratio": "點擊縮放比例",
|
||||
"Click a shortcut, then press the new keys (Esc cancels).": "按一下快速鍵,然後按下新的按鍵(Esc 取消)。",
|
||||
"Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。",
|
||||
"Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束",
|
||||
"Click the object to select it · ⌥-click leaves a part out": "點擊物件即可選取 · 按住 ⌥ 點擊可排除某個部分",
|
||||
|
|
@ -481,11 +487,13 @@
|
|||
"File Path": "檔案路徑",
|
||||
"File Size": "檔案大小",
|
||||
"File Type": "檔案類型",
|
||||
"File types:": "檔案類型:",
|
||||
"Filename": "檔名",
|
||||
"Fill": "填滿",
|
||||
"First": "最早",
|
||||
"First commit": "首次提交",
|
||||
"Five stars": "五顆星",
|
||||
"Fixed keys": "固定按鍵",
|
||||
"Flagged as Pick": "已標記為精選",
|
||||
"Flagged as Reject": "已標記為排除",
|
||||
"Focal Length": "焦距",
|
||||
|
|
@ -495,6 +503,7 @@
|
|||
"GPU rendering": "GPU 算繪",
|
||||
"Generated demo photo": "產生的示範照片",
|
||||
"Grab bag": "名單",
|
||||
"Green label": "綠色標籤",
|
||||
"Group name": "群組名稱",
|
||||
"HDR Merge Preview": "HDR 合併預覽",
|
||||
"HDR Panorama Merge Preview": "HDR 全景合併預覽",
|
||||
|
|
@ -505,16 +514,21 @@
|
|||
"Has GPS": "有 GPS",
|
||||
"Home": "個人資料夾",
|
||||
"Hours": "小時",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import Lightroom Catalog": "匯入 Lightroom 目錄",
|
||||
"Import Lightroom Catalog…": "匯入 Lightroom 目錄…",
|
||||
"Import as new": "以新照片匯入",
|
||||
"Imported": "已匯入",
|
||||
"Importing Lightroom Catalog": "正在匯入 Lightroom 目錄",
|
||||
"In Photo Grid and Square Grid: Flag as Pick and advance": "在照片格狀檢視與方格檢視中:標記為精選並前進",
|
||||
"In Photo Grid and Square Grid: expand or collapse the stack": "在照片格狀檢視與方格檢視中:展開或收合堆疊",
|
||||
"Info Overlay Off": "關閉資訊疊加",
|
||||
"Info Overlay: Exposure": "資訊疊加:曝光",
|
||||
"Info Overlay: File": "資訊疊加:檔案",
|
||||
"Insert at the cursor": "在游標處插入",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"Inspect Lightroom Catalog": "檢查 Lightroom 目錄",
|
||||
"Inspecting Lightroom Catalog": "正在檢查 Lightroom 目錄",
|
||||
"Invert Component": "反轉元件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
|
||||
"Label": "標籤",
|
||||
|
|
@ -526,6 +540,9 @@
|
|||
"Library saved": "照片庫已儲存",
|
||||
"Licence:": "授權:",
|
||||
"LightCraft could not start": "LightCraft 無法啟動",
|
||||
"Lightroom Classic Catalog": "Lightroom Classic 目錄",
|
||||
"Lightroom import cancelled": "已取消 Lightroom 匯入",
|
||||
"Lightroom inspection cancelled": "已取消 Lightroom 檢查",
|
||||
"Line delta": "行數差",
|
||||
"Linear": "線性",
|
||||
"Lines +/−": "行數 +/−",
|
||||
|
|
@ -552,6 +569,7 @@
|
|||
"Nested group": "巢狀群組",
|
||||
"New time": "新時間",
|
||||
"No contributor data was built into this copy.": "此版本未內建貢獻者資料。",
|
||||
"No matching commands": "沒有符合的命令",
|
||||
"No model credits were built into this copy.": "此版本未內建模型致謝資訊。",
|
||||
"No named people in these photos": "這些照片中沒有已命名的人物",
|
||||
"No named people yet": "尚無已命名的人物",
|
||||
|
|
@ -574,6 +592,7 @@
|
|||
"Pictures": "圖片",
|
||||
"Prepare AI Masks": "準備 AI 遮色片",
|
||||
"Presets and profiles": "預設集與描述檔",
|
||||
"Press keys…": "請按鍵…",
|
||||
"Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG",
|
||||
"Proof copy created": "已建立校樣副本",
|
||||
"Quit Anyway": "仍要結束",
|
||||
|
|
@ -581,12 +600,24 @@
|
|||
"RAW decoding": "RAW 解碼",
|
||||
"Radial": "放射狀",
|
||||
"Random": "隨機",
|
||||
"Rate ★": "評為 ★",
|
||||
"Rate ★ and advance": "評為 ★ 並前進",
|
||||
"Rate ★★": "評為 ★★",
|
||||
"Rate ★★ and advance": "評為 ★★ 並前進",
|
||||
"Rate ★★★": "評為 ★★★",
|
||||
"Rate ★★★ and advance": "評為 ★★★ 並前進",
|
||||
"Rate ★★★★": "評為 ★★★★",
|
||||
"Rate ★★★★ and advance": "評為 ★★★★ 並前進",
|
||||
"Rate ★★★★★": "評為 ★★★★★",
|
||||
"Rate ★★★★★ and advance": "評為 ★★★★★ 並前進",
|
||||
"Rating cleared": "已清除評分",
|
||||
"Raw": "RAW",
|
||||
"Read the SAM License": "閱讀 SAM 授權",
|
||||
"Reading Lightroom catalog…": "正在讀取 Lightroom 目錄…",
|
||||
"Real name": "真實姓名",
|
||||
"Recent": "最近使用",
|
||||
"Recent Keywords": "最近使用的關鍵字",
|
||||
"Red label": "紅色標籤",
|
||||
"Refine": "調整",
|
||||
"Refine AI Mask Detail": "調整 AI 遮色片細節",
|
||||
"Refine Edges": "調整邊緣",
|
||||
|
|
@ -598,9 +629,12 @@
|
|||
"Remove Face Box": "移除臉部框",
|
||||
"Remove a filter above, or choose Clear all": "請移除上方的某個篩選條件,或選擇「全部清除」",
|
||||
"Remove from Target Album": "從目標相簿移除",
|
||||
"Remove shortcut": "移除快速鍵",
|
||||
"Remove this face box (undo with Edit ▸ Undo)": "移除此臉部框(可用「編輯 ▸ 復原」還原)",
|
||||
"Rename Component": "重新命名元件",
|
||||
"Reorder Masks": "重新排序遮色片",
|
||||
"Reset All": "全部重設",
|
||||
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
|
||||
"Reshuffle": "重新隨機排序",
|
||||
"File name": "檔案名稱",
|
||||
"Fill Edges": "填補邊緣",
|
||||
|
|
@ -1146,6 +1180,7 @@
|
|||
"Resize to": "調整尺寸為",
|
||||
"Resolution (ppi)": "解析度(ppi)",
|
||||
"Restore": "還原",
|
||||
"Restore (no shortcut)": "還原(無快速鍵)",
|
||||
"Restore Library from Backup…": "從備份還原照片庫…",
|
||||
"Restore Version": "還原版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
|
||||
|
|
@ -1192,6 +1227,7 @@
|
|||
"Save as set": "儲存為關鍵字集",
|
||||
"Save as…": "另存新檔…",
|
||||
"Save preset": "儲存預設集",
|
||||
"Saving Lightroom import index…": "正在儲存 Lightroom 匯入索引…",
|
||||
"Scale": "縮放",
|
||||
"Scan failed": "掃描失敗",
|
||||
"Scanning": "正在掃描",
|
||||
|
|
@ -1199,6 +1235,7 @@
|
|||
"Screen": "螢幕",
|
||||
"Search": "搜尋",
|
||||
"Search Photos": "搜尋照片",
|
||||
"Search commands or keys": "搜尋命令或按鍵",
|
||||
"Search…": "搜尋…",
|
||||
"Seasons": "四季",
|
||||
"Second Window": "第二視窗",
|
||||
|
|
@ -1229,6 +1266,7 @@
|
|||
"Set Develop Values": "設定顯像參數",
|
||||
"Set Flag": "設定旗標",
|
||||
"Set Image Zoom and Pan": "設定影像縮放與平移",
|
||||
"Set Keyboard Shortcut": "設定鍵盤快速鍵",
|
||||
"Set Mask Adjustments": "設定遮色片調整",
|
||||
"Set Point Curve": "設定點曲線",
|
||||
"Set Profile": "設定描述檔",
|
||||
|
|
@ -1278,6 +1316,7 @@
|
|||
"Show Pins": "顯示控制點",
|
||||
"Show Source": "顯示來源",
|
||||
"Show Thumbnails": "顯示縮圖",
|
||||
"Show commands without a shortcut": "顯示沒有快速鍵的命令",
|
||||
"Show destination gamut warning": "顯示目標色域警告",
|
||||
"Show display gamut warning": "顯示螢幕色域警告",
|
||||
"Show file names": "顯示檔案名稱",
|
||||
|
|
@ -1381,6 +1420,7 @@
|
|||
"Text": "文字",
|
||||
"Text direction": "文字方向",
|
||||
"Texture": "紋理",
|
||||
"That key can't be used as a shortcut": "該按鍵無法用作快速鍵",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 模型已安裝:物件遮色片與描述遮色片已可使用。",
|
||||
"The download didn't work:": "下載失敗:",
|
||||
"The number at the end of the original name (IMG_0042 → 0042)": "原始名稱結尾的數字(IMG_0042 → 0042)",
|
||||
|
|
@ -1390,7 +1430,6 @@
|
|||
"Theme": "主題",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
|
||||
"Thirds": "三分法",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
|
||||
"This photo can't be opened": "無法開啟此照片",
|
||||
"Threshold": "臨界值",
|
||||
|
|
@ -1418,6 +1457,7 @@
|
|||
"Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
|
||||
"Type": "類型",
|
||||
"Uncheck All": "全部取消勾選",
|
||||
"Uncheck the files of this type": "取消勾選此類型的所有檔案",
|
||||
"Undo": "復原",
|
||||
"Unedited": "未編輯",
|
||||
"Unedited raws": "未編輯的 RAW",
|
||||
|
|
@ -1494,6 +1534,7 @@
|
|||
"Yellow Hue": "黃色色相",
|
||||
"Yellow Luminance": "黃色明度",
|
||||
"Yellow Saturation": "黃色飽和度",
|
||||
"Yellow label": "黃色標籤",
|
||||
"Yes": "是",
|
||||
"You can close this: the download continues, and resumes if interrupted.": "可以關閉此視窗:下載會繼續進行,中斷後也會接續下載。",
|
||||
"Your Name": "你的名字",
|
||||
|
|
@ -1595,5 +1636,8 @@
|
|||
"Expand": "展開",
|
||||
"Remove Disk from Library": "從照片庫移除磁碟",
|
||||
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,21 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
|||
}
|
||||
|
||||
pub fn all_commands(app: &LightcraftApp) -> Value {
|
||||
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
let mut v: Vec<Value> = app
|
||||
.session
|
||||
.commands()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
||||
let sc = crate::shortcuts::binding(keymap, id, *sc);
|
||||
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
||||
}
|
||||
Value::Array(v)
|
||||
|
|
|
|||
|
|
@ -1635,8 +1635,9 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (src, dest) = (base.join("card"), base.join("out"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
|
||||
// more files than a batch holds (see `batch_size`)
|
||||
for i in 0..20u8 {
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 12, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
|
||||
}
|
||||
|
|
@ -1651,7 +1652,7 @@ mod tests {
|
|||
h.settle(SETTLE);
|
||||
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.candidates.len(), 10);
|
||||
assert_eq!(opts.candidates.len(), 20);
|
||||
for id in ["button:importCopy", "button:importDest"] {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), t);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
|
|
@ -1670,8 +1671,8 @@ mod tests {
|
|||
assert!(h.app.import.is_none(), "finished");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
|
||||
let want: Vec<String> = (1..=20).map(|i| format!("Trip-{i:02}.png")).collect();
|
||||
assert_eq!(names, want, "numbered across batches of {}", lightcraft_engine::import::batch_size());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ use crate::render::Slot;
|
|||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
|
||||
pub(crate) const BATCH: usize = 8;
|
||||
/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the
|
||||
/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const BATCH: usize = 8;
|
||||
|
||||
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
|
||||
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
|
||||
|
|
@ -87,6 +89,22 @@ pub struct ImportDialog {
|
|||
pub last_clicked: Option<usize>,
|
||||
}
|
||||
|
||||
/// A candidate's file type, as the review groups them: its format, or its extension when the
|
||||
/// probe named none (`JPEG` and `JPG` are one type).
|
||||
fn file_type(c: &ImportCandidate) -> String {
|
||||
let f = if c.format.trim().is_empty() {
|
||||
std::path::Path::new(&c.path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else {
|
||||
c.format.clone()
|
||||
};
|
||||
match f.trim().to_uppercase().as_str() {
|
||||
"JPEG" => "JPG".into(),
|
||||
"TIFF" => "TIF".into(),
|
||||
"" => "?".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
pub fn new(candidates: Vec<ImportCandidate>) -> Self {
|
||||
let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect();
|
||||
|
|
@ -95,6 +113,29 @@ impl ImportDialog {
|
|||
pub fn importable(&self, i: usize) -> bool {
|
||||
self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty()))
|
||||
}
|
||||
/// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each.
|
||||
pub fn file_types(&self) -> Vec<(String, usize)> {
|
||||
let mut types: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for c in &self.candidates {
|
||||
*types.entry(file_type(c)).or_default() += 1;
|
||||
}
|
||||
types.into_iter().collect()
|
||||
}
|
||||
/// Any file of type `ty` is checked.
|
||||
pub fn type_checked(&self, ty: &str) -> bool {
|
||||
self.candidates.iter().zip(&self.checked).any(|(c, on)| *on && file_type(c) == ty)
|
||||
}
|
||||
/// Check (the importable ones) or uncheck every file of type `ty`: the raws only, say, and not
|
||||
/// the JPEGs kept beside them (issue #344).
|
||||
pub fn check_type(&mut self, ty: &str, on: bool) {
|
||||
for i in 0..self.candidates.len() {
|
||||
let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty);
|
||||
let importable = self.importable(i);
|
||||
if same && let Some(c) = self.checked.get_mut(i) {
|
||||
*c = on && importable;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Candidate `i` is a file whose photo is in Recently Deleted.
|
||||
pub fn is_trashed(&self, i: usize) -> bool {
|
||||
self.trashed.get(i).copied().unwrap_or(false)
|
||||
|
|
@ -273,7 +314,7 @@ impl ImportRun {
|
|||
let ctx = ctx.clone();
|
||||
let work = move || {
|
||||
let files = job.expand(&queue);
|
||||
for chunk in files.chunks(BATCH) {
|
||||
for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -819,6 +860,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
|||
}
|
||||
});
|
||||
});
|
||||
// with more than one file type: a toggle per type checks or unchecks all of its files
|
||||
let types = d.file_types();
|
||||
if types.len() > 1 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim));
|
||||
for (ty, count) in &types {
|
||||
let on = d.type_checked(ty);
|
||||
let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on)
|
||||
.on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" }));
|
||||
if r.clicked() {
|
||||
d.check_type(ty, !on);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// candidate grid
|
||||
let cell = 116.0;
|
||||
let avail = ui.available_width();
|
||||
|
|
@ -1390,6 +1446,32 @@ mod tests {
|
|||
ImportDialog::new(candidates)
|
||||
}
|
||||
|
||||
/// A toggle per file type: the raws only, without the JPEGs beside them (issue #344).
|
||||
#[test]
|
||||
fn file_types_check_and_uncheck_together() {
|
||||
let mut candidates: Vec<ImportCandidate> =
|
||||
["a.ARW", "a.JPG", "b.arw", "b.jpeg", "c.png"].iter().map(|p| ImportCandidate { path: p.to_string(), ..Default::default() }).collect();
|
||||
candidates[2].format = "ARW".into();
|
||||
candidates[3].format = "JPEG".into();
|
||||
candidates[2].duplicate = Some("content".into());
|
||||
let mut d = ImportDialog::new(candidates);
|
||||
assert_eq!(d.file_types(), [("ARW".to_string(), 2), ("JPG".to_string(), 2), ("PNG".to_string(), 1)]);
|
||||
assert!(d.type_checked("JPG"));
|
||||
d.check_type("JPG", false);
|
||||
assert_eq!(d.checked, [true, false, false, false, true]);
|
||||
assert!(!d.type_checked("JPG"));
|
||||
d.check_type("PNG", false);
|
||||
d.check_type("ARW", true);
|
||||
// the duplicate stays unchecked: it isn't importable
|
||||
assert_eq!(d.checked, [true, false, false, false, false]);
|
||||
d.check_type("JPG", true);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
assert_eq!(d.selected_paths(), ["a.ARW", "a.JPG", "b.jpeg"]);
|
||||
// an unknown type changes nothing
|
||||
d.check_type("CR3", false);
|
||||
assert_eq!(d.checked, [true, true, false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_toggles_one_candidate() {
|
||||
let mut d = dialog();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod headless;
|
|||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod import;
|
||||
pub mod lightroom_import;
|
||||
pub mod links;
|
||||
pub mod menubar;
|
||||
pub mod menus;
|
||||
|
|
@ -33,6 +34,8 @@ mod tests_filmstrip;
|
|||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_keymap;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
|
|
@ -106,6 +109,8 @@ pub struct Services {
|
|||
pub reveal: Option<RevealFn>,
|
||||
/// Choose a folder (Settings → General → Open Library…; desktop only).
|
||||
pub pick_folder: Option<PickFolder>,
|
||||
/// Open a Lightroom Classic `.lrcat` catalog for read-only import.
|
||||
pub pick_lightroom_catalog: Option<PickFolder>,
|
||||
/// Open a web link in the browser (Help menu, About, Discord button).
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
|
|
@ -148,6 +153,9 @@ pub struct LightcraftApp {
|
|||
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
|
||||
/// leaves them alone so nothing fires twice.
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
|
||||
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
|
||||
pub recording_shortcut: Option<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
|
|
@ -193,6 +201,10 @@ pub struct LightcraftApp {
|
|||
pub import: Option<import::ImportTask>,
|
||||
/// A folder scan in progress (feeds the import review).
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// 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.
|
||||
pub lightroom_last: Option<Value>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// Background file-system work of other commands (Find Missing Photos, auto import…).
|
||||
|
|
@ -227,6 +239,7 @@ impl LightcraftApp {
|
|||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
recording_shortcut: None,
|
||||
headless_host: false,
|
||||
notices: vec![],
|
||||
quit_prompt: None,
|
||||
|
|
@ -252,6 +265,8 @@ impl LightcraftApp {
|
|||
merge: merge::MergeState::default(),
|
||||
import: None,
|
||||
scan: None,
|
||||
lightroom: None,
|
||||
lightroom_last: None,
|
||||
export: None,
|
||||
tasks: Default::default(),
|
||||
last_export_result: None,
|
||||
|
|
@ -651,6 +666,7 @@ impl LightcraftApp {
|
|||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
lightroom_import::tick(self, ctx);
|
||||
tasks::poll(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.save_status(ctx);
|
||||
|
|
@ -672,7 +688,7 @@ 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.tasks.is_running(LABEL) {
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.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>| {
|
||||
|
|
@ -713,10 +729,12 @@ impl LightcraftApp {
|
|||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
|
||||
if !photos.is_empty()
|
||||
&& let Err(e) = import::start_paths(self, photos)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
if !photos.is_empty() {
|
||||
if self.lightroom.is_some() {
|
||||
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
|
||||
} else if let Err(e) = import::start_paths(self, photos) {
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -853,6 +871,7 @@ impl LightcraftApp {
|
|||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
lightroom_import::progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,471 @@
|
|||
//! Background Lightroom catalog inspection and import.
|
||||
//!
|
||||
//! Reading a catalog, probing its originals and staging its archive can take minutes on a
|
||||
//! removable or network drive. This task keeps that work off the owner thread, commits prepared
|
||||
//! data only on the owner thread, then finishes the archive index on a worker.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use egui::Align2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Inspect,
|
||||
Import,
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Inspected(Result<Value, String>),
|
||||
Prepared(Result<Box<lightcraft_engine::lightroom_job::PreparedLightroom>, String>),
|
||||
Finalized(Result<(), String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
Reading,
|
||||
Committing,
|
||||
Finalizing,
|
||||
}
|
||||
|
||||
/// A Lightroom task in flight. The receiver is polled from the egui owner thread.
|
||||
pub struct LightroomTask {
|
||||
kind: Kind,
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
rx: mpsc::Receiver<Message>,
|
||||
phase: Phase,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
impl LightroomTask {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn status(&self) -> Value {
|
||||
let kind = match self.kind {
|
||||
Kind::Inspect => "inspect",
|
||||
Kind::Import => "import",
|
||||
};
|
||||
let phase = match self.phase {
|
||||
Phase::Reading => "reading",
|
||||
Phase::Committing => "committing",
|
||||
Phase::Finalizing => "finalizing",
|
||||
};
|
||||
json!({
|
||||
"running": true,
|
||||
"kind": kind,
|
||||
"path": self.path,
|
||||
"phase": phase,
|
||||
"done": self.done.load(Ordering::Relaxed),
|
||||
"total": self.total.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightroomTask {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn unsupported_wasm() -> Result<Value, String> {
|
||||
Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into())
|
||||
}
|
||||
|
||||
/// Whether a Lightroom task currently owns the catalog transition.
|
||||
pub fn is_running(app: &LightcraftApp) -> bool {
|
||||
app.lightroom.is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn busy(app: &LightcraftApp) -> Result<(), String> {
|
||||
if app.lightroom.is_some() {
|
||||
return Err("a Lightroom catalog task is already running".into());
|
||||
}
|
||||
if app.import.is_some() || app.scan.is_some() {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_inspect(
|
||||
path: PathBuf,
|
||||
cancel: Arc<AtomicBool>,
|
||||
total: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicUsize>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-inspect".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom inspect", || {
|
||||
lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done)
|
||||
})
|
||||
.and_then(|r| r.map(inspect_report));
|
||||
let _ = tx.send(Message::Inspected(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom inspect: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_prepare(
|
||||
mut job: lightcraft_engine::lightroom_job::LightroomJob,
|
||||
cancel: Arc<AtomicBool>,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-import".into())
|
||||
.spawn(move || {
|
||||
let result =
|
||||
lightcraft_engine::guard::catch("Lightroom import", || job.prepare(&cancel)).and_then(|r| r.map(Box::new).map_err(|e| e.to_string()));
|
||||
if let Err(mpsc::SendError(Message::Prepared(Ok(prepared)))) = tx.send(Message::Prepared(result)) {
|
||||
// The owner dropped the task (for example while closing): staged copies/moves
|
||||
// must be put back even though no UI receiver remains to accept the result.
|
||||
prepared.rollback();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom import: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_finalize(
|
||||
finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
tx: mpsc::Sender<Message>,
|
||||
ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
std::thread::Builder::new()
|
||||
.name("lc-lightroom-finalize".into())
|
||||
.spawn(move || {
|
||||
let result = lightcraft_engine::guard::catch("Lightroom archive finalization", || finalization.finish())
|
||||
.and_then(|r| r.map_err(|e| e.to_string()));
|
||||
let _ = tx.send(Message::Finalized(result));
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not start Lightroom archive finalization: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn spawn_finalize(
|
||||
_finalization: lightcraft_engine::lightroom_job::LightroomArchiveFinalization,
|
||||
_tx: mpsc::Sender<Message>,
|
||||
_ctx: egui::Context,
|
||||
) -> Result<(), String> {
|
||||
unsupported_wasm().map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value {
|
||||
json!({
|
||||
"photos": data.photos.len(),
|
||||
"collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(),
|
||||
"missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(),
|
||||
"warnings": data.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Value) {
|
||||
let message = if let Some(e) = value.get("error").and_then(Value::as_str) {
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}"));
|
||||
if matches!(kind, Kind::Inspect) {
|
||||
crate::i18n::tr_format!("Lightroom inspection failed: {e}{warning}", e = e, warning = warning)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import failed: {e}{warning}", e = e, warning = warning)
|
||||
}
|
||||
} else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) {
|
||||
crate::i18n::tr(if matches!(kind, Kind::Inspect) { "Lightroom inspection cancelled" } else { "Lightroom import cancelled" }).into()
|
||||
} else if matches!(kind, Kind::Inspect) {
|
||||
let photos = value["photos"].as_u64().unwrap_or(0);
|
||||
let collections = value["collections"].as_u64().unwrap_or(0);
|
||||
let missing = value["missing"].as_array().map_or(0, Vec::len);
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing",
|
||||
photos = photos,
|
||||
collections = collections,
|
||||
missing = missing
|
||||
)
|
||||
} else {
|
||||
let report = value.get("report").unwrap_or(&value);
|
||||
let imported = report["imported"].as_u64().unwrap_or(0);
|
||||
let failed = report["failed"].as_array().map_or(0, Vec::len);
|
||||
let warning = value.get("indexWarning").and_then(Value::as_str);
|
||||
if let Some(warning) = warning {
|
||||
crate::i18n::tr_format!(
|
||||
"Lightroom import complete: {imported} photos added; index warning: {warning}",
|
||||
imported = imported,
|
||||
warning = warning
|
||||
)
|
||||
} else if failed == 0 {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added", imported = imported)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed)
|
||||
}
|
||||
};
|
||||
app.lightroom_last = Some(value);
|
||||
app.lightroom = None;
|
||||
app.toast_for(ctx, message, 6.0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "inspect", "path": path}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, ctx: &egui::Context) -> Result<Value, String> {
|
||||
busy(app)?;
|
||||
let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?;
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let total = job.total_atomic();
|
||||
let done = job.done_atomic();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
spawn_prepare(job, cancel.clone(), tx, ctx.clone())?;
|
||||
app.lightroom_last = None;
|
||||
app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
|
||||
Ok(json!({"running": true, "kind": "import", "path": path}))
|
||||
}
|
||||
|
||||
/// Run Lightroom UI commands before the generic engine dispatcher.
|
||||
pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context) -> Result<Value, String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (app, id, p, ctx);
|
||||
return unsupported_wasm();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = p.get("path").and_then(Value::as_str).map(PathBuf::from);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
||||
let status = p.get("status").and_then(Value::as_bool).unwrap_or(false) || path.is_none();
|
||||
if status {
|
||||
if p.get("cancel").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(task) = app.lightroom.as_mut()
|
||||
{
|
||||
task.cancel();
|
||||
}
|
||||
if wait && app.lightroom.is_some() {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
return Ok(app.lightroom.as_ref().map(LightroomTask::status).or_else(|| app.lightroom_last.clone()).unwrap_or(Value::Null));
|
||||
}
|
||||
let Some(path) = path else { return Err("missing `path`".into()) };
|
||||
let result = if id == "library.inspectLightroom" {
|
||||
start_inspect(app, path, ctx)
|
||||
} else {
|
||||
start_import(app, path, p.get("updateExisting").and_then(Value::as_bool).unwrap_or(false), ctx)
|
||||
}?;
|
||||
if wait {
|
||||
return wait_for(app, ctx, std::time::Duration::from_secs(600));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll owner-thread work, commit a prepared import, and finish its archive index.
|
||||
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut task) = app.lightroom.take() else { return };
|
||||
let message = match task.rx.try_recv() {
|
||||
Ok(m) => m,
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
app.lightroom = Some(task);
|
||||
return;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": "Lightroom worker stopped unexpectedly"}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match message {
|
||||
Message::Inspected(result) => match result {
|
||||
Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, task.kind, report),
|
||||
Ok(_) => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => terminal(app, ctx, task.kind, json!({"cancelled": true})),
|
||||
Err(e) => terminal(app, ctx, task.kind, json!({"error": e})),
|
||||
},
|
||||
Message::Prepared(result) => {
|
||||
let prepared = match result {
|
||||
Ok(p) => p,
|
||||
Err(e) if task.cancel.load(Ordering::Relaxed) || e.contains("cancelled") => {
|
||||
terminal(app, ctx, task.kind, json!({"cancelled": true}));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
terminal(app, ctx, task.kind, json!({"error": e}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) {
|
||||
prepared.rollback();
|
||||
terminal(
|
||||
app,
|
||||
ctx,
|
||||
task.kind,
|
||||
if task.cancel.load(Ordering::Relaxed) {
|
||||
json!({"cancelled": true})
|
||||
} else {
|
||||
json!({"error": "Lightroom import belongs to a different open library"})
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
task.phase = Phase::Committing;
|
||||
let mut finalization = None;
|
||||
let mut report = None;
|
||||
let committed = app.session.execute_fn("library.importLightroom", |s| {
|
||||
let completion = lightcraft_engine::lightroom_job::commit_prepared(s, *prepared)?;
|
||||
report = Some(completion.report.clone());
|
||||
finalization = Some(completion.finalization);
|
||||
Ok(completion.report)
|
||||
});
|
||||
let report = report.unwrap_or(Value::Null);
|
||||
task.phase = Phase::Finalizing;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let commit_error = committed.as_ref().err().map(ToString::to_string);
|
||||
let Some(finalization) = finalization else {
|
||||
terminal(app, ctx, task.kind, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));
|
||||
return;
|
||||
};
|
||||
if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {
|
||||
let mut result = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = commit_error {
|
||||
result["error"] = json!(error);
|
||||
}
|
||||
terminal(app, ctx, task.kind, result);
|
||||
return;
|
||||
}
|
||||
task.rx = rx;
|
||||
task.cancelled = false;
|
||||
let mut pending = json!({"running": true, "kind": "import", "report": report});
|
||||
if let Some(error) = commit_error {
|
||||
pending["error"] = json!(error);
|
||||
}
|
||||
app.lightroom_last = Some(pending);
|
||||
app.lightroom = Some(task);
|
||||
}
|
||||
Message::Finalized(result) => {
|
||||
let pending = app.lightroom_last.take().unwrap_or(Value::Null);
|
||||
let report = pending.get("report").cloned().unwrap_or(Value::Null);
|
||||
match result {
|
||||
Ok(()) if pending.get("error").is_none() => terminal(app, ctx, task.kind, report),
|
||||
Ok(()) => terminal(app, ctx, task.kind, json!({"report": report, "error": pending["error"].clone()})),
|
||||
Err(e) => {
|
||||
let mut value = json!({"report": report, "indexWarning": e});
|
||||
if let Some(error) = pending.get("error") {
|
||||
value["error"] = error.clone();
|
||||
}
|
||||
terminal(app, ctx, task.kind, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Wait for a task in control/headless mode while still applying owner-thread completion.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait_for(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> Result<Value, String> {
|
||||
let start = std::time::Instant::now();
|
||||
while app.lightroom.is_some() {
|
||||
tick(app, ctx);
|
||||
if start.elapsed() >= timeout {
|
||||
return Err("timed out waiting for Lightroom task".into());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
Ok(app.lightroom_last.clone().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::time::Duration) -> Result<Value, String> {
|
||||
unsupported_wasm()
|
||||
}
|
||||
|
||||
/// The progress window for inspect/import, with cancellation.
|
||||
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.lightroom.as_ref() else { return };
|
||||
let total = task.total.load(Ordering::Relaxed);
|
||||
let done = task.done.load(Ordering::Relaxed);
|
||||
let title = match task.kind {
|
||||
Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"),
|
||||
Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"),
|
||||
};
|
||||
let text = match task.phase {
|
||||
Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(),
|
||||
Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total),
|
||||
Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(),
|
||||
Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(),
|
||||
};
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0));
|
||||
let r =
|
||||
ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
register(ui.ctx(), "button:lightroomCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
},
|
||||
);
|
||||
if cancel {
|
||||
if let Some(task) = app.lightroom.as_mut() {
|
||||
task.cancel();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if app.lightroom.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::inspect_report;
|
||||
use lightcraft_engine::lightroom_catalog::CatalogImport;
|
||||
|
||||
#[test]
|
||||
fn inspect_report_keeps_empty_catalog_shape() {
|
||||
let report = inspect_report(CatalogImport {
|
||||
source: "synthetic.lrcat".into(),
|
||||
photos: Vec::new(),
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: vec!["warning".into()],
|
||||
});
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["collections"], 0);
|
||||
assert_eq!(report["missing"], serde_json::json!([]));
|
||||
assert_eq!(report["warnings"], serde_json::json!(["warning"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +150,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
|
||||
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
|
||||
("file.addFolder", "Import from Folder…", None, "File"),
|
||||
("file.importLightroom", "Import Lightroom Catalog…", None, "File"),
|
||||
("file.addFromDevice", "Import from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("file.backupLibrary", "Back Up Library…", None, "File"),
|
||||
|
|
@ -175,6 +176,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("app.github", "LightCraft on GitHub", None, "Help"),
|
||||
("app.artcraft", "ArtCraft Website", None, "Help"),
|
||||
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
|
||||
("app.setShortcut", "Set Keyboard Shortcut", None, ""),
|
||||
("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""),
|
||||
("app.export", "Export Now", None, ""),
|
||||
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
|
||||
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
|
||||
|
|
@ -242,6 +245,10 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
|||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
|
||||
let ctx = egui::Context::default();
|
||||
return Some(crate::lightroom_import::command(app, id, p, &ctx));
|
||||
}
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
// Immediately, not on the next frame: the reply and anything else run this frame
|
||||
|
|
@ -978,12 +985,23 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
app.ui.dialog = Some(Dialog::Shortcuts);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.setShortcut" => crate::shortcuts::set_shortcut(app, p),
|
||||
"app.resetShortcuts" => {
|
||||
app.ui.settings.keymap.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"library.browse" if !cfg!(target_arch = "wasm32") => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before browsing folders".into()));
|
||||
}
|
||||
// listed and read in the background (see `import::browse`)
|
||||
let path = p.get("path").and_then(Value::as_str)?;
|
||||
crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool))
|
||||
}
|
||||
"file.addPhotos" => {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Some(Err("finish Lightroom catalog import before adding photos".into()));
|
||||
}
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
|
||||
|
|
@ -994,6 +1012,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
// review first: the import dialog lists what was found
|
||||
crate::import::open(app, paths)
|
||||
}
|
||||
"file.importLightroom" => {
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f()));
|
||||
match path {
|
||||
Some(path) => app.run("library.importLightroom", json!({"path":path})),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.ui.quit = true;
|
||||
Ok(Value::Null)
|
||||
|
|
@ -1321,7 +1347,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
|||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
|
||||
|
|
@ -1349,7 +1375,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
|||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
menu: m.split('>').map(str::to_string).collect(),
|
||||
shortcut: sc.map(str::to_string),
|
||||
shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string),
|
||||
enabled: ui_enabled(app, id),
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -1359,7 +1385,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
|||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
menu: c.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: c.shortcut.map(str::to_string),
|
||||
shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string),
|
||||
enabled: c.enabled,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -780,50 +780,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app);
|
||||
ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label }));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for sc in ["0–5", "Shift+0–5"] {
|
||||
ui.label(crate::i18n::tr("Set Rating"));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new("photo.rate").color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(crate::i18n::tr("Set Color Label"));
|
||||
ui.label("6–9");
|
||||
ui.label(egui::RichText::new("photo.label").color(t.text_dim));
|
||||
ui.end_row();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(crate::i18n::tr(c.label));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new(c.id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for (sc, id, _) in crate::shortcuts::ALIASES {
|
||||
let label = crate::menus::ui_commands()
|
||||
.find(|c| c.0 == *id)
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
.unwrap_or(id);
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t),
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -882,6 +839,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
ctx.move_to_top(w.response.layer_id);
|
||||
crate::widgets::register(ctx, "dialog:window", w.response.rect);
|
||||
}
|
||||
// (while the shortcuts editor records a key, it takes Esc itself to cancel)
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
//! Help ▸ Keyboard Shortcuts (⌘/): every command's shortcut, editable.
|
||||
//!
|
||||
//! Click a shortcut, then press the new keys (Esc cancels); × removes it, ↺ restores the declared
|
||||
//! one. A key another command had moves to the new command. Changes go through
|
||||
//! [`crate::shortcuts::assign`] into [`crate::state::AppSettings::keymap`] (saved in `ui.json`),
|
||||
//! the same path as the `app.setShortcut` command.
|
||||
|
||||
use egui::RichText;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::shortcuts::{self, Bindable};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
const SEARCH: &str = "shortcuts-search";
|
||||
const SHOW_ALL: &str = "shortcuts-show-all";
|
||||
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
capture(app, ui.ctx());
|
||||
ui.set_min_width(560.0);
|
||||
let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL));
|
||||
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0));
|
||||
register(ui.ctx(), "field:shortcutsSearch", r.rect);
|
||||
let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut"));
|
||||
register(ui.ctx(), "check:shortcuts.showAll", r.rect);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All")));
|
||||
register(ui.ctx(), "button:shortcutsResetAll", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.resetShortcuts", serde_json::json!({}));
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(search_id, search.clone());
|
||||
d.insert_temp(all_id, show_all);
|
||||
});
|
||||
ui.label(RichText::new(crate::i18n::tr("Click a shortcut, then press the new keys (Esc cancels).")).size(11.0).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
|
||||
let needle = search.trim().to_lowercase();
|
||||
let mut rows: Vec<(&'static Bindable, Option<String>)> = shortcuts::bindable()
|
||||
.iter()
|
||||
.map(|b| (b, shortcuts::binding(&app.ui.settings.keymap, b.id, b.default).map(str::to_string)))
|
||||
.filter(|(b, sc)| show_all || sc.is_some() || app.recording_shortcut.as_deref() == Some(b.id))
|
||||
.filter(|(b, sc)| {
|
||||
needle.is_empty()
|
||||
|| crate::i18n::tr(b.label).to_lowercase().contains(&needle)
|
||||
|| b.id.to_lowercase().contains(&needle)
|
||||
|| sc.as_deref().is_some_and(|sc| sc.to_lowercase().contains(&needle) || menu_text(sc, mac).to_lowercase().contains(&needle))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase());
|
||||
|
||||
// a fixed height, so the dialog doesn't jump around while the search narrows the list
|
||||
egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (b, sc) in &rows {
|
||||
row(app, ui, t, b, sc.as_deref(), mac);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.label(RichText::new(crate::i18n::tr("No matching commands")).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
fixed_keys(app, ui, t, mac, &needle);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS).
|
||||
fn menu_text(sc: &str, mac: bool) -> String {
|
||||
crate::menubar::shortcut_text(sc, mac)
|
||||
}
|
||||
|
||||
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) {
|
||||
let changed = app.ui.settings.keymap.contains_key(b.id);
|
||||
let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text);
|
||||
if changed {
|
||||
name = name.strong();
|
||||
}
|
||||
// keys whose command changes in the Library grids (see `shortcuts::handle`)
|
||||
let hover = match b.id {
|
||||
"panel.presets" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: Flag as Pick and advance")),
|
||||
"view.softProof" => format!("{} — {}", b.id, crate::i18n::tr("In Photo Grid and Square Grid: expand or collapse the stack")),
|
||||
_ => b.id.to_string(),
|
||||
};
|
||||
ui.label(name).on_hover_text(hover);
|
||||
let recording = app.recording_shortcut.as_deref() == Some(b.id);
|
||||
let text = if recording {
|
||||
RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE)
|
||||
} else {
|
||||
match sc {
|
||||
Some(sc) => RichText::new(menu_text(sc, mac)).color(t.text),
|
||||
None => RichText::new("—").color(t.text_dim),
|
||||
}
|
||||
};
|
||||
let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording));
|
||||
register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) };
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut"));
|
||||
register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null}));
|
||||
}
|
||||
let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default {
|
||||
Some(d) => crate::i18n::tr_format!("Restore {}", menu_text(d, mac)),
|
||||
None => crate::i18n::tr("Restore (no shortcut)").to_string(),
|
||||
});
|
||||
register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect);
|
||||
if r.clicked() {
|
||||
apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true}));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(b.id).size(10.5).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Keys that aren't a command's own shortcut (ratings, labels, secondary keys): listed, not editable.
|
||||
/// One the user gave to a command shows struck through.
|
||||
fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, needle: &str) {
|
||||
let mut fixed: Vec<(String, &str)> = Vec::new();
|
||||
for (sc, id, _) in shortcuts::ALIASES {
|
||||
let label = shortcuts::find_bindable(id).map_or(*id, |b| b.label);
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
for n in 0..=5 {
|
||||
fixed.push((n.to_string(), ["Clear rating", "Rate ★", "Rate ★★", "Rate ★★★", "Rate ★★★★", "Rate ★★★★★"][n]));
|
||||
fixed.push((
|
||||
format!("Shift+{n}"),
|
||||
[
|
||||
"Clear rating and advance",
|
||||
"Rate ★ and advance",
|
||||
"Rate ★★ and advance",
|
||||
"Rate ★★★ and advance",
|
||||
"Rate ★★★★ and advance",
|
||||
"Rate ★★★★★ and advance",
|
||||
][n],
|
||||
));
|
||||
}
|
||||
for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] {
|
||||
fixed.push((sc.to_string(), label));
|
||||
}
|
||||
let fixed: Vec<_> = fixed
|
||||
.into_iter()
|
||||
.filter(|(sc, label)| {
|
||||
needle.is_empty() || crate::i18n::tr(label).to_lowercase().contains(needle) || menu_text(sc, mac).to_lowercase().contains(needle)
|
||||
})
|
||||
.collect();
|
||||
if fixed.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text));
|
||||
let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect();
|
||||
egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, label) in fixed {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
||||
let mut text = RichText::new(menu_text(&sc, mac)).color(t.text_dim);
|
||||
if shortcuts::parse(&sc).is_some_and(|k| taken.contains(&k)) {
|
||||
text = text.strikethrough();
|
||||
}
|
||||
ui.label(text);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `app.setShortcut` and say what changed (a key taken from another command, or why not).
|
||||
fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value) {
|
||||
match app.run("app.setShortcut", params) {
|
||||
Ok(r) => {
|
||||
let lost: Vec<String> = r["removedFrom"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string())
|
||||
.collect();
|
||||
if !lost.is_empty() {
|
||||
app.toast_for(ctx, crate::i18n::tr_format!("Removed from {}", lost.join(", ")), 3.0);
|
||||
}
|
||||
}
|
||||
Err(e) => app.toast_for(ctx, e, 3.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// While a row is recording: the first key press becomes its shortcut, Esc cancels.
|
||||
fn capture(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.recording_shortcut.clone() else { return };
|
||||
let pressed = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
// ⌘, ⇧, ⌥ and ⌃ on their own arrive as keys too: keep waiting for the key they modify
|
||||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !shortcuts::is_modifier(*key) => Some((*key, *modifiers)),
|
||||
// the windowing layer turns ⌘C / ⌘X / ⌘V (with or without other modifiers) into these
|
||||
egui::Event::Copy => Some((egui::Key::C, i.modifiers)),
|
||||
egui::Event::Cut => Some((egui::Key::X, i.modifiers)),
|
||||
egui::Event::Paste(_) => Some((egui::Key::V, i.modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let Some((key, modifiers)) = pressed else { return };
|
||||
// the key press is ours: nothing else (a focused button's Space, Tab navigation) acts on it
|
||||
ctx.input_mut(|i| {
|
||||
i.events
|
||||
.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)));
|
||||
});
|
||||
app.recording_shortcut = None;
|
||||
if key == egui::Key::Escape && modifiers.is_none() {
|
||||
return;
|
||||
}
|
||||
match shortcuts::format(modifiers, key) {
|
||||
Some(sc) => apply(app, ctx, serde_json::json!({"id": id, "shortcut": sc})),
|
||||
None => app.toast_for(ctx, crate::i18n::tr("That key can't be used as a shortcut"), 3.0),
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub mod dialogs;
|
|||
pub mod edit;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod keymap;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
|
|
|
|||
|
|
@ -543,6 +543,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
|
||||
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
|
||||
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
|
||||
}
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => x.to_string(),
|
||||
None => match app.services.pick_folder.as_mut() {
|
||||
|
|
@ -555,6 +558,7 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
|
|||
};
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
app.lightroom_last = None;
|
||||
app.renderer.forget_all();
|
||||
app.ui.compare = None;
|
||||
app.ui.settings.library_path = path.clone();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,195 @@
|
|||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands.
|
||||
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings, apply the user's keymap (Help ▸
|
||||
//! Keyboard Shortcuts, `app.setShortcut`) and dispatch UI and engine commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
|
||||
/// The user's changes to the keymap: command id → shortcut (`""` = no shortcut). Saved with the
|
||||
/// app settings (`ui.json`); commands not listed keep their declared shortcut.
|
||||
pub type Keymap = BTreeMap<String, String>;
|
||||
|
||||
/// Shortcuts the app menu owns (Settings…, Quit), which no command can take.
|
||||
pub const RESERVED: &[&str] = &["Cmd+,", "Cmd+Q"];
|
||||
|
||||
/// A command that can have a shortcut.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Bindable {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// The declared shortcut. An engine command whose key a UI command wraps (e.g. `W` opens the
|
||||
/// White Balance Selector rather than sampling without a point) has none: the UI one owns it.
|
||||
pub default: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Every command that can have a shortcut: UI commands, then engine commands (one entry per id).
|
||||
pub fn bindable() -> &'static [Bindable] {
|
||||
static ALL: OnceLock<Vec<Bindable>> = OnceLock::new();
|
||||
ALL.get_or_init(|| {
|
||||
let mut v: Vec<Bindable> = Vec::new();
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if !v.iter().any(|b| b.id == *id) {
|
||||
v.push(Bindable { id, label, default: *sc });
|
||||
}
|
||||
}
|
||||
let ui_keys: Vec<(Modifiers, Key)> = v.iter().filter_map(|b| b.default.and_then(parse)).collect();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if v.iter().any(|b| b.id == c.id) {
|
||||
continue;
|
||||
}
|
||||
let default = c.shortcut.filter(|sc| parse(sc).is_some_and(|k| !ui_keys.contains(&k)));
|
||||
v.push(Bindable { id: c.id, label: c.label, default });
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_bindable(id: &str) -> Option<&'static Bindable> {
|
||||
bindable().iter().find(|b| b.id == id)
|
||||
}
|
||||
|
||||
/// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry
|
||||
/// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is
|
||||
/// ignored.
|
||||
pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> {
|
||||
match keymap.get(id) {
|
||||
Some(sc) if sc.is_empty() => None,
|
||||
Some(sc) if parse(sc).is_some() => Some(sc.as_str()),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective shortcut of command `id` (`None` for unknown commands).
|
||||
pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> {
|
||||
find_bindable(id).and_then(|b| binding(keymap, id, b.default))
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
|
||||
/// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted).
|
||||
pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> {
|
||||
let Some(key) = parse(sc) else { return vec![] };
|
||||
bindable()
|
||||
.iter()
|
||||
.filter(|b| b.id != id && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == b.id) || (*y == id && *x == b.id)))
|
||||
.filter(|b| binding(keymap, b.id, b.default).and_then(parse) == Some(key))
|
||||
.map(|b| b.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Give `id` the shortcut `sc` (`None` = no shortcut), taking it away from commands that had the
|
||||
/// same key. Returns the commands that lost it.
|
||||
pub fn assign(keymap: &mut Keymap, id: &str, sc: Option<&str>) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
let sc = match sc.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let key = parse(s).ok_or_else(|| format!("not a shortcut: {s}"))?;
|
||||
// store the canonical spelling so equal keys compare equal (menus, native accelerators)
|
||||
let canonical = format(key.0, key.1).unwrap_or_else(|| s.to_string());
|
||||
if RESERVED.iter().any(|r| parse(r) == Some(key)) {
|
||||
return Err(format!("{canonical} is reserved for the app menu"));
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
};
|
||||
let lost = sc.as_deref().map(|s| conflicts(keymap, id, s)).unwrap_or_default();
|
||||
for other in &lost {
|
||||
set(keymap, other, None);
|
||||
}
|
||||
set(keymap, b.id, sc.as_deref());
|
||||
Ok(lost)
|
||||
}
|
||||
|
||||
/// Store `sc` for `id`, dropping the entry when it equals the declared shortcut.
|
||||
fn set(keymap: &mut Keymap, id: &str, sc: Option<&str>) {
|
||||
let default = find_bindable(id).and_then(|b| b.default);
|
||||
let same = match (sc, default) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => parse(a) == parse(b),
|
||||
_ => false,
|
||||
};
|
||||
if same {
|
||||
keymap.remove(id);
|
||||
} else {
|
||||
keymap.insert(id.to_string(), sc.unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the declared shortcut of `id`, taking it away from a command the user gave it to.
|
||||
pub fn reset(keymap: &mut Keymap, id: &str) -> Result<Vec<&'static str>, String> {
|
||||
let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?;
|
||||
assign(keymap, id, b.default)
|
||||
}
|
||||
|
||||
/// `app.setShortcut {id, shortcut?, reset?}`: `shortcut` null or `""` removes it.
|
||||
pub fn set_shortcut(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = p.get("id").and_then(Value::as_str).ok_or("missing id")?;
|
||||
let keymap = &mut app.ui.settings.keymap;
|
||||
let lost = if p.get("reset").and_then(Value::as_bool).unwrap_or(false) {
|
||||
reset(keymap, id)?
|
||||
} else {
|
||||
match p.get("shortcut") {
|
||||
None => return Err("missing shortcut (null removes it)".into()),
|
||||
Some(Value::Null) => assign(keymap, id, None)?,
|
||||
Some(Value::String(s)) => assign(keymap, id, Some(s))?,
|
||||
Some(_) => return Err("shortcut must be a string or null".into()),
|
||||
}
|
||||
};
|
||||
Ok(json!({"id": id, "shortcut": shortcut_of(&app.ui.settings.keymap, id), "removedFrom": lost}))
|
||||
}
|
||||
|
||||
/// ⌘ / ⇧ / ⌥ / ⌃ themselves: they modify a shortcut's key, they can't be it.
|
||||
pub fn is_modifier(k: Key) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight
|
||||
)
|
||||
}
|
||||
|
||||
/// The shortcut text for a key press (`Cmd+Shift+K`), or `None` for keys a shortcut can't name.
|
||||
/// `Cmd` is ⌘ on macOS and Ctrl elsewhere; `Ctrl` is the macOS Control key.
|
||||
pub fn format(m: Modifiers, k: Key) -> Option<String> {
|
||||
if is_modifier(k) {
|
||||
return None;
|
||||
}
|
||||
let key = match k {
|
||||
Key::Backspace | Key::Delete => "Delete",
|
||||
Key::Slash => "/",
|
||||
Key::Backslash => "\\",
|
||||
Key::Equals => "=",
|
||||
Key::Minus => "-",
|
||||
Key::OpenBracket => "[",
|
||||
Key::CloseBracket => "]",
|
||||
Key::Quote => "'",
|
||||
Key::Comma => ",",
|
||||
k => k.name(),
|
||||
};
|
||||
let mut s = String::new();
|
||||
if m.command || m.mac_cmd {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
// off macOS Ctrl *is* Cmd; on macOS it is the separate Control key
|
||||
if m.ctrl && (m.mac_cmd || !m.command) {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
s.push_str(key);
|
||||
let back = parse(&s)?;
|
||||
let want = if k == Key::Delete { Key::Backspace } else { k };
|
||||
(back.1 == want).then_some(s)
|
||||
}
|
||||
|
||||
/// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`.
|
||||
/// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our
|
||||
/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts.
|
||||
|
|
@ -48,21 +233,26 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
|||
// "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`.
|
||||
"Delete" => key = Some(Key::Backspace),
|
||||
k => {
|
||||
key = Key::from_name(k).or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
// an unknown part (`Hyper`, a typo) makes the whole shortcut invalid
|
||||
key = Some(
|
||||
Key::from_name(k)
|
||||
.or(match k {
|
||||
"Right" => Some(Key::ArrowRight),
|
||||
"Left" => Some(Key::ArrowLeft),
|
||||
"Up" => Some(Key::ArrowUp),
|
||||
"Down" => Some(Key::ArrowDown),
|
||||
"\\" => Some(Key::Backslash),
|
||||
"/" => Some(Key::Slash),
|
||||
"=" => Some(Key::Equals),
|
||||
"-" => Some(Key::Minus),
|
||||
"[" => Some(Key::OpenBracket),
|
||||
"]" => Some(Key::CloseBracket),
|
||||
"'" => Some(Key::Quote),
|
||||
"," => Some(Key::Comma),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|k| !is_modifier(*k))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,36 +282,33 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
|
|||
}
|
||||
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// don't steal keys from text fields
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
|
||||
app.recording_shortcut = None;
|
||||
}
|
||||
// don't steal keys from text fields or from the keymap editor recording a shortcut
|
||||
if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut fire: Vec<String> = Vec::new();
|
||||
// shortcuts the native menu bar handles (it consumes those key presses itself)
|
||||
let native = |sc: &str| app.native_shortcuts.contains(sc);
|
||||
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
let keymap = &app.ui.settings.keymap;
|
||||
// keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them
|
||||
let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect();
|
||||
ctx.input(|i| {
|
||||
let mut ui_keys = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some((m, k)) = sc.and_then(parse) {
|
||||
ui_keys.push((m, k));
|
||||
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
|
||||
fire.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
// A UI command bound to the same key wraps the engine command (e.g. `W` opens the
|
||||
// White Balance Selector tool rather than sampling without a point): the UI one wins.
|
||||
if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse)
|
||||
&& !ui_keys.contains(&(m, k))
|
||||
for b in bindable() {
|
||||
if let Some(sc) = binding(keymap, b.id, b.default)
|
||||
&& let Some((m, k)) = parse(sc)
|
||||
&& !native(sc)
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
fire.push(c.id.to_string());
|
||||
fire.push(b.id.to_string());
|
||||
}
|
||||
}
|
||||
for (sc, id, params) in ALIASES {
|
||||
if let Some((m, k)) = parse(sc).filter(|_| !native(sc))
|
||||
&& !taken.contains(&(m, k))
|
||||
&& matches(i, m, k)
|
||||
{
|
||||
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
|
||||
|
|
@ -129,15 +316,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
// rating 0-5, colour labels 6-9 (with Shift: and advance)
|
||||
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) {
|
||||
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) {
|
||||
fire.push(format!("rate:{n}:0"));
|
||||
}
|
||||
if matches(i, Modifiers::SHIFT, *key) {
|
||||
if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) {
|
||||
fire.push(format!("rate:{n}:1"));
|
||||
}
|
||||
}
|
||||
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) {
|
||||
if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) {
|
||||
fire.push(format!("label:{label}"));
|
||||
}
|
||||
}
|
||||
|
|
@ -554,8 +741,73 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
|
||||
const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
|
||||
#[test]
|
||||
fn key_presses_format_to_shortcuts_that_parse_back() {
|
||||
for k in Key::ALL {
|
||||
for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] {
|
||||
if let Some(sc) = format(m, *k) {
|
||||
let want = if *k == Key::Delete { Key::Backspace } else { *k };
|
||||
assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/"));
|
||||
assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_a_key_moves_it_and_reset_restores_it() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
// D belongs to Detail: Survey takes it, Detail loses it
|
||||
let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap();
|
||||
assert_eq!(lost, vec!["view.detail"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), None);
|
||||
// restoring Detail takes D back
|
||||
let lost = reset(&mut keymap, "view.detail").unwrap();
|
||||
assert_eq!(lost, vec!["view.survey"]);
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
reset(&mut keymap, "view.survey").unwrap();
|
||||
assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}");
|
||||
// a command without a declared shortcut can get one, and lose it again
|
||||
assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err();
|
||||
assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap();
|
||||
assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1"));
|
||||
assign(&mut keymap, "view.photoGrid", None).unwrap();
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_assignments_are_errors() {
|
||||
let mut keymap = Keymap::new();
|
||||
assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err());
|
||||
assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err());
|
||||
assert!(keymap.is_empty());
|
||||
}
|
||||
|
||||
/// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic.
|
||||
#[test]
|
||||
fn junk_keymap_entries_mean_no_shortcut() {
|
||||
let mut keymap = Keymap::new();
|
||||
keymap.insert("view.survey".into(), "Hyper+☃".into());
|
||||
keymap.insert("no.suchCommand".into(), "K".into());
|
||||
keymap.insert("view.detail".into(), "Cmd+SuperLeft".into());
|
||||
assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N"));
|
||||
assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D"));
|
||||
assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey"));
|
||||
assert_eq!(parse("Hyper+K"), None);
|
||||
}
|
||||
|
||||
/// Contextual partners keep sharing their key when one is reassigned.
|
||||
#[test]
|
||||
fn contextual_partners_are_not_conflicts() {
|
||||
let keymap = Keymap::new();
|
||||
assert!(conflicts(&keymap, "photo.reject", "X").is_empty());
|
||||
}
|
||||
|
||||
/// No key fires two different actions (a UI command may shadow the engine command it wraps).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ pub struct AppSettings {
|
|||
pub film_badges: bool,
|
||||
/// Grid: when to show the rating / flag / edited badges.
|
||||
pub grid_badges: GridBadges,
|
||||
/// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none.
|
||||
pub keymap: crate::shortcuts::Keymap,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
|
|
@ -167,6 +169,7 @@ impl Default for AppSettings {
|
|||
film_names: true,
|
||||
film_badges: true,
|
||||
grid_badges: GridBadges::Auto,
|
||||
keymap: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires
|
||||
//! its command and not the old one's, the editor records a key press without running it, Esc
|
||||
//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::{Dialog, ViewMode};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Headless, k: &str, shift: bool) {
|
||||
let r = h.request("ui.key", json!({"key": k, "shift": shift}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_shortcut_fires_and_the_old_key_does_not() {
|
||||
let mut h = demo();
|
||||
let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap();
|
||||
assert_eq!(r["shortcut"], "Shift+K", "{r}");
|
||||
// the old key (N) no longer opens Survey…
|
||||
key(&mut h, "N", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
// …the new one does, and menus show it
|
||||
key(&mut h, "K", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap();
|
||||
assert_eq!(entry.shortcut.as_deref(), Some("Shift+K"));
|
||||
// the keymap is saved with the UI state
|
||||
let saved = serde_json::to_string(&h.app.ui).unwrap();
|
||||
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap();
|
||||
assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_editor_records_a_key_and_esc_cancels_without_closing() {
|
||||
let mut h = demo();
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
// the list is longer than the dialog: narrow it so the row is on screen
|
||||
click(&mut h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
// record Shift+J for Compare: the key press is taken, not run
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare"));
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
assert_eq!(h.app.ui.view, ViewMode::Detail);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
// Esc while recording cancels; the dialog stays open and nothing changes
|
||||
click(&mut h, "button:shortcut-view.compare");
|
||||
key(&mut h, "Escape", false);
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
|
||||
// the next Esc closes it; the new key works
|
||||
key(&mut h, "Escape", false);
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
key(&mut h, "J", true);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Compare);
|
||||
// Reset All restores the declared keys
|
||||
h.app.run("app.resetShortcuts", json!({})).unwrap();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_given_to_a_command_overrides_a_fixed_key() {
|
||||
let mut h = demo();
|
||||
let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
// 3 (rate ★★★) now opens Survey instead
|
||||
h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap();
|
||||
key(&mut h, "3", false);
|
||||
assert_eq!(h.app.ui.view, ViewMode::Survey);
|
||||
let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
|
||||
assert_eq!(before, after, "the rating key must not fire too");
|
||||
}
|
||||
|
||||
/// Open the editor, filtered to `search`, and start recording for `id`.
|
||||
fn record(h: &mut Headless, search: &str, id: &str) {
|
||||
if h.app.ui.dialog.is_none() {
|
||||
h.app.run("app.shortcuts", json!({})).unwrap();
|
||||
h.step();
|
||||
h.step();
|
||||
click(h, "field:shortcutsSearch");
|
||||
assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
click(h, &format!("button:shortcut-{id}"));
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some(id));
|
||||
}
|
||||
|
||||
/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut
|
||||
/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored.
|
||||
#[test]
|
||||
fn modifiers_wait_for_their_key_and_clipboard_keys_record() {
|
||||
let mut h = demo();
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone");
|
||||
let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.recording_shortcut, None);
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K"));
|
||||
// ⌘⌥C: the windowing layer sends a Copy event with the modifiers held
|
||||
record(&mut h, "survey", "view.survey");
|
||||
let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND };
|
||||
h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.app.synthetic.push(egui::Event::Copy);
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C"));
|
||||
// a saved modifier-only shortcut (from the bug) means no shortcut
|
||||
assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Lightroom Classic catalog import
|
||||
|
||||
File → Import Lightroom Catalog… opens `.lrcat` directly. Lightroom, Python, DNG conversion
|
||||
and a C SQLite runtime are not required. The source database is read-only, including committed
|
||||
`-wal` pages; a catalog that changes during the read is rejected. LightCraft owns subsequent edits.
|
||||
|
||||
Equivalent commands:
|
||||
|
||||
```text
|
||||
lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path="Lightroom Catalog.lrcat"
|
||||
lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat"
|
||||
```
|
||||
|
||||
Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata,
|
||||
hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop
|
||||
settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft
|
||||
edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import
|
||||
identity prevents reimport from duplicating virtual copies and collections. One undo step reverses
|
||||
catalog changes; any saved recovery archives remain available.
|
||||
|
||||
Before changing records, the importer attempts to save source image records, decoded XMP,
|
||||
verbatim develop settings, history, snapshots and collection content to compressed
|
||||
`Interop/lightroom-import-N.lca` recovery archives in the LightCraft library. Archiving is
|
||||
best-effort: a size limit or write failure produces a warning and does not prevent photo import.
|
||||
Unchanged source data reuses its existing archive. Opaque SQL BLOBs are skipped. Each archive is
|
||||
capped at 32 MiB (uncompressed and on disk), with at most eight archives and 128 MiB total
|
||||
retained. Oldest managed archives are retired; legacy `.json` archives remain untouched.
|
||||
`Interop/lightroom-index.json` records imported identities. Source catalogs and originals are
|
||||
never overwritten.
|
||||
|
||||
Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per
|
||||
table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format
|
||||
specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB.
|
||||
Optional history, keyword and collection tables that exceed limits are skipped with warnings;
|
||||
required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed
|
||||
WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional.
|
||||
|
||||
Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported
|
||||
mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI
|
||||
models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped
|
||||
fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots
|
||||
and original smart-collection rules. Smart collections become regular albums with current
|
||||
membership. Archived history and snapshots are source data, not native LightCraft history yet.
|
||||
Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings;
|
||||
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
|
||||
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
|
||||
|
||||
Native desktop inspection/import runs on a cancellable background worker with progress. Only
|
||||
prepared catalog operations commit on the owner thread; archive index writing stays on a worker.
|
||||
Late results from a closed or switched library are rejected, including reopening the same path.
|
||||
Ordinary edits during preparation remain intact. CLI commands complete synchronously using the
|
||||
same preparation/commit pipeline; browser builds reject native catalog import.
|
||||
|
||||
Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib-compressed JSON.
|
||||
Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths,
|
||||
settings, decoded XMP, keywords, history, snapshots, collection definitions and membership.
|
||||
|
||||
The importer uses shared Rust code on macOS, Windows and Linux. If originals move between
|
||||
computers or volumes, use LightCraft's missing-photo relinking tools to update their paths.
|
||||
|
|
@ -26,8 +26,8 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
|
||||
|
|
@ -42,18 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
| Lightroom Classic extras | 32 | 10 | 39 | 9 | — | 22/23 (96%) |
|
||||
| **Total** | 390 | 34 | 86 | 37 | 194/200 (97%) | 140/150 (93%) |
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.7% of 150 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -127,7 +124,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) |
|
||||
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) |
|
||||
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
|
||||
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
|
||||
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) |
|
||||
|
|
@ -484,7 +481,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
|
||||
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | |
|
||||
| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | |
|
||||
| LR-PREF-DEVICE | Device settings | P2 | ⬜ | | |
|
||||
|
|
@ -731,6 +728,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| 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. 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`, `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) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers 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 |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@
|
|||
- `Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.
|
||||
- Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).
|
||||
|
||||
### Keyboard shortcuts
|
||||
- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and
|
||||
press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that
|
||||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
|
|
@ -22,6 +27,12 @@
|
|||
- Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the
|
||||
starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.
|
||||
|
||||
### Lightroom Classic catalogs
|
||||
- File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.
|
||||
Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;
|
||||
existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported
|
||||
fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.
|
||||
|
||||
### Presets and profiles
|
||||
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
|
||||
bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.
|
||||
|
|
@ -47,6 +58,8 @@
|
|||
(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.
|
||||
- The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks
|
||||
or unchecks a range of photos (issue #338).
|
||||
- When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):
|
||||
import only the raws and leave the JPEGs beside them (issue #344).
|
||||
|
||||
### Languages
|
||||
- The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,
|
||||
|
|
|
|||
Loading…
Reference in New Issue