diff --git a/NOTICE b/NOTICE index 3f4f4afa..a95eb76a 100644 --- a/NOTICE +++ b/NOTICE @@ -14,3 +14,7 @@ list (author, source, license) is in assets/ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. + +BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors +(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1; +see assets/fonts/OFL-BIZUDMincho.txt. diff --git a/ROADMAP.md b/ROADMAP.md index be749ff8..54c86e81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -189,3 +189,13 @@ own chart-based calibration (M11.4). Adobe matrices are never used. Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added tracker rows for camera colour, camera coverage and render fidelity. + +## Japanese interface and text watermarks + +English/Japanese interface language is persisted in UI state. Core menus have Japanese +translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled +under OFL for Japanese glyph coverage without system fonts. Text watermarks now accept +`vertical: true` in export JSON/presets and expose an orientation selector. Japanese +characters stay upright in top-to-bottom columns, with newlines starting columns to the +left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical +OpenType shaping. The same coverage renderer serves 8/16/32-bit exports. diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 4f68ec88..00c236c2 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -533,11 +533,17 @@ fn render(args: &[String]) -> Result<(), String> { .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; } let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?; - std::fs::write(&output, &e.bytes).map_err(|err| format!("{output}: {err}"))?; - for (ext, bytes) in &e.sidecars { - let sc = Path::new(&output).with_extension(ext); - std::fs::write(&sc, bytes).map_err(|err| format!("{}: {err}", sc.display()))?; - eprintln!("lightcraft-cli: wrote {}", sc.display()); + // never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg` + let sidecars: Vec<(String, &Vec)> = + e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect(); + let guard = s.original_guard(); + for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) { + guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?; + } + lightcraft_engine::export::write_file(&output, &e.bytes)?; + for (sc, bytes) in &sidecars { + lightcraft_engine::export::write_file(sc, bytes)?; + eprintln!("lightcraft-cli: wrote {sc}"); } eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height); Ok(()) @@ -593,18 +599,8 @@ fn snapshot(args: &[String]) -> Result<(), String> { eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3); } let services = lightcraft_ui_egui::Services { - write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| { - if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - } - std::fs::write(p, b).map_err(|e| format!("{p}: {e}")) - })), - write: Some(Box::new(|p: &str, b: &[u8]| { - if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - } - std::fs::write(p, b).map_err(|e| format!("{p}: {e}")) - })), + write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), + write: Some(Box::new(lightcraft_engine::export::write_file)), png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 913200d2..b1f816b4 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -86,6 +86,24 @@ fn render_subcommand() { assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } +/// Issue #93: `render IMG -o IMG` replaced its own input with the render. +#[test] +fn render_refuses_to_overwrite_its_input() { + let input = tmp("self-in.png"); + gradient_png(&input); + let before = std::fs::read(&input).unwrap(); + let dir = input.parent().unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let other_spelling = dir.join("sub/../self-in.png"); + for out in [&input, &other_spelling] { + let o = + Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1"]).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("never writes over an original"), "{}", String::from_utf8_lossy(&o.stderr)); + } + assert_eq!(std::fs::read(&input).unwrap(), before, "the input is untouched"); +} + #[test] fn render_export_options() { let input = tmp("o-in.png"); diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 981867c8..427c97cd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -209,18 +209,9 @@ fn services() -> Services { .save_file() .map(|p| p.to_string_lossy().to_string()) })), - write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| { - if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - } - std::fs::write(p, b).map_err(|e| e.to_string()) - })), - write: Some(Box::new(|p: &str, b: &[u8]| { - if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - } - std::fs::write(p, b).map_err(|e| e.to_string()) - })), + // atomic (temp file + sync + rename): a failed write never leaves a truncated file + write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), + write: Some(Box::new(lightcraft_engine::export::write_file)), png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index f6b7dc06..c522d756 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -211,7 +211,7 @@ impl NativeMenu { // the application menu let app_menu = Submenu::new("LightCraft", true); let about = MenuItem::with_id("app.about", "About LightCraft", true, None); - let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY)); + let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY)); let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); let _ = app_menu.append_items(&[ &about, @@ -230,7 +230,7 @@ impl NativeMenu { for (title, nodes) in &bar { let sub = Submenu::new(title, true); - if title == "Window" { + if title == app.ui.language.tr("Window") { let _ = sub.append_items(&[ &PredefinedMenuItem::minimize(None), &PredefinedMenuItem::maximize(Some("Zoom")), @@ -245,15 +245,15 @@ impl NativeMenu { .collect(); let nodes = tidy_separators(nodes); self.append_nodes(&sub, &nodes); - if title == "File" { + if title == app.ui.language.tr("File") { // ⌘W, the system's own item let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]); } - if title == "Window" { + if title == app.ui.language.tr("Window") { let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); sub.set_as_windows_menu_for_nsapp(); } - if title == "Help" { + if title == app.ui.language.tr("Help") { sub.set_as_help_menu_for_nsapp(); } let _ = self.menu.append(&sub); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index c439adc8..bc72d5e6 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -9,6 +9,8 @@ covers a whole directory), or if a licence file referenced in the Licence column | Path | Asset | Creator | Source | Licence | Added | Modifications | |---|---|---|---|---|---|---| | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | +| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none | +| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/fonts/BIZUDMincho-Regular.ttf b/assets/fonts/BIZUDMincho-Regular.ttf new file mode 100644 index 00000000..b5a3f4cc Binary files /dev/null and b/assets/fonts/BIZUDMincho-Regular.ttf differ diff --git a/assets/fonts/OFL-BIZUDMincho.txt b/assets/fonts/OFL-BIZUDMincho.txt new file mode 100644 index 00000000..1664f9f6 --- /dev/null +++ b/assets/fonts/OFL-BIZUDMincho.txt @@ -0,0 +1,93 @@ +Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 4b9bdcdc..5bea87e8 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -15,6 +15,7 @@ pub mod local; pub mod model; pub mod query; pub mod rules; +pub mod safe_file; pub mod stacks; pub mod store; diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs new file mode 100644 index 00000000..1e482b81 --- /dev/null +++ b/crates/catalog/src/safe_file.rs @@ -0,0 +1,317 @@ +//! Crash-safe writes of user-visible files: exports, renders, DNG conversions, smart previews and +//! XMP sidecars all go through here (the catalog's own files use [`crate::store::FsStore`]). +//! +//! - [`write_atomic`] replaces a file so that after a crash, a full disk or an unplugged drive +//! either the old or the complete new content is there — never a truncated mix. The content +//! goes to a new temp file in the same folder (unique name, `create_new`), is synced to disk and +//! renamed over the target; then the folder is synced (POSIX). Any failure removes the temp +//! file and leaves the target as it was. +//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file +//! and sync, plus a read-back check, and the file then appears under its final name **without +//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the +//! volume has no hard links). +//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix, +//! resolved paths elsewhere), for "is this target an original?" checks. + +use std::cell::Cell; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +thread_local! { + static FAIL_AFTER: Cell> = const { Cell::new(None) }; +} + +/// Fault injection for tests (in any crate): while the returned guard lives, every write made by +/// this module **on the calling thread** fails once `bytes` bytes of a file were written — as a +/// full disk or an unplugged drive would. +#[doc(hidden)] +pub fn fail_writes_after(bytes: u64) -> FaultGuard { + FAIL_AFTER.with(|c| c.set(Some(bytes))); + FaultGuard(()) +} + +/// Ends a [`fail_writes_after`] when dropped. +#[doc(hidden)] +pub struct FaultGuard(()); + +impl Drop for FaultGuard { + fn drop(&mut self) { + FAIL_AFTER.with(|c| c.set(None)); + } +} + +/// Counts what is written; fails past an injected limit. +struct Limited { + inner: W, + n: u64, + limit: Option, +} + +impl Write for Limited { + fn write(&mut self, buf: &[u8]) -> io::Result { + let buf = match self.limit { + Some(l) => { + let room = usize::try_from(l.saturating_sub(self.n)).unwrap_or(usize::MAX); + if room == 0 && !buf.is_empty() { + return Err(io::Error::other("write failed (injected fault)")); + } + buf.get(..room.min(buf.len())).unwrap_or(buf) + } + None => buf, + }; + let k = self.inner.write(buf)?; + self.n += k as u64; + Ok(k) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn parent_of(path: &Path) -> &Path { + path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")) +} + +/// Make a rename or a new name in `dir` durable (POSIX). Opening a directory fails on Windows, +/// where NTFS journals the rename itself, so errors are ignored. +pub fn sync_dir(dir: &Path) { + if let Ok(d) = File::open(dir) { + let _ = d.sync_all(); + } +} + +/// A new, empty temp file next to `path` (hidden, unique in this process and folder). +fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> { + static N: AtomicU64 = AtomicU64::new(0); + if path.file_name().is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is not a file path", path.display()))); + } + let dir = parent_of(path); + for _ in 0..1000 { + let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + match OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(f) => return Ok((tmp, f)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("no free temp file name in {}", dir.display()))) +} + +/// Write a temp file next to `path` with what `fill` writes, synced to disk. On failure the temp +/// file is removed. Returns its path and length. +fn write_temp(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result<(PathBuf, u64)> { + let (tmp, file) = create_temp(path)?; + let r = (|| { + let limit = FAIL_AFTER.with(Cell::get); + let mut w = Limited { inner: io::BufWriter::with_capacity(1 << 20, file), n: 0, limit }; + fill(&mut w)?; + w.flush()?; + let n = w.n; + let f = w.inner.into_inner().map_err(|e| e.into_error())?; + f.sync_all()?; + Ok(n) + })(); + match r { + Ok(n) => Ok((tmp, n)), + Err(e) => { + let _ = fs::remove_file(&tmp); + Err(e) + } + } +} + +/// Replace (or create) `path` with `data` atomically and durably; see the module docs. The +/// folder must exist. +pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { + write_atomic_with(path, &mut |w| w.write_all(data)).map(|_| ()) +} + +/// [`write_atomic`] with streamed content: `fill` writes it (an error aborts and leaves the old +/// file). Returns the length written. +pub fn write_atomic_with(path: &Path, fill: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>) -> io::Result { + let (tmp, n) = write_temp(path, fill)?; + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + sync_dir(parent_of(path)); + Ok(n) +} + +/// Write `data` as the new file `path`: never replaces an existing file (`AlreadyExists`), and +/// the file only appears once it is complete, synced and read back identical. +pub fn write_new(path: &Path, data: &[u8]) -> io::Result<()> { + write_new_unique(&mut std::iter::once(path.to_path_buf()), data).map(|_| ()) +} + +/// [`write_new`] under the first free name of `candidates` (all in the same folder, e.g. +/// `IMG_1.dng`, `IMG_1-2.dng`, …): the file is written once and then given the first name that +/// is not taken. Returns that name; `AlreadyExists` when every candidate is taken. +pub fn write_new_unique(candidates: &mut dyn Iterator, data: &[u8]) -> io::Result { + let mut candidates = candidates.peekable(); + let Some(first) = candidates.peek().cloned() else { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "no file name to write")); + }; + let (tmp, _) = write_temp(&first, &mut |w| w.write_all(data))?; + let r = (|| { + if !same_content(&tmp, data)? { + return Err(io::Error::other("the written file reads back different (failing drive or connection?)")); + } + for path in candidates.by_ref().take(100_000) { + match publish_new(&tmp, &path) { + Ok(()) => { + sync_dir(parent_of(&path)); + return Ok(path); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: no free file name", first.display()))) + })(); + // after a hard link the temp name is a second name of the new file; after a rename it's gone + let _ = fs::remove_file(&tmp); + r +} + +/// Give the complete temp file `tmp` the name `path`, unless that is taken. +fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> { + match fs::hard_link(tmp, path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e), + // no hard links on this volume (FAT/exFAT, some shares): check, then rename + Err(_) => { + if fs::symlink_metadata(path).is_ok() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display()))); + } + fs::rename(tmp, path) + } + } +} + +/// Does the file at `path` hold exactly `data`? +fn same_content(path: &Path, data: &[u8]) -> io::Result { + use std::io::Read; + let mut f = File::open(path)?; + if f.metadata()?.len() != data.len() as u64 { + return Ok(false); + } + let mut buf = vec![0u8; 1 << 20]; + let mut at = 0usize; + loop { + let k = match f.read(&mut buf) { + Ok(0) => return Ok(at == data.len()), + Ok(k) => k, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + if data.get(at..at + k) != buf.get(..k) { + return Ok(false); + } + at += k; + } +} + +/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)? +pub fn same_file(a: &Path, b: &Path) -> bool { + let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + ma.dev() == mb.dev() && ma.ino() == mb.ino() + } + #[cfg(not(unix))] + { + if ma.len() != mb.len() { + return false; + } + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-safe-file-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + fn names(dir: &Path) -> Vec { + let mut v: Vec = fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + } + + #[test] + fn atomic_write_replaces_and_leaves_no_temp() { + let d = temp("replace"); + let p = d.join("out.jpg"); + write_atomic(&p, b"one").unwrap(); + write_atomic(&p, b"two two").unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"two two"); + assert_eq!(names(&d), vec!["out.jpg"]); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn a_failure_mid_write_keeps_the_old_file() { + let d = temp("midwrite"); + let p = d.join("photo.jpg"); + fs::write(&p, b"the original bytes").unwrap(); + { + let _f = fail_writes_after(4); + let e = write_atomic(&p, &[7u8; 10_000]).unwrap_err(); + assert!(e.to_string().contains("injected"), "{e}"); + assert!(write_new(&d.join("new.dng"), &[1u8; 100]).is_err()); + } + assert_eq!(fs::read(&p).unwrap(), b"the original bytes", "the target is untouched"); + assert_eq!(names(&d), vec!["photo.jpg"], "no temp file and no partial new file"); + // a fill error aborts the same way + let r = write_atomic_with(&p, &mut |w| { + w.write_all(b"half")?; + Err(io::Error::other("encoder failed")) + }); + assert!(r.is_err()); + assert_eq!(fs::read(&p).unwrap(), b"the original bytes"); + assert_eq!(names(&d), vec!["photo.jpg"]); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn new_files_never_replace_anything() { + let d = temp("new"); + let p = d.join("a.dng"); + fs::write(&p, b"keep").unwrap(); + assert_eq!(write_new(&p, b"other").unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), b"keep"); + let mut c = (1..).map(|k| if k == 1 { d.join("a.dng") } else { d.join(format!("a-{k}.dng")) }); + let got = write_new_unique(&mut c, b"fresh").unwrap(); + assert_eq!(got, d.join("a-2.dng")); + assert_eq!(fs::read(&got).unwrap(), b"fresh"); + assert_eq!(names(&d), vec!["a-2.dng", "a.dng"], "the temp file is gone"); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn same_file_sees_through_other_spellings() { + let d = temp("same"); + let p = d.join("x.jpg"); + fs::write(&p, b"x").unwrap(); + fs::write(d.join("y.jpg"), b"x").unwrap(); + fs::create_dir_all(d.join("sub")).unwrap(); + assert!(same_file(&p, &d.join("sub/../x.jpg"))); + assert!(!same_file(&p, &d.join("y.jpg"))); + assert!(!same_file(&p, &d.join("missing.jpg"))); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index ddb2fdac..900c5257 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -10,22 +10,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_active, has_selection}; use crate::{Result, Session}; -/// A free `.dng` (then `-2.dng`…) next to `path`. -fn dng_path(path: &str) -> String { +/// `.dng`, then `-2.dng`, `-3.dng`… next to `path`. +fn dng_names(path: &str) -> impl Iterator { let p = Path::new(path); - let dir = p.parent().unwrap_or(Path::new("")); + let dir = p.parent().unwrap_or(Path::new("")).to_path_buf(); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()); - let mut out = dir.join(format!("{stem}.dng")); - let mut n = 2; - while out.exists() { - out = dir.join(format!("{stem}-{n}.dng")); - n += 1; - } - out.to_string_lossy().to_string() + (1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) }) } -/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path. -/// An empty `packet` writes no XMP. +/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name; +/// returns the new path. An empty `packet` writes no XMP. +/// +/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as +/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is +/// written to a temp file, synced, read back identical and only then given its name — never +/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use. pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result { let bytes = match &s.media.file_bytes { Some(r) => r(path)?, @@ -35,9 +34,21 @@ pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::res drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; - let out = dng_path(path); - std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?; - Ok(out) + let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?; + let same = match (&back.data, &raw.data) { + (lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b, + (lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()) + } + _ => false, + }; + if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same { + return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept")); + } + drop((raw, back)); + let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng) + .map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?; + Ok(out.to_string_lossy().to_string()) } fn convert(s: &mut Session, p: &Value) -> Result { @@ -133,7 +144,7 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { }; let mut written = Vec::new(); let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> { - std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?; + crate::export::write_file(path, bytes)?; written.push(path.to_string()); Ok(()) }; diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 75173c84..11a11f6c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -96,5 +96,18 @@ pub fn specs() -> Vec { save ), cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete), + cmd!( + query "export.checkTarget", + "Check Output Path", + [], + None, + "{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}", + always, + |s, p| { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?; + s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?; + Ok(json!({"path": path})) + } + ), ] } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index 102ff0d3..a8869153 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -30,7 +30,8 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result { if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) { let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()) .map_err(|e| bad(id, e.to_string()))?; - std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?; + s.check_write_target(path).map_err(|e| bad(id, e))?; + crate::export::write_file(path, &png).map_err(|e| bad(id, e))?; info["previewPath"] = json!(path); } if let Some(img) = &out.preview { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 8da19ce3..44b2cebd 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -102,7 +102,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?; let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false); let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; - let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new()); + let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new()); if !discard { // a chosen folder on another drive must be there: never recreate it on this one if s.smart_previews_dir.is_some() { @@ -123,20 +123,27 @@ fn smart(s: &mut Session, p: &Value) -> Result { } continue; } - if path.exists() { + // a complete proxy is kept; a missing or damaged one (cut short by a crash or a full + // drive) is (re)built + let damaged = path.exists(); + if damaged && crate::smart::is_valid(&path) { built += 1; continue; } + // atomic: a failed write leaves no partial proxy that would pass for a built one let r = s .source_now(id, crate::media::SourceLevel::Preview) .and_then(|src| crate::smart::encode(&src)) - .and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string())); + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))); match r { - Ok(()) => built += 1, + Ok(()) => { + built += 1; + repaired += usize::from(damaged); + } Err(e) => failed.push(json!([id.0, e])), } } - Ok(json!({"built": built, "removed": removed, "failed": failed})) + Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed})) } /// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be @@ -205,7 +212,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result { /// Whether photo `id` has a smart preview. pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool { match (&s.media.smart_dir, s.catalog.photo(id)) { - (Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(), + (Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))), _ => false, } } @@ -218,7 +225,7 @@ pub fn specs() -> Vec { "Build Smart Previews", [], None, - "{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline → {built, removed, failed}", + "{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}", always, smart ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 70f89a7a..6c30b1a0 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -202,6 +202,8 @@ pub enum Anchor { #[serde(default, rename_all = "camelCase")] pub struct Watermark { pub text: String, + /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. + pub vertical: bool, /// Text height as a fraction of the image's short edge. pub size: f32, /// 0..1. @@ -226,6 +228,7 @@ impl Default for Watermark { fn default() -> Self { Self { text: String::new(), + vertical: false, size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, @@ -241,6 +244,9 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); +/// BIZ UDMincho (OFL), Japanese fallback on both native and web. +/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback. +pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf"); /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -288,23 +294,67 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: return; } let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; + let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return }; + let fonts = [font, japanese]; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); - let sf = font.as_scaled(PxScale::from(px)); - // Lay out one line. let mut glyphs = Vec::new(); let mut x = 0.0f32; + let mut y = 0.0f32; + let mut tw = 0.0f32; let mut prev = None; - for ch in text.chars() { - let id = sf.glyph_id(ch); - if let Some(p) = prev { - x += sf.kern(p, id); + let columns = text.split('\n').count(); + let mut column = 0usize; + for original in text.chars() { + if original == '\n' { + if wm.vertical { + column = column.saturating_add(1); + tw = tw.max(y); + y = 0.0; + } else { + tw = tw.max(x); + x = 0.0; + y += px; + } + prev = None; + continue; + } + let vertical_form = match original { + '、' => '︑', + '。' => '︒', + '(' => '︵', + ')' => '︶', + '「' => '﹁', + '」' => '﹂', + '『' => '﹃', + '』' => '﹄', + '【' => '︻', + '】' => '︼', + '…' => '︙', + other => other, + }; + let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original }; + let face = usize::from(fonts[0].glyph_id(ch).0 == 0); + let sf = fonts[face].as_scaled(PxScale::from(px)); + let id = sf.glyph_id(ch); + if wm.vertical { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); + let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); + glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); + y += px; + } else { + if let Some((previous_face, previous)) = prev + && previous_face == face + { + x += sf.kern(previous, id); + } + glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); + x += sf.h_advance(id); + prev = Some((face, id)); } - glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent()))); - x += sf.h_advance(id); - prev = Some(id); } - let (tw, th) = (x, sf.ascent() - sf.descent()); + let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; let inset = wm.inset.clamp(0.0, 0.4) * short; let (w, h) = (width as f32, height as f32); use Anchor::*; @@ -328,10 +378,17 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for g in &glyphs { - if let Some(o) = font.outline_glyph(g.clone()) { + for (face, g, rotation) in &glyphs { + if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { let b = o.px_bounds(); - o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha)); + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); + }); } } } @@ -930,6 +987,8 @@ pub struct PreparedExport { pub photo: lightcraft_catalog::PhotoId, pub file_name: String, work: Work, + /// The library's originals, which [`run_batch`] never writes over (shared by a batch). + guard: std::sync::Arc, } struct RenderWork { @@ -952,12 +1011,30 @@ enum Work { }, } -/// Set up the export of photo `id` at 1-based position `seq` of a batch. +/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use +/// [`prepare_batch`]: it looks at the library's originals once.) pub fn prepare_export( session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize, +) -> Result { + let guard = std::sync::Arc::new(session.original_guard()); + prepare_guarded(session, id, o, seq, guard) +} + +/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1). +pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result, String> { + let guard = std::sync::Arc::new(session.original_guard()); + ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect() +} + +fn prepare_guarded( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, + guard: std::sync::Arc, ) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); @@ -979,7 +1056,7 @@ pub fn prepare_export( size: (p.width as usize, p.height as usize), } }; - Ok(PreparedExport { photo: id, file_name, work }) + Ok(PreparedExport { photo: id, file_name, work, guard }) } impl PreparedExport { @@ -1031,14 +1108,16 @@ pub enum DngCompression { } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied), or one exact file path (single photo; overwritten). +/// applied to each file together with its sidecars), or one exact file path (single photo; an +/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar) +/// is never written over: see [`crate::originals`]. #[derive(Clone, Debug, Default)] pub struct Destination { pub dir: String, pub exact: Option, } -/// Export `ids` in order ([`prepare_export`] + [`run_batch`]), stopping at the first error. +/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error. pub fn export_batch( session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], @@ -1047,15 +1126,34 @@ pub fn export_batch( write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, exists: &dyn Fn(&str) -> bool, ) -> Result, String> { - let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::, _>>()?; + let items = prepare_batch(session, ids, o)?; run_batch(items, o, to, write, exists, true, &mut |_, _| true) } +/// Write an exported or rendered file on disk: its folder is created if needed, and the file is +/// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic`]: a temp file, synced, then +/// renamed), so a failure part-way leaves any previous file intact and no truncated one. The +/// native writer behind exports, renders and screenshots (app, CLI, MCP). +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + let p = std::path::Path::new(path); + if let Some(dir) = p.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + lightcraft_catalog::safe_file::write_atomic(p, bytes).map_err(|e| format!("{path}: {e}")) +} + +/// The path of the sidecar with extension `ext` of the exported file `main`. +fn sidecar_path(main: &str, ext: &str) -> String { + std::path::Path::new(main).with_extension(ext).to_string_lossy().to_string() +} + /// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to -/// `write`. `exists` tells whether a path is taken (conflict policy). `progress(done, next file)` -/// is called before each photo; returning false cancels the rest. Returns one JSON object per -/// photo: `{path, width, height, bytes, sidecars}`, `{skipped: path}` or (unless -/// `stop_on_error`) `{photo, file, error}`. +/// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its +/// sidecars as one: with Unique both get the same free name, with Skip the photo is skipped when +/// either is taken. A path that is a catalogued original (or its sidecar) is refused whatever the +/// policy. `progress(done, next file)` is called before each photo; returning false cancels the +/// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, +/// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. pub fn run_batch( items: Vec, o: &ExportOptions, @@ -1075,7 +1173,7 @@ pub fn run_batch( if !progress(i, &item.file_name) { break; } - let (photo, name) = (item.photo, item.file_name.clone()); + let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone()); let e = match item.run() { Ok(e) => e, Err(err) if stop_on_error => return Err(err), @@ -1084,14 +1182,18 @@ pub fn run_batch( continue; } }; + // the exported file and its sidecars + let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); let path = match to.exact.as_deref().filter(|_| single) { - Some(p) => p.to_string(), + Some(p) => Ok(p.to_string()), None => { - let mut path = join(&dir, &e.file_name); - let busy = |p: &str, taken: &std::collections::HashSet| taken.contains(p) || exists(p); - if busy(&path, &taken) { + let path = join(&dir, &e.file_name); + let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); + if !busy(&path) { + Ok(path) + } else { match o.conflict { - Conflict::Overwrite => {} + Conflict::Overwrite => Ok(path), Conflict::Skip => { out.push(json!({"skipped": path})); continue; @@ -1099,29 +1201,34 @@ pub fn run_batch( Conflict::Unique => { let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); - path = (2..).map(name).find(|p| !busy(p, &taken)).expect("a free name"); + (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) } } } - path } }; - let written = write(&path, &e.bytes).and_then(|()| { - let mut sidecars = Vec::new(); - for (ext, bytes) in &e.sidecars { - let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string(); - write(&sc, bytes)?; - sidecars.push(sc); + let file = path.clone().unwrap_or_else(|_| name.clone()); + let written = path.and_then(|path| { + let files = group(&path); + // never over an original, whatever the conflict policy or the exact path said + for f in &files { + guard.check(std::path::Path::new(f))?; } - Ok(sidecars) + write(&path, &e.bytes)?; + let mut sidecars = Vec::new(); + for ((_, bytes), sc) in e.sidecars.iter().zip(files.iter().skip(1)) { + write(sc, bytes)?; + sidecars.push(sc.clone()); + } + Ok((path, files, sidecars)) }); match written { - Ok(sidecars) => { - taken.insert(path.clone()); + Ok((path, files, sidecars)) => { + taken.extend(files); out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); } Err(err) if stop_on_error => return Err(err), - Err(err) => out.push(json!({"photo": photo.0, "file": path, "error": err})), + Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})), } } Ok(out) @@ -1227,6 +1334,44 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + #[test] + fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() { + use ab_glyph::Font; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap(); + for c in "日本語".chars() { + assert_ne!(font.glyph_id(c).0, 0); + } + let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); + assert!(!old.watermark.unwrap().vertical); + let options = + ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); + let wm = options.watermark.unwrap(); + assert!(wm.vertical); + let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); + assert!(round.vertical); + let mut img = Rgba8::new(400, 300); + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); + assert!(lit.len() > 100); + let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); + let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); + assert!(h > w * 2, "vertical Japanese must extend down the column"); + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm }; + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &two); + for (text, dx) in [("本", 0), ("日", 30)] { + let mut single = Rgba8::new(400, 300); + single.data.fill([0, 0, 0, 255]); + draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); + for y in 0..30 { + for x in 0..30 { + assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); + } + } + } + } + #[test] fn graphic_watermark_is_placed_scaled_and_blended() { let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 1109859e..8eb4c010 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -374,25 +374,14 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress } /// Copy `src` into `root`/`folders` as `name` (default: its own name), made unique with -1, -2…; -/// returns the new path. +/// returns the new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, +/// synced and compared byte for byte); a bad one is removed and the photo reported as failed. fn copy_into(root: &Path, src: &str, folders: &[String], name: Option<&str>) -> Result { let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f)); - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let name = name .map(str::to_string) .unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into())); - let (stem, ext) = match name.rsplit_once('.') { - Some((s, e)) => (s.to_string(), format!(".{e}")), - None => (name.clone(), String::new()), - }; - let mut dst = dir.join(&name); - let mut i = 1; - while dst.exists() { - dst = dir.join(format!("{stem}-{i}{ext}")); - i += 1; - } - std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?; - Ok(dst.to_string_lossy().to_string()) + crate::import_move::copy_new(Path::new(src), &dir, &name).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 diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index 3c949e79..a32865b2 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -169,6 +169,27 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result Result { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (name.to_string(), String::new()), + }; + for i in 0..100_000u32 { + let d = if i == 0 { dir.join(name) } else { dir.join(format!("{stem}-{i}{ext}")) }; + match copy_verified(src, &d) { + Ok(()) => return Ok(d), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("copy {}: {e} (nothing was imported from it; the original is untouched)", src.display())), + } + } + Err(format!("copy {}: no free name in {}", src.display(), dir.display())) +} + /// Undo a [`place`]: remove what it created at the destination (the sources were never touched). pub(crate) fn rollback(p: &Placed) { for sc in p.sidecars.iter().filter(|s| s.created) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f128bc9..b86d5880 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod library; pub mod media; pub mod memory; pub mod merge; +pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 4841533b..18e67faf 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -290,7 +290,7 @@ impl MediaCache { && !std::path::Path::new(path).exists() { let sp = dir.join(crate::smart::file_name(p)); - if sp.exists() { + if crate::smart::is_valid(&sp) { return SourceRef::Smart { path: sp }; } } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 24c65489..53c7f546 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -304,15 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) { s.color.saturation = a.saturation; } -/// A free file name `dir/-.dng` (`-2`, `-3`… when taken). -fn output_path(first: &str, suffix: &str) -> String { +/// `dir/-.dng`, then `-2`, `-3`… (the candidates for a merge's output). +fn output_names(first: &str, suffix: &str) -> impl Iterator { let p = std::path::Path::new(first); let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into()); - let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }); - // (`u64::MAX` taken names can't exist; the last candidate is the fallback) - let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX)); - free.to_string_lossy().to_string() + let suffix = suffix.to_string(); + (1u64..).map(move |k| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") })) } /// Parse `merge.*` command parameters into a kind and finishing options. @@ -367,8 +365,11 @@ impl Session { /// apply Auto Settings / the auto crop. Returns `{id, path, …info}`. pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result { let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1; - let path = output_path(first, job.kind.suffix()); - std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // a new file under a free name (never replacing one), complete and synced before it appears + let path = lightcraft_catalog::safe_file::write_new_unique(&mut output_names(first, job.kind.suffix()), &out.dng) + .map_err(|e| EngineError::Other(format!("could not write the merged DNG next to {first}: {e}")))? + .to_string_lossy() + .to_string(); let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?; let id = report .imported diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs new file mode 100644 index 00000000..59a5f6a2 --- /dev/null +++ b/crates/engine/src/originals.rs @@ -0,0 +1,102 @@ +//! Never write over an original: exports, renders and other files LightCraft writes for the user +//! must not land on a catalogued photo's own file (or its XMP sidecar), whatever the conflict +//! setting or exact path asked for. [`OriginalGuard`] is a snapshot of the library's originals +//! that answers "would writing here replace one?" by file identity (the same file under another +//! spelling, through `..`, a link or a case-insensitive volume counts), not by comparing strings. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::safe_file::same_file; +use lightcraft_catalog::{Catalog, Source}; + +/// The library's originals and their sidecars, looked up by file name. +#[derive(Debug, Default)] +pub struct OriginalGuard { + /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) + by_name: HashMap>, +} + +impl OriginalGuard { + /// The originals of every photo in `catalog`. + pub fn new(catalog: &Catalog) -> Self { + let mut g = Self::default(); + for p in catalog.photos() { + if let Source::File { path } = &p.source { + g.add(Path::new(path), &p.file_name); + } + } + g + } + + /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). + pub fn add(&mut self, path: &Path, label: &str) { + let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; + let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); + put(name.clone(), path.to_path_buf(), format!("the original of {label}")); + let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + put(format!("{stem}.xmp"), path.with_extension("xmp"), format!("the XMP sidecar of {label}")); + put(format!("{name}.xmp"), PathBuf::from(format!("{}.xmp", path.to_string_lossy())), format!("the XMP sidecar of {label}")); + } + + /// `Err` (a message for the user) when writing `target` would replace a protected file. + pub fn check(&self, target: &Path) -> Result<(), String> { + if std::fs::symlink_metadata(target).is_err() { + return Ok(()); // nothing there to replace + } + let mut names: Vec = target.file_name().map(|n| n.to_string_lossy().to_lowercase()).into_iter().collect(); + if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) { + names.push(n); + } + for n in &names { + for (p, what) in self.by_name.get(n).into_iter().flatten() { + if same_file(p, target) { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + } + } + Ok(()) + } +} + +impl crate::Session { + /// A guard over this library's originals (see [`OriginalGuard`]). + pub fn original_guard(&self) -> OriginalGuard { + OriginalGuard::new(&self.catalog) + } + + /// `Err` when writing `path` would replace a catalogued photo's original or sidecar. + pub fn check_write_target(&self, path: &str) -> Result<(), String> { + self.original_guard().check(Path::new(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::{Op, Photo, PhotoId}; + + #[test] + fn originals_and_their_sidecars_are_protected_by_identity() { + let d = std::env::temp_dir().join(format!("lc-originals-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("sub")).unwrap(); + let orig = d.join("IMG_1.jpg"); + std::fs::write(&orig, b"original").unwrap(); + std::fs::write(d.join("IMG_1.xmp"), b"").unwrap(); + std::fs::write(d.join("other.jpg"), b"other").unwrap(); + let mut c = Catalog::default(); + let p = Photo::new(PhotoId(1), Source::File { path: orig.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let g = OriginalGuard::new(&c); + assert!(g.check(&orig).unwrap_err().contains("original of IMG_1.jpg")); + assert!(g.check(&d.join("sub/../IMG_1.jpg")).is_err(), "another spelling of the same file"); + assert!(g.check(&d.join("IMG_1.xmp")).unwrap_err().contains("sidecar")); + assert!(g.check(&d.join("other.jpg")).is_ok(), "an ordinary existing file"); + assert!(g.check(&d.join("new.jpg")).is_ok(), "a free name"); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index a49e5fee..fb25871c 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -258,13 +258,9 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { lightcraft_meta::write_xmp_lc(&meta, &lc) } -/// Write `data` to `path` atomically (temp file + rename). +/// Write `data` to `path` atomically (temp file + sync + rename). fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> { - let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id())); - std::fs::write(&tmp, data)?; - std::fs::rename(&tmp, path).inspect_err(|_| { - let _ = std::fs::remove_file(&tmp); - }) + lightcraft_catalog::safe_file::write_atomic(path, data) } fn file_path(p: &Photo) -> Option<&str> { diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 79560c96..038f3330 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -130,11 +130,16 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { Existing::Discard => std::fs::remove_file(&src), _ => { let dst = to.join(e.file_name()); - if dst.exists() { - // named by content: the copy already there is the same preview + if is_valid(&dst) { + // named by content: the complete copy already there is the same preview std::fs::remove_file(&src) } else { - std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src))) + // across drives: copied atomically (never a partial proxy at the new place) + std::fs::rename(&src, &dst).or_else(|_| { + std::fs::read(&src) + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) + .and_then(|()| std::fs::remove_file(&src)) + }) } } }; @@ -146,6 +151,29 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { (done, failed) } +/// A cheap check that the smart preview at `path` is complete: our header, then a JPEG from its +/// start marker to its end marker. A file cut short by a crash, a full drive or an unplugged one +/// fails it (and is then rebuilt by Build Smart Previews, not counted as there). +pub fn is_valid(path: &Path) -> bool { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { return false }; + let mut head = Vec::with_capacity(256); + if (&mut f).take(256).read_to_end(&mut head).is_err() { + return false; + } + let Some(rest) = head.strip_prefix(MAGIC) else { return false }; + let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; + let size_ok = rest + .get(..nl) + .and_then(|h| serde_json::from_slice::(h).ok()) + .is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); + if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) { + return false; + } + let mut end = [0u8; 2]; + f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] +} + /// Load the proxy at `path`. pub fn load(path: &Path) -> Result, String> { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -193,6 +221,27 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { + let base = temp("valid"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let good = encode(&img).unwrap(); + std::fs::write(a.join("1.lcsp"), &good).unwrap(); + assert!(is_valid(&a.join("1.lcsp"))); + for cut in [good.len() - 1, good.len() / 2, 10, 0] { + std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); + assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); + } + assert!(!is_valid(&b.join("missing.lcsp"))); + // moving onto a damaged copy replaces it instead of deleting the good one + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 13c28213..dcd74cb4 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -207,3 +207,119 @@ fn export_falls_back_to_the_cpu_when_gpu_work_is_lost() { assert!(m > 0.02, "not black: mean {m}"); assert!((m - healthy).abs() < 0.01, "GPU {healthy} vs CPU {m}"); } + +/// A JPEG original `dir/IMG_1.jpg` imported into a library on disk. +fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::path::PathBuf, std::path::PathBuf, Vec) { + let dir = temp_dir(tag); + let src = dir.join("IMG_1.jpg"); + let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, 120, 255]); + let bytes = crate::export::encode_image(&img, &ExportOptions::default()).unwrap(); + std::fs::write(&src, &bytes).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap(); + (s, id, dir, src, bytes) +} + +fn disk_batch( + s: &mut Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + to: &crate::export::Destination, +) -> Result, String> { + crate::export::export_batch(s, &[id], o, to, &mut crate::export::write_file, &|p| std::path::Path::new(p).exists()) +} + +/// Issue #93: exporting into the photo's own folder as `{name}` with "Overwrite" replaced the +/// original with the re-encoded render. It is refused now, whatever the policy or exact path. +#[test] +fn export_never_overwrites_an_original() { + use crate::export::{Conflict, Destination}; + let (mut s, id, dir, src, original) = library_with_jpeg("export-guard"); + let folder = Destination { dir: dir.to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { naming: "{name}".into(), conflict: Conflict::Overwrite, ..Default::default() }; + let err = disk_batch(&mut s, id, &o, &folder).unwrap_err(); + assert!(err.contains("original of IMG_1.jpg"), "{err}"); + assert_eq!(std::fs::read(&src).unwrap(), original, "the original is byte-identical"); + + // the exact path of the control channel / MCP, also spelled another way + std::fs::create_dir_all(dir.join("sub")).unwrap(); + for exact in [src.to_string_lossy().to_string(), dir.join("sub/../IMG_1.jpg").to_string_lossy().to_string()] { + let to = Destination { dir: String::new(), exact: Some(exact) }; + assert!(disk_batch(&mut s, id, &o, &to).unwrap_err().contains("never writes over an original")); + } + // the "Original" format re-writing the file onto itself (and its sidecar) + let orig = ExportOptions { format: ExportFormat::Original, ..o.clone() }; + assert!(disk_batch(&mut s, id, &orig, &folder).is_err()); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err()); + assert!(s.execute("export.checkTarget", &json!({"path": dir.join("free.jpg").to_string_lossy()})).is_ok()); + + // Unique moves past it; Overwrite still replaces an ordinary earlier export + let unique = ExportOptions { conflict: Conflict::Unique, ..o.clone() }; + let files = disk_batch(&mut s, id, &unique, &folder).unwrap(); + assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}"); + let earlier = dir.join("IMG_1-2.jpg"); + std::fs::write(&earlier, b"an earlier export").unwrap(); + let named = ExportOptions { naming: "{name}-2".into(), ..o }; + disk_batch(&mut s, id, &named, &folder).unwrap(); + assert_ne!(std::fs::read(&earlier).unwrap(), b"an earlier export", "an ordinary file is overwritten"); + assert_eq!(std::fs::read(&src).unwrap(), original); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #93: the XMP sidecar of an "Original" export ignored the conflict policy. +#[test] +fn export_sidecars_follow_the_conflict_policy() { + use crate::export::{Conflict, Destination}; + let (mut s, id, dir, _src, original) = library_with_jpeg("export-sidecar"); + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let theirs = out.join("IMG_1.xmp"); + std::fs::write(&theirs, b"someone else's sidecar").unwrap(); + let to = Destination { dir: out.to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { format: ExportFormat::Original, naming: "{name}".into(), ..Default::default() }; + + // Unique: the photo and its sidecar both move to the next free name + let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Unique, ..o.clone() }, &to).unwrap(); + assert!(files[0]["path"].as_str().unwrap().ends_with("IMG_1-2.jpg"), "{files:?}"); + assert!(files[0]["sidecars"][0].as_str().unwrap().ends_with("IMG_1-2.xmp"), "{files:?}"); + assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar"); + assert_eq!(std::fs::read(out.join("IMG_1-2.jpg")).unwrap(), original); + // Skip: a taken sidecar name skips the photo + std::fs::remove_file(out.join("IMG_1-2.jpg")).unwrap(); + std::fs::remove_file(out.join("IMG_1-2.xmp")).unwrap(); + let files = disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Skip, ..o.clone() }, &to).unwrap(); + assert!(files[0]["skipped"].as_str().is_some(), "{files:?}"); + assert!(!out.join("IMG_1.jpg").exists()); + assert_eq!(std::fs::read(&theirs).unwrap(), b"someone else's sidecar"); + // Overwrite: replaced, as asked + disk_batch(&mut s, id, &ExportOptions { conflict: Conflict::Overwrite, ..o }, &to).unwrap(); + assert!(std::fs::read_to_string(&theirs).unwrap().contains("xmpmeta")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #93: the native writer truncated the target first; a failure mid-write (full disk, +/// unplugged drive) now leaves the previous file intact and no partial one. +#[test] +fn a_failed_export_write_keeps_the_previous_file() { + use crate::export::{Conflict, Destination}; + let (mut s, id, dir, _src, _) = library_with_jpeg("export-midwrite"); + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let prev = out.join("IMG_1.jpg"); + std::fs::write(&prev, b"last week's export").unwrap(); + let to = Destination { dir: out.to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() }; + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(100); + let err = disk_batch(&mut s, id, &o, &to).unwrap_err(); + assert!(err.contains("injected"), "{err}"); + } + assert_eq!(std::fs::read(&prev).unwrap(), b"last week's export"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "no temp file left behind"); + disk_batch(&mut s, id, &o, &to).unwrap(); + assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index cd156395..a719ca5f 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -391,6 +391,44 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed +/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not +/// reported as present and is rebuilt. +#[test] +fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { + let src = temp_dir("smartdmg-src"); + let lib = temp_dir("smartdmg-lib"); + write_png(&src.join("a.png"), 5); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(64); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}"); + } + assert!(!file.exists(), "no partial proxy"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no temp file left"); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false); + + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0)), "{r}"); + assert!(crate::smart::is_valid(&file)); + // cut short, as by a crash before the fix + let full = std::fs::read(&file).unwrap(); + std::fs::write(&file, &full[..full.len() / 2]).unwrap(); + assert!(!crate::smart::is_valid(&file)); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false, "not counted as there"); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + /// LUT profiles: a .cube imports as a creative profile (into the library), renders differently /// (Amount 0 = no change), is listed in the profile browser and comes back with the library. #[test] diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 59591065..5eb278fe 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -163,6 +163,50 @@ fn a_failed_or_corrupt_copy_keeps_the_source() { } } +/// Issue #96: Import → Copy used a plain unverified copy (and check-then-copy naming that could +/// replace a file). It now gets the same verified copy as Move: a copy that fails or comes out +/// different is removed and reported as a failed import, nothing is catalogued, and the card +/// is untouched; a good copy never replaces an existing file. +#[test] +fn copy_import_verifies_each_copy() { + let import_copy = |s: &mut Session, card: &Path, dest: &Path| { + s.execute( + "library.import", + &json!({"paths": [card.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat"}), + ) + .unwrap() + }; + for fault in [Fault::FailWrite, Fault::Corrupt] { + let base = temp_dir(&format!("copyfail-{fault:?}")); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 3); + let bytes = std::fs::read(card.join("a.png")).unwrap(); + let mut s = session(); + inject(fault); + let r = import_copy(&mut s, &card, &dest); + inject(Fault::None); + assert_eq!(len(&r, "failed"), 1, "{fault:?}: {r}"); + assert_eq!(len(&r, "imported"), 0, "{fault:?}: {r}"); + assert!(r.to_string().contains("the original is untouched"), "{r}"); + assert_eq!(s.catalog.len(), 0); + assert_eq!(std::fs::read(card.join("a.png")).unwrap(), bytes, "{fault:?}: source intact"); + assert_eq!(files_under(&dest), Vec::::new(), "{fault:?}: no bad copy left"); + let _ = std::fs::remove_dir_all(&base); + } + // a good copy: verified, and a taken name gets -1 instead of being replaced + let base = temp_dir("copyok"); + let (card, dest) = (base.join("card"), base.join("out")); + write_png(&card.join("a.png"), 4); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(dest.join("a.png"), b"someone else's file").unwrap(); + let mut s = session(); + let r = import_copy(&mut s, &card, &dest); + assert_eq!(len(&r, "imported"), 1, "{r}"); + assert_eq!(std::fs::read(dest.join("a.png")).unwrap(), b"someone else's file"); + assert_eq!(std::fs::read(dest.join("a-1.png")).unwrap(), std::fs::read(card.join("a.png")).unwrap()); + let _ = std::fs::remove_dir_all(&base); +} + /// A source that can't be removed (a read-only card) stays and is reported; the photo is /// imported from its copy. #[test] diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 00b8e3b0..f31c3d48 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -329,6 +329,55 @@ fn convert_raw_to_dng() { let _ = std::fs::remove_dir_all(&dir); } +/// Issue #106: Convert to DNG wrote straight to the final name (no temp file, sync or check) and +/// relinked the photo even when the write was cut short. A failed write now leaves no DNG and +/// the photo on its raw; a good one is verified and never replaces an existing file. +#[test] +fn convert_to_dng_is_verified_and_atomic() { + let dir = temp_dir("todng-safe"); + // a synthetic raw (stored as DNG, catalogued as a NEF so Convert to DNG takes it) + let raw = dir.join("shot.dng"); + let bytes = synthetic_dng_with(None, Default::default()); + std::fs::write(&raw, &bytes).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let path = raw.to_string_lossy().to_string(); + s.catalog + .apply(lightcraft_catalog::Op::Relink { + id, + file_name: "shot.dng".into(), + source: lightcraft_catalog::Source::File { path: path.clone() }, + format: Some("NEF".into()), + }) + .unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let names = || { + let mut v: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + }; + { + // the drive fills up part-way + let _fault = lightcraft_catalog::safe_file::fail_writes_after(200); + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + assert_eq!(r["converted"].as_array().unwrap().len(), 0, "{r}"); + assert!(r["skipped"][0][1].as_str().unwrap().contains("the raw is kept"), "{r}"); + } + assert_eq!(names(), ["shot.dng"], "no partial DNG"); + assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: path.clone() }, "not relinked"); + // a good conversion: a new name (the existing file is not replaced), decodable, relinked + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + let out = r["converted"][0]["path"].as_str().unwrap().to_string(); + assert!(out.ends_with("shot-2.dng"), "{r}"); + assert_eq!(std::fs::read(&raw).unwrap(), bytes, "the raw is untouched"); + let back = lightcraft_raw::decode(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(back.data, lightcraft_raw::decode(&bytes).unwrap().data); + assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: out }); + assert_eq!(names(), ["shot-2.dng", "shot.dng"], "no temp file left"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to /// the original, added and stacked on top of it; a second one doesn't overwrite the first. #[test] diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index c5154ca4..2ae21b25 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -78,12 +78,7 @@ impl Headless { None => vec![self.photo_or_active(p)?], }; let dir = p.get("dir").and_then(Value::as_str).unwrap_or(""); - let write = &mut |path: &str, bytes: &[u8]| { - if let Some(parent) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; - } - std::fs::write(path, bytes).map_err(|err| format!("{path}: {err}")) - }; + let write = &mut lightcraft_engine::export::write_file; let files = export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| { Path::new(path).exists() @@ -118,6 +113,7 @@ impl Backend for Headless { let img = self.render(&p, 1600)?; match p.get("path").and_then(Value::as_str) { Some(path) => { + self.session.check_write_target(path)?; write_image(Path::new(path), &img, 92)?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } @@ -183,7 +179,7 @@ pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result, Stri pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> { let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); let bytes = encode_image(&ext, img, quality)?; - std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) + lightcraft_engine::export::write_file(&path.to_string_lossy(), &bytes) } #[cfg(test)] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 517326ed..c613bf37 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; -use crate::{Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name}; +use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name}; fn server() -> Server { Server::new(Box::new(Headless::demo())) @@ -92,6 +92,34 @@ fn import_tool_copies_with_a_folder_template() { let _ = std::fs::remove_dir_all(&base); } +/// Issue #93: `render_photo`, `ui.render` and `app.export` with an exact `path` must not replace +/// a photo's original. +#[test] +fn path_writes_never_replace_an_original() { + let base = std::env::temp_dir().join(format!("lc-mcp-guard-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 30, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let orig = base.join("a.png"); + std::fs::write(&orig, &png).unwrap(); + let mut b = Headless::demo(); + let r = call_tool(&mut b, "import", &json!({"paths": [orig.to_string_lossy()]})); + assert!(!r.is_error, "{r:?}"); + let id = b.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id.0; + let path = orig.to_string_lossy().to_string(); + let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": path})); + assert!(r.is_error, "{r:?}"); + assert!(b.call("ui.render", json!({"id": id, "path": path})).unwrap_err().contains("original")); + let e = b.call("app.export", json!({"ids": [id], "path": path})).unwrap_err(); + assert!(e.contains("never writes over an original"), "{e}"); + assert_eq!(std::fs::read(&orig).unwrap(), png, "the original is untouched"); + // another path still works + let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": base.join("render.png").to_string_lossy()})); + assert!(!r.is_error, "{r:?}"); + let _ = std::fs::remove_dir_all(&base); +} + /// The `import` helper moves: renamed into the folder template, the source removed. #[test] fn import_tool_moves_with_a_folder_template() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7e2e212b..c5419425 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -205,7 +205,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, - "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], ), @@ -352,9 +352,9 @@ fn image_result(file: &std::path::Path, max: Option, format: &str, save_to: (bytes, w, h) }; if let Some(p) = save_to - && let Err(e) = std::fs::write(p, &bytes) + && let Err(e) = lightcraft_engine::export::write_file(p, &bytes) { - return ToolResult::error(format!("{p}: {e}")); + return ToolResult::error(e); } let mime = if jpeg { "image/jpeg" } else { "image/png" }; let mut info = meta; @@ -372,7 +372,18 @@ fn image_result(file: &std::path::Path, max: Option, format: &str, save_to: } } +/// A user-given `path` to save to must not be a photo's original (or its sidecar). +fn check_save_path(b: &mut dyn Backend, args: &Value) -> Result<(), String> { + match args.get("path").and_then(Value::as_str) { + Some(p) => exec(b, "export.checkTarget", json!({"path": p})).map(|_| ()), + None => Ok(()), + } +} + fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult { + if let Err(e) = check_save_path(b, args) { + return ToolResult::error(e); + } let size = args.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(16, 4096); let id = match args.get("id").and_then(Value::as_u64) { Some(id) => Some(id), @@ -394,6 +405,9 @@ fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult { } fn screenshot(b: &mut dyn Backend, args: &Value) -> ToolResult { + if let Err(e) = check_save_path(b, args) { + return ToolResult::error(e); + } let file = temp_path("screenshot"); let path = file.to_string_lossy().to_string(); match b.call("ui.screenshot", json!({"path": path})) { diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 7dbdda1e..7ec2dcfe 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -4,6 +4,11 @@ //! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size //! exceeds the budget, the least recently used files (by modification time, refreshed on read) //! are removed down to 80 % of the budget. +//! +//! The cache only ever lists, counts or deletes files of its own name shape +//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see +//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a +//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98). use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -96,13 +101,25 @@ impl DiskCache { } } - /// All cache files: (path, size, modified). + /// All cache files (only names of the cache's own shape, see [`is_cache_file`]): + /// (path, size, modified). fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> { let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(&self.dir) else { return out }; for sub in rd.flatten() { + let sub_name = sub.file_name(); + let Some(sub_name) = sub_name.to_str() else { continue }; + if !is_shard_name(sub_name) { + continue; + } let Ok(files) = std::fs::read_dir(sub.path()) else { continue }; for f in files.flatten() { + let name = f.file_name(); + let Some(name) = name.to_str() else { continue }; + if !is_cache_file(sub_name, name) { + continue; + } + // `DirEntry::metadata` does not follow symlinks: a link is never a cache file if let Ok(m) = f.metadata() && m.is_file() { @@ -144,6 +161,29 @@ impl DiskCache { } } +fn is_lower_hex(s: &str) -> bool { + s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// A shard directory name: two lowercase hex digits. +fn is_shard_name(s: &str) -> bool { + s.len() == 2 && is_lower_hex(s) +} + +/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.jpg` +/// (whose first two digits are the shard) or a `<32 hex>.tmp` temp file of +/// [`DiskCache::put`]. Anything else in the cache directory belongs to someone else. +pub fn is_cache_file(shard: &str, name: &str) -> bool { + if !is_shard_name(shard) { + return false; + } + let Some((stem, ext)) = name.split_once('.') else { return false }; + if stem.len() != 32 || !is_lower_hex(stem) || !stem.starts_with(shard) { + return false; + } + ext == "jpg" || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric())) +} + /// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0). pub fn encode_jpeg(img: &Rgba8) -> Option> { if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize { diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 038f935a..ee5bd36f 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -154,3 +154,41 @@ fn pool_runs_inline() { assert_eq!(p.run_inline(10), 1); assert_eq!(p.try_recv().map(|d| d.result), Some(1)); } + +#[test] +fn disk_cache_never_touches_foreign_files() { + // issue #98: a library opened on a folder that already has a `thumbs/` folder + let dir = temp_dir("foreign"); + let foreign = [ + dir.join("readme.txt"), + dir.join("ab").join("holiday.jpg"), + dir.join("ab").join("0123456789abcdef0123456789abcdef.jpg"), // wrong shard + dir.join("small").join("0123456789abcdef0123456789abcdef.jpg"), + dir.join("01").join("0123456789ABCDEF0123456789ABCDEF.jpg"), // upper case + dir.join("01").join("0123456789abcdef0123456789abcdef.jpeg"), + dir.join("01").join("0123456789abcdef0123456789abcdef.tmp"), + ]; + for p in &foreign { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, vec![7u8; 50_000]).unwrap(); + } + // a stale temp file of our own is cleaned up + let own_tmp = dir.join("01").join("0123456789abcdef0123456789abcdef.tmpThreadId9"); + std::fs::write(&own_tmp, b"partial").unwrap(); + let d = DiskCache::new(&dir, 40_000); + assert_eq!(d.size(), 7, "only the cache's own temp file counts"); + // fill past the budget (forces prunes), then clear + for i in 0..40u8 { + d.put(hash_bytes(&[i, 2]), &gradient(160, 100, i)); + } + assert!(d.size() <= 40_000); + d.clear(); + assert_eq!(d.size(), 0); + assert!(!own_tmp.exists()); + for p in &foreign { + assert!(p.exists(), "foreign file deleted: {}", p.display()); + } + assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg")); + assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e71124de..26408bc4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -290,12 +290,19 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ok(Value::Null) } "ui.screenshot" => { + // never over a photo's original + if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) { + return err(e); + } ctx.request_repaint(); Outcome::Screenshot { path: s("path").map(str::to_string), headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) } } "ui.render" => { let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or(app.session.active()); let Some(id) = id else { return err("no photo") }; + if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) { + return err(e); + } let size = p.get("size").and_then(Value::as_u64).unwrap_or(1600) as usize; match app.session.render_now(id, size, size) { Ok(r) => match (s("path"), app.services.png.as_ref()) { @@ -356,11 +363,7 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result, _>>()?; + let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?; crate::export_task::start(app, items, opts, to)? } else { let w = app.services.write.as_mut().ok_or("no writer")?; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 00000000..36e004c8 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,154 @@ +//! Interface translations. Command ids, document text and file names remain stable. +//! Untranslated labels fall back to English so coverage can grow incrementally. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, +} + +impl Language { + pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + _ => None, + } + } + + pub fn tr(self, text: &str) -> &str { + if self == Self::Ja + && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) + { + return japanese; + } + text + } +} + +const JAPANESE: &[(&str, &str)] = &[ + ("Photo", "写真"), + ("Import Photos…", "写真を読み込み…"), + ("Detail", "詳細"), + ("Photo Grid", "写真グリッド"), + ("Square Grid", "正方形グリッド"), + ("Object", "オブジェクト"), + ("Effect", "効果"), + ("Settings…", "環境設定…"), + ("Image", "画像"), + ("Layer", "レイヤー"), + ("Type", "書式"), + ("Select", "選択"), + ("Filter", "フィルター"), + ("Window", "ウィンドウ"), + ("Language", "表示言語"), + ("Save As…", "別名で保存…"), + ("Exit", "終了"), + ("New…", "新規…"), + ("New", "新規"), + ("Horizontal", "横書き"), + ("Vertical", "縦書き"), + ("Orientation", "組み方向"), + ("Layers", "レイヤー"), + ("History", "履歴"), + ("Properties", "プロパティ"), + ("Color", "カラー"), + ("Brush Settings", "ブラシ設定"), + ("Tools", "ツール"), + ("Options", "オプション"), + ("Zoom In", "ズームイン"), + ("Zoom Out", "ズームアウト"), + ("Fit on Screen", "画面に合わせる"), + ("Copy", "コピー"), + ("Cut", "切り取り"), + ("Paste", "貼り付け"), + ("Select All", "すべて選択"), + ("Deselect", "選択を解除"), + ("Export", "書き出し"), + ("Export As…", "形式を指定して書き出し…"), + ("Search…", "検索…"), + ("Theme", "テーマ"), + ("Menu", "メニュー"), + ("File", "ファイル"), + ("Edit", "編集"), + ("Pages", "ページ"), + ("View", "表示"), + ("Help", "ヘルプ"), + ("Preferences", "環境設定"), + ("Preferences…", "環境設定…"), + ("Interface language", "表示言語"), + ("Open…", "開く…"), + ("New blank PDF", "空白の PDF を作成"), + ("Create PDF from file…", "ファイルから PDF を作成…"), + ("Create PDF from images…", "画像から PDF を作成…"), + ("Create PDF from clipboard", "クリップボードから PDF を作成"), + ("Combine files…", "ファイルを結合…"), + ("Save", "保存"), + ("Save as…", "別名で保存…"), + ("Close file", "ファイルを閉じる"), + ("Close all", "すべて閉じる"), + ("Revert", "保存済みの状態に戻す"), + ("Print…", "印刷…"), + ("Document properties…", "文書のプロパティ…"), + ("Undo", "取り消し"), + ("Redo", "やり直し"), + ("Find…", "検索…"), + ("Advanced search…", "高度な検索…"), + ("Copy pages", "ページをコピー"), + ("Cut pages", "ページを切り取り"), + ("Paste pages", "ページを貼り付け"), + ("Fit visible", "表示範囲に合わせる"), + ("Marquee zoom", "範囲指定ズーム"), + ("Take a snapshot", "スナップショットを作成"), + ("Full screen mode", "全画面表示"), + ("Read mode", "閲覧モード"), + ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), + ("Comments panel", "コメントパネル"), + ("Form fields panel", "フォームフィールドパネル"), + ("Clear form", "フォームをクリア"), + ("Find tools and commands…", "ツールとコマンドを検索…"), + ("Zoom", "ズーム"), + ("Actual size", "実際のサイズ"), + ("Zoom to page level", "ページ全体を表示"), + ("Fit to width", "幅に合わせる"), + ("Display theme", "表示テーマ"), + ("Side panels", "サイドパネル"), + ("OK", "OK"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_setting_persists() { + let state = crate::state::UiState { language: Language::Ja, ..Default::default() }; + let saved = serde_json::to_string(&state).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + assert_eq!(crate::state::UiState::default().language, Language::En); + } + + #[test] + fn translations_are_unique_and_preserve_unknown_text() { + for (i, (en, ja)) in JAPANESE.iter().enumerate() { + assert!(!ja.is_empty()); + assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); + assert_eq!(Language::En.tr(en), *en); + } + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("xx"), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8624f0a0..ec4cbc3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod control; pub mod export_task; pub mod headless; +pub mod i18n; pub mod icons; pub mod import; pub mod links; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 4c4d2f73..71571d32 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -250,6 +250,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { let u = &app.ui; let panel = |p: RightPanel| Some(u.right == p); match id { + "app.language.english" => Some(u.language == crate::i18n::Language::En), + "app.language.japanese" => Some(u.language == crate::i18n::Language::Ja), "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), "view.secondWindow" => Some(u.second_window), @@ -593,6 +595,22 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { } bar.push((title.to_string(), tidy(items))); } + fn translate(nodes: &mut [MenuNode], language: crate::i18n::Language) { + for node in nodes { + match node { + MenuNode::Submenu { label, children } => { + *label = language.tr(label).to_string(); + translate(children, language); + } + MenuNode::Item { label, .. } => *label = language.tr(label).to_string(), + MenuNode::Separator => {} + } + } + } + for (title, nodes) in &mut bar { + *title = app.ui.language.tr(title).to_string(); + translate(nodes, app.ui.language); + } bar } @@ -646,9 +664,9 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { const TITLE_GAP: f32 = 24.0; /// Width of the in-window menu bar's titles. -pub fn bar_width(ui: &egui::Ui) -> f32 { +pub fn bar_width(ui: &egui::Ui, language: crate::i18n::Language) -> f32 { let t = crate::theme::Tokens::get(ui.ctx()); - MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() + MENUS.iter().map(|m| ui.painter().layout_no_wrap(language.tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() } /// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single @@ -666,14 +684,14 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 if total <= max_width { let saved = ui.spacing().item_spacing.x; ui.spacing_mut().item_spacing.x = TITLE_GAP; - for (title, items) in &bar { + for (index, (title, items)) in bar.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); - crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + crate::widgets::register(ui.ctx(), format!("menu:{}", MENUS.get(index).copied().unwrap_or(title.as_str())), r.rect); egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); } ui.spacing_mut().item_spacing.x = saved; } else { - let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false)); + let r = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Menu")).font(font.clone()).color(t.text_label)).frame(false)); crate::widgets::register(ui.ctx(), "menu:all", r.rect); egui::Popup::menu(&r).show(|ui| { for (title, items) in &bar { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2d0bcff2..b4ef0ab3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,8 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); pub const UI_COMMANDS: &[UiCommand] = &[ + ("app.language.english", "English", None, "Edit>Language"), + ("app.language.japanese", "日本語", None, "Edit>Language"), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), // G: Photo Grid ↔ Square Grid (from other views: the photo grid) @@ -202,6 +204,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> { + if matches!(id, "app.language.english" | "app.language.japanese") { + app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En }; + return Some(Ok(json!(app.ui.language))); + } let ctx = egui::Context::default(); let r: Result = match id { "view.photoGrid" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 71fa5644..da1b386d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -551,8 +551,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { wm.image_width = (width / 100.0) as f32; } } else { + choices(ui, app.ui.language.tr("Orientation"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal")), (true, app.ui.language.tr("Vertical"))], &mut wm.vertical); field(ui, "Text", |ui| { - ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY)) + ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY)) }); } choices( diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 8142b9ec..e1aaacf7 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -19,7 +19,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field - let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui, app.ui.language) + 24.0; sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); } let search_left = full.center().x - sw / 2.0; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59c8087e..c585c6a7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -200,6 +200,8 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + #[serde(default)] + pub language: crate::i18n::Language, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] pub preview_build_seen: Option<(usize, bool)>, @@ -508,6 +510,7 @@ pub enum Dialog { impl Default for UiState { fn default() -> Self { UiState { + language: crate::i18n::Language::default(), preview_build_seen: None, unsaved_seen: false, luminance_map_restore: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 65e9c3ef..91f044f9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -117,6 +117,10 @@ pub fn install_fonts(ctx: &egui::Context) { let mut semi = vec!["Inter-SemiBold".to_string()]; semi.extend(fallback); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); + fonts.font_data.insert("japanese".into(), Arc::new(FontData::from_static(lightcraft_engine::export::WATERMARK_JAPANESE_FONT))); + for stack in fonts.families.values_mut() { + stack.push("japanese".into()); + } ctx.set_fonts(fonts); } diff --git a/docs/parity.md b/docs/parity.md index 2b864225..78eca9be 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -116,7 +116,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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), 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); batched import with a progress window; one undo step. Drag-and-drop still imports directly | | 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); no PTP/MTP (cameras that don't mount as a disk) | +| 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 like Move's (a new file, synced, compared byte for byte; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | @@ -128,7 +128,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | -| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | ## B. Library management (LIB) @@ -436,10 +436,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file, synced and renamed into place (issue #93) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | -| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) | @@ -480,7 +480,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-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | -| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive. The thumbnail cache stays in the library | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). The thumbnail cache stays in the library | | LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | | LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | @@ -728,7 +728,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | -| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable | +| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | @@ -793,3 +793,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | | KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | | KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | + +### Japanese text watermarks + +`app.export` watermark objects accept `vertical` (boolean, defaults to false). The export +dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back to bundled +BIZ UDMincho. Vertical lettering uses upright em cells and right-to-left newline columns; +Latin stays upright. Advanced Japanese composition remains open. Regression coverage: +`export::tests::japanese_watermarks_support_vertical_columns_and_legacy_defaults`. diff --git a/docs/whats-new.md b/docs/whats-new.md index 524f77ae..9dd8f27c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,22 @@ - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. ### Reliability +- Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with + the same name and "Overwrite" (or Export with Previous repeating it), an exact output path from the control + channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear + message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked. + Exported files are written to a temp file, synced and then renamed into place, so a full disk or an unplugged + drive never leaves a truncated file; the XMP sidecar of an "Original" export follows the "If file exists" + choice too. +- Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue + #106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its + name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write + leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or + a full drive) no longer counts as built and Build Smart Previews replaces it. +- Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and + compared byte for byte with the card. A copy that fails or differs is removed and reported as a failed import — + so "import complete" means the copies are good before you format the card — and a name that is taken gets -1, + -2… instead of being replaced. - Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter layout is read from each file instead of assumed. @@ -19,6 +35,8 @@ in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf` (`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back. +- The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that + already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared. ### Library - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive