mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-17 04:36:56 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c066b66f2 | ||
|
|
e11f77d9ca | ||
|
|
f383d1e736 | ||
|
|
edf05789ed | ||
|
|
bccccfb92a | ||
|
|
4939d9b994 | ||
|
|
e0ab41ede0 | ||
|
|
8690b51fcf | ||
|
|
e1d3b56bb6 | ||
|
|
6100237cdb | ||
|
|
4579599745 | ||
|
|
8506fecf5f | ||
|
|
0f49ebf8f0 | ||
|
|
ab86862011 | ||
|
|
577330a08b | ||
|
|
30697cfc26 | ||
|
|
ddbf139e5e | ||
|
|
ee77490dd2 | ||
|
|
f31a55c8a7 | ||
|
|
7218b990f5 | ||
|
|
d16d9aadeb | ||
|
|
1caa04acbc | ||
|
|
bef2060b24 | ||
|
|
40303b999c | ||
|
|
ce5488ad99 | ||
|
|
f6bc1ae5ae | ||
|
|
6c8a65c337 | ||
|
|
fe386f4f15 | ||
|
|
5e618c553a | ||
|
|
cbebabccad | ||
|
|
08c2acda26 | ||
|
|
1a05c3eed2 | ||
|
|
ee4b78f646 | ||
|
|
d592b29d12 | ||
|
|
cddcb905e0 | ||
|
|
e96a2750a1 | ||
|
|
771b0b98d7 | ||
|
|
f4df914731 | ||
|
|
882a059fac | ||
|
|
fb5b18843f | ||
|
|
dfbb1ad935 | ||
|
|
0fd0ffb783 |
No files matched your search
@@ -91,6 +91,17 @@ USAGE:
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
|
||||
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
|
||||
@@ -182,7 +193,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
h
|
||||
}
|
||||
@@ -419,7 +430,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -586,7 +597,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
|
||||
@@ -260,3 +260,31 @@ fn devices_are_listed_and_imported_from() {
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
|
||||
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
|
||||
/// with who has it and how to drive the running app instead; free again once the first exits.
|
||||
#[test]
|
||||
fn a_library_open_in_another_process_is_refused() {
|
||||
let lib = tmp("locked-lib");
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let lib_s = lib.to_str().unwrap();
|
||||
let mut holder =
|
||||
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut err = BufReader::new(holder.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("opened library") {
|
||||
line.clear();
|
||||
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
|
||||
}
|
||||
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(!ok);
|
||||
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("mcp --connect"), "{stderr}");
|
||||
|
||||
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
|
||||
assert!(holder.wait().unwrap().success());
|
||||
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(ok, "{stderr}");
|
||||
assert_eq!(lines[0]["ok"], true);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -295,6 +295,7 @@ impl WebApp {
|
||||
files: Box::new(files.store()),
|
||||
on_disk: false,
|
||||
};
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
@@ -305,9 +306,17 @@ impl WebApp {
|
||||
r.replayed
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("opening the library failed: {e}; starting a temporary one");
|
||||
session = Session::with_demo();
|
||||
// never a silent demo session (issue #100): an empty one, and the window says why
|
||||
log::error!("opening the library failed: {e}");
|
||||
session = Session::new();
|
||||
originals.install(&mut session);
|
||||
problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem {
|
||||
can_retry: false,
|
||||
..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new(
|
||||
"the browser's storage for this page",
|
||||
format!("{e}. Reload the page to try again."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
@@ -318,6 +327,7 @@ impl WebApp {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
app.library_problem = problem;
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,235 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the connection
|
||||
// closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused_without_buffering_it_all() {
|
||||
// An endless line without a newline: reading stops after MAX_LINE + 1 bytes.
|
||||
let endless = std::io::repeat(b'x');
|
||||
let mut out = Vec::new();
|
||||
let mut ran = false;
|
||||
serve_lines(endless, &mut out, |_, _| {
|
||||
ran = true;
|
||||
None
|
||||
});
|
||||
assert!(!ran);
|
||||
let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap();
|
||||
assert!(reply["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
}
|
||||
+197
-28
@@ -7,7 +7,9 @@
|
||||
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts).
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
||||
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
||||
//! (`lightcraft_ui_egui::panels::library_problem`).
|
||||
//!
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
@@ -26,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
|
||||
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
||||
@@ -41,15 +44,16 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.1.as_mut() {
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -58,7 +62,9 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
}
|
||||
@@ -78,23 +84,118 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any.
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
return (None, None, false);
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
}
|
||||
|
||||
fn save_prefs(app: &LightcraftApp) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
Err(e) => {
|
||||
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
let kept = std::fs::rename(path, &keep);
|
||||
let what = match &kept {
|
||||
Ok(()) => format!("it was kept as {}", keep.display()),
|
||||
Err(r) => format!("it couldn't be set aside ({r})"),
|
||||
};
|
||||
let msg = format!(
|
||||
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
|
||||
path.display()
|
||||
);
|
||||
(None, Some(msg), kept.is_err())
|
||||
}
|
||||
}
|
||||
if let Some(d) = config_dir() {
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(d.join("ui.json"), bytes);
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
|
||||
/// disk leaves the old file or the new one, never a truncated one).
|
||||
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let written = (|| {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(dir) = path.parent()
|
||||
&& let Ok(d) = std::fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
/// The last write failed (reported once until a write works).
|
||||
failing: bool,
|
||||
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
||||
keep_file: bool,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let moved = app.ui.settings.library_path != self.library;
|
||||
if !moved && now - self.checked < 3.0 {
|
||||
return;
|
||||
}
|
||||
self.checked = now;
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
// don't retry every frame
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,15 +319,19 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return fallback();
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
|
||||
return fallback();
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
@@ -240,11 +345,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
t0.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
s
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
|
||||
fallback()
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +398,7 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let prefs = load_prefs();
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -321,17 +426,29 @@ fn main() -> eframe::Result {
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if !files.is_empty() {
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
@@ -341,9 +458,61 @@ fn main() -> eframe::Result {
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
fn damaged_ui_json_is_kept_and_reported() {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
assert!(!path.exists());
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_atomic(&path, b"{\"new\": true}").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
assert!(!d.join("ui.json.tmp").exists());
|
||||
// a failed write leaves the old file
|
||||
let missing = d.join("gone").join("ui.json");
|
||||
assert!(write_atomic(&missing, b"x").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
+196
-26
@@ -1,8 +1,9 @@
|
||||
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
|
||||
//!
|
||||
//! Files (in a [`Store`]):
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N`; replaced atomically (temp + fsync + rename).
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
|
||||
//! rename).
|
||||
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
|
||||
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
|
||||
//! fsynced before [`Journal::append`] returns.
|
||||
@@ -10,6 +11,12 @@
|
||||
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
|
||||
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
|
||||
//! and the file is cut back to the last good record, so later appends start on a clean line;
|
||||
//! - a **failed append** (disk full, a dropped network share) may leave part of its batch in the
|
||||
//! file: [`Journal::append`] cuts the log back to the last whole record before returning the
|
||||
//! error (or, if that fails too, before the next append), so the retry — the same ops with the
|
||||
//! same `seq`s — starts on a clean line. Logs written by builds without that cut-back hold the
|
||||
//! fragment glued to the first retried record on one line; the loader recognises the whole,
|
||||
//! CRC-valid record at the end of such a line and skips only the fragment;
|
||||
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
|
||||
//! the log) are skipped;
|
||||
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
|
||||
@@ -27,6 +34,32 @@
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
//!
|
||||
//! # Format versions
|
||||
//!
|
||||
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
|
||||
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
|
||||
//!
|
||||
//! | Version | Written by | Adds |
|
||||
//! |---|---|---|
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
|
||||
//! build would misread or silently drop. New fields must deserialize from older data
|
||||
//! (`#[serde(default)]`), so newer builds keep reading every older format.
|
||||
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
|
||||
//! snapshot in this build's format (also for a new library), before anything is appended — so a
|
||||
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
|
||||
//! [`LoadReport::upgraded_from`] says when that happened.
|
||||
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
|
||||
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
|
||||
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
|
||||
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
|
||||
//! "unsupported format".)
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -34,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
|
||||
pub const SNAPSHOT: &str = "catalog.snap";
|
||||
pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
const VERSION: u32 = 1;
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 2;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -62,10 +97,16 @@ pub struct LoadReport {
|
||||
pub failed: usize,
|
||||
/// Bytes of a torn final record that were dropped.
|
||||
pub torn_bytes: u64,
|
||||
/// Partial records left mid-log by a failed append that was retried (written by builds that
|
||||
/// didn't cut the log back after a failed write): skipped, the retried records were replayed.
|
||||
pub torn_fragments: usize,
|
||||
/// The damaged log was preserved under this name.
|
||||
pub damaged: Option<String>,
|
||||
/// No catalog files existed (a new library).
|
||||
pub created: bool,
|
||||
/// The library was in an older catalog format (`0` = before format versions: a log without a
|
||||
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
|
||||
pub upgraded_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
@@ -140,6 +181,9 @@ pub struct Journal {
|
||||
pending: Option<Pending>,
|
||||
/// After a failed background snapshot: don't retry before the log has this many records.
|
||||
retry_at_records: u64,
|
||||
/// A failed append may have left part of its records after `log_bytes`, and cutting them off
|
||||
/// failed too: the next append cuts the log back to `log_bytes` first.
|
||||
tail_dirty: bool,
|
||||
}
|
||||
|
||||
/// A background snapshot in flight.
|
||||
@@ -168,18 +212,58 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
|
||||
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
|
||||
}
|
||||
|
||||
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
|
||||
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
|
||||
/// the op is one this version doesn't know (see [`Journal::open`]).
|
||||
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
let seq: u64 = seq.parse().ok()?;
|
||||
let crc: u32 = crc.parse().ok()?;
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return None;
|
||||
match decode_line(line) {
|
||||
Line::Record(seq, op) => Some((seq, op)),
|
||||
Line::Newer(_) | Line::Bad => None,
|
||||
}
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// One log line, decoded.
|
||||
enum Line {
|
||||
Record(u64, Op),
|
||||
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
|
||||
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
|
||||
Newer(u64),
|
||||
/// Malformed or a CRC mismatch: torn or damaged.
|
||||
Bad,
|
||||
}
|
||||
|
||||
fn decode_line(line: &str) -> Line {
|
||||
let parts = || {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
|
||||
};
|
||||
let Some((seq, crc, body)) = parts() else { return Line::Bad };
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return Line::Bad;
|
||||
}
|
||||
match serde_json::from_str(body) {
|
||||
Ok(op) => Line::Record(seq, op),
|
||||
Err(_) => Line::Newer(seq),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a library written by a newer LightCraft.
|
||||
fn newer(what: String) -> CatalogError {
|
||||
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
|
||||
}
|
||||
|
||||
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
|
||||
/// by the record that retried it (the retry re-encodes the same ops with the same `seq`s). Returns
|
||||
/// the fragment's length and the record, if that record continues the log (`seq <= last + 1`).
|
||||
/// The record's CRC must match, so damage is never mistaken for it.
|
||||
fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
|
||||
const START: &[u8] = b"{\"seq\":";
|
||||
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
|
||||
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
|
||||
(rec.0 <= last + 1).then_some((i, rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `catalog.snap` for the state after op `seq`, streaming the JSON into the store (no
|
||||
@@ -205,10 +289,16 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Just the identification of `catalog.snap` (read before the catalog itself).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
struct SnapHeader {
|
||||
format: String,
|
||||
version: u32,
|
||||
}
|
||||
|
||||
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
seq: u64,
|
||||
catalog: Catalog,
|
||||
}
|
||||
@@ -221,15 +311,20 @@ impl Journal {
|
||||
let snap = store.read(SNAPSHOT).map_err(io)?;
|
||||
let log = store.read(LOG).map_err(io)?;
|
||||
report.created = snap.is_none() && log.is_none();
|
||||
let (mut catalog, snapshot_seq) = match snap {
|
||||
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
|
||||
Some(bytes) => {
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if s.format != FORMAT || s.version > VERSION {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
|
||||
// the header first: a newer snapshot may not parse as this version's catalog
|
||||
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if h.format != FORMAT {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
|
||||
}
|
||||
(s.catalog, s.seq)
|
||||
if h.version > VERSION {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
(s.catalog, s.seq, Some(h.version))
|
||||
}
|
||||
None => (Catalog::new(), 0),
|
||||
None => (Catalog::new(), 0, None),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal {
|
||||
@@ -242,6 +337,7 @@ impl Journal {
|
||||
stats: PersistStats::default(),
|
||||
pending: None,
|
||||
retry_at_records: 0,
|
||||
tail_dirty: false,
|
||||
};
|
||||
let log = log.unwrap_or_default();
|
||||
|
||||
@@ -261,7 +357,22 @@ impl Journal {
|
||||
good_end = next;
|
||||
continue;
|
||||
}
|
||||
match line.and_then(decode_record) {
|
||||
let mut record = line.and_then(decode_record);
|
||||
if record.is_none()
|
||||
&& let Some((at, r)) = resync(&log[pos..line_end], j.seq)
|
||||
{
|
||||
// the torn start of a failed append, then the retried records (older builds
|
||||
// appended them right after the fragment)
|
||||
log::warn!("catalog log: skipped a partial record ({at} bytes) left by a failed write");
|
||||
report.torn_fragments += 1;
|
||||
record = Some(r);
|
||||
}
|
||||
if record.is_none()
|
||||
&& let Some(Line::Newer(seq)) = line.map(decode_line)
|
||||
{
|
||||
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
|
||||
}
|
||||
match record {
|
||||
Some((seq, op)) if seq <= j.seq => {
|
||||
// already in the snapshot
|
||||
let _ = op;
|
||||
@@ -277,7 +388,15 @@ impl Journal {
|
||||
}
|
||||
_ => {
|
||||
// bad record (or a gap): torn tail if nothing good follows, else damage
|
||||
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
|
||||
let mut rest_has_good = false;
|
||||
for l in log[next..].split(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
|
||||
Ok(Line::Record(..)) => rest_has_good = true,
|
||||
// refused before anything is modified
|
||||
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
|
||||
Ok(Line::Bad) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
if rest_has_good {
|
||||
damaged_at = Some(pos);
|
||||
}
|
||||
@@ -301,10 +420,34 @@ impl Journal {
|
||||
report.torn_bytes = (log.len() - good_end) as u64;
|
||||
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
|
||||
j.store.truncate(LOG, good_end as u64).map_err(io)?;
|
||||
j.log_bytes = good_end as u64;
|
||||
} else if needs_newline {
|
||||
j.store.append(LOG, b"\n").map_err(io)?;
|
||||
j.log_bytes = good_end as u64 + 1;
|
||||
} else {
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
}
|
||||
// Bring an older (or new, or snapshot-less) library to this version's format before
|
||||
// anything is appended: a snapshot in this format makes older builds refuse the library
|
||||
// instead of misreading ops or dropping fields they don't know.
|
||||
if report.created {
|
||||
// tiny: written directly, not counted as a compaction
|
||||
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
|
||||
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
|
||||
log::warn!("catalog: can't write the new library's snapshot: {e}");
|
||||
}
|
||||
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
|
||||
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
|
||||
match j.snapshot(&catalog) {
|
||||
Ok(()) => {
|
||||
if let Some(v) = report.upgraded_from {
|
||||
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
|
||||
}
|
||||
}
|
||||
// nothing lost: the old files are intact and still load; retried on next open
|
||||
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
|
||||
}
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
catalog.revision = 0;
|
||||
Ok((j, catalog, report))
|
||||
@@ -323,7 +466,20 @@ impl Journal {
|
||||
buf.push_str(&encode_record(seq, op));
|
||||
buf.push('\n');
|
||||
}
|
||||
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
|
||||
if self.tail_dirty {
|
||||
self.store.truncate(LOG, self.log_bytes).map_err(io)?;
|
||||
self.tail_dirty = false;
|
||||
}
|
||||
if let Err(e) = self.store.append(LOG, buf.as_bytes()) {
|
||||
// Part of the batch may be in the file. Cut it back to the last whole record, so the
|
||||
// retry (same seqs) starts on a clean line instead of after a fragment that would
|
||||
// read back as damage.
|
||||
if let Err(t) = self.store.truncate(LOG, self.log_bytes) {
|
||||
log::error!("catalog: can't cut the log back after a failed append: {t}");
|
||||
self.tail_dirty = true;
|
||||
}
|
||||
return Err(io(e));
|
||||
}
|
||||
if let Some(p) = self.pending.as_mut() {
|
||||
p.tail.extend_from_slice(buf.as_bytes());
|
||||
p.tail_records += ops.len() as u64;
|
||||
@@ -343,12 +499,22 @@ impl Journal {
|
||||
|
||||
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
|
||||
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
|
||||
self.snapshot_with_unlogged(catalog, 0)
|
||||
}
|
||||
|
||||
/// [`Journal::snapshot`] of a catalog that also reflects `unlogged` ops applied after the
|
||||
/// last appended one but never written to the log (their append failed). The snapshot counts
|
||||
/// them (it holds the state after op `seq + unlogged`), so they are saved and must not be
|
||||
/// appended afterwards. Once the snapshot is durable [`Journal::seq`] includes them, even if
|
||||
/// resetting the log then fails (the error is still returned).
|
||||
pub fn snapshot_with_unlogged(&mut self, catalog: &Catalog, unlogged: u64) -> Result<()> {
|
||||
// never two snapshot writers; the one in flight is older, so it must land first
|
||||
if let Err(e) = self.wait() {
|
||||
log::warn!("catalog: background snapshot failed ({e}); writing one now");
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), self.seq, catalog) {
|
||||
let seq = self.seq + unlogged;
|
||||
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// nothing lost (the log is whole); don't retry on every append
|
||||
@@ -357,6 +523,9 @@ impl Journal {
|
||||
return Err(io(e));
|
||||
}
|
||||
};
|
||||
// the snapshot is durable: it holds every op up to `seq`
|
||||
self.seq = seq;
|
||||
self.snapshot_seq = seq;
|
||||
let t2 = web_time::Instant::now();
|
||||
// A crash here leaves old records in the log; they are skipped by seq on load.
|
||||
self.store.write_atomic(LOG, b"").map_err(io)?;
|
||||
@@ -365,10 +534,10 @@ impl Journal {
|
||||
timing.blocking_ms = timing.total_ms;
|
||||
timing.records = self.log_records;
|
||||
self.record_snapshot(timing);
|
||||
self.snapshot_seq = self.seq;
|
||||
self.log_records = 0;
|
||||
self.log_bytes = 0;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -459,6 +628,7 @@ impl Journal {
|
||||
self.log_records = p.tail_records;
|
||||
self.log_bytes = p.tail.len() as u64;
|
||||
self.retry_at_records = 0;
|
||||
self.tail_dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
|
||||
//! - **undo/redo**: the engine keeps inverse ops;
|
||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||
//!
|
||||
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
|
||||
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
|
||||
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
|
||||
//! instead of reading part of it.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -12,6 +17,7 @@ pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
pub mod lock;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
@@ -27,6 +33,7 @@ pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTim
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use lock::{LibraryLock, LockError, LockOwner};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
@@ -47,6 +54,10 @@ pub enum CatalogError {
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
Io(String),
|
||||
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
|
||||
/// version doesn't know). Nothing was read into the session and nothing was modified.
|
||||
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
|
||||
Newer(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||
@@ -644,6 +655,12 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -0,0 +1,140 @@
|
||||
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
|
||||
//! the library is open (issue #99).
|
||||
//!
|
||||
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
|
||||
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
|
||||
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
//! - File systems without lock support (some network shares): the library opens unlocked, with a
|
||||
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
|
||||
//! advisory and depend on the server; the owner file is then the only hint.
|
||||
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
|
||||
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The lock file in the library directory.
|
||||
pub const LOCK: &str = "catalog.lock";
|
||||
/// Who holds the lock (JSON, see [`LockOwner`]).
|
||||
pub const OWNER: &str = "catalog.lock.owner";
|
||||
|
||||
/// Who has the library open (as written in [`OWNER`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LockOwner {
|
||||
pub pid: u32,
|
||||
/// Computer name, if known.
|
||||
pub host: String,
|
||||
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
|
||||
pub program: String,
|
||||
pub version: String,
|
||||
/// When it opened the library (seconds since 1970).
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
impl LockOwner {
|
||||
/// This process, as `program`.
|
||||
pub fn this_process(program: &str) -> LockOwner {
|
||||
LockOwner {
|
||||
pid: std::process::id(),
|
||||
host: host_name(),
|
||||
program: program.to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
|
||||
pub fn describe(&self) -> String {
|
||||
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
|
||||
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
|
||||
let host = if self.host.is_empty() {
|
||||
String::new()
|
||||
} else if self.host == host_name() {
|
||||
" on this computer".to_string()
|
||||
} else {
|
||||
format!(" on {}", self.host)
|
||||
};
|
||||
format!("{program}{version} (process {}{host})", self.pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// The computer's name, best effort (empty if unknown).
|
||||
fn host_name() -> String {
|
||||
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
|
||||
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process has the library open.
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
|
||||
InUse(Option<LockOwner>),
|
||||
}
|
||||
|
||||
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
|
||||
/// dies).
|
||||
#[derive(Debug)]
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl LibraryLock {
|
||||
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Ok(unlocked(e)),
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
|
||||
return Err(LockError::InUse(owner));
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
|
||||
}
|
||||
let owner = LockOwner::this_process(program);
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&owner)
|
||||
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,12 @@ pub trait Store: Send {
|
||||
self.write_atomic(name, &buf)?;
|
||||
Ok(buf.len() as u64)
|
||||
}
|
||||
/// Append and make durable (fsync) before returning.
|
||||
/// Append and make durable (fsync) before returning. On an error, part of `data` may have
|
||||
/// been written (a full disk, a dropped network share): the journal cuts the file back to its
|
||||
/// previous length with [`Store::truncate`] before appending again.
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again).
|
||||
/// Cut a file to `len` bytes (drop a torn tail before appending again). A missing file is
|
||||
/// left missing.
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
|
||||
/// Human-readable location (diagnostics).
|
||||
fn describe(&self) -> String;
|
||||
@@ -132,15 +135,26 @@ impl Store for FsStore {
|
||||
self.appender = Some((name.to_string(), f));
|
||||
}
|
||||
let Some((_, f)) = self.appender.as_mut() else { return Err(io::Error::other("appender not open")) };
|
||||
f.write_all(data)?;
|
||||
f.sync_data()
|
||||
let written = f.write_all(data).and_then(|()| f.sync_data());
|
||||
if written.is_err() {
|
||||
// The handle may be dead (a network share that reconnected, a removed volume): the next
|
||||
// attempt reopens the file. Part of `data` may be in the file; the caller cuts it back
|
||||
// ([`Store::truncate`]) before appending again.
|
||||
self.appender = None;
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
|
||||
self.appender = None;
|
||||
}
|
||||
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
|
||||
let f = match std::fs::OpenOptions::new().write(true).open(self.path(name)) {
|
||||
Ok(f) => f,
|
||||
// nothing to cut (an append that failed before creating the file)
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
f.set_len(len)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
@@ -180,7 +180,8 @@ fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
assert!(g.files.get(SNAPSHOT).is_none());
|
||||
// still the (empty) snapshot written when the library was created
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
|
||||
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
|
||||
use crate::*;
|
||||
|
||||
fn photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// A catalog with two photos and the log line for a third op on top of it.
|
||||
fn legacy_parts() -> (Catalog, String, Catalog) {
|
||||
let mut c = Catalog::new();
|
||||
for _ in 0..2 {
|
||||
let op = photo(&mut c);
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let base = c.clone();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let op = Op::SetRating { id, rating: 4 };
|
||||
c.apply(op.clone()).unwrap();
|
||||
(base, format!("{}\n", encode_record(3, &op)), c)
|
||||
}
|
||||
|
||||
fn snapshot_version(m: &MemStore) -> u64 {
|
||||
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
|
||||
v["version"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
|
||||
// rewritten in this format before anything is appended
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
assert!(m.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.seq(), 3);
|
||||
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
|
||||
drop(j);
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
let op = photo(&mut c);
|
||||
c.apply(op.clone()).unwrap();
|
||||
let m = MemStore::new();
|
||||
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
|
||||
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
assert_eq!(r.upgraded_from, Some(0));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
|
||||
/// it would read in part.
|
||||
#[test]
|
||||
fn new_library_is_written_in_this_format() {
|
||||
let m = MemStore::new();
|
||||
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert!(r.created);
|
||||
assert_eq!(r.upgraded_from, None);
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
fn assert_refused_untouched(m: &MemStore) {
|
||||
let before = m.files.lock().unwrap().clone();
|
||||
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
|
||||
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
|
||||
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_snapshot_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
// even when its catalog doesn't parse as this version's
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
|
||||
);
|
||||
assert_refused_untouched(&m);
|
||||
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
|
||||
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
|
||||
}
|
||||
|
||||
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
|
||||
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
|
||||
#[test]
|
||||
fn unknown_op_in_the_log_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let future = |seq: u64| {
|
||||
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
|
||||
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
|
||||
};
|
||||
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
|
||||
let id = base.photos().next().unwrap().id;
|
||||
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
|
||||
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.clone().into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
// a known op with a field of an unknown shape is "newer" too
|
||||
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.into_bytes());
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
|
||||
/// A CRC mismatch is still damage / a torn tail (not "newer").
|
||||
#[test]
|
||||
fn bad_crc_is_still_a_torn_tail() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
let body = r#"{"op":"fromTheFuture"}"#;
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
|
||||
}
|
||||
|
||||
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
|
||||
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
|
||||
/// variant here under the new version.
|
||||
#[test]
|
||||
fn op_variants_are_versioned() {
|
||||
fn since(op: &Op) -> u32 {
|
||||
match op {
|
||||
Op::AddPhoto { .. }
|
||||
| Op::RemovePhoto { .. }
|
||||
| Op::SetRating { .. }
|
||||
| Op::SetFlag { .. }
|
||||
| Op::SetLabel { .. }
|
||||
| Op::SetDevelop { .. }
|
||||
| Op::SetMeta { .. }
|
||||
| Op::SetDeleted { .. }
|
||||
| Op::SetLocal { .. }
|
||||
| Op::SetVersions { .. }
|
||||
| Op::SetHistory { .. }
|
||||
| Op::PushHistory { .. }
|
||||
| Op::AddAlbum { .. }
|
||||
| Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumPhotos { .. }
|
||||
| Op::SetAlbumCover { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::AddStack { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetStack { .. }
|
||||
| Op::SetCaptured { .. }
|
||||
| Op::SetAnalysis { .. }
|
||||
| Op::SetFile { .. }
|
||||
| Op::Relink { .. }
|
||||
| Op::SetContent { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -283,7 +283,12 @@ fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot());
|
||||
let legacy = format!(
|
||||
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n",
|
||||
crate::journal::VERSION,
|
||||
j.seq(),
|
||||
c.to_snapshot()
|
||||
);
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Issue #99: one process per library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
|
||||
|
||||
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, std::process::id());
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
|
||||
|
||||
drop(first);
|
||||
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
|
||||
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
|
||||
assert!(again.held());
|
||||
drop(again);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(l.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
|
||||
.env("LC_LOCK_CHILD_DIR", &dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !dir.join("child-locked").exists() {
|
||||
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
|
||||
assert!(std::time::Instant::now() < deadline, "child never locked");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, child.id());
|
||||
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
|
||||
|
||||
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
|
||||
child.wait().unwrap();
|
||||
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
|
||||
assert!(l.held());
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
|
||||
/// lock, say so, and wait to be killed.
|
||||
#[test]
|
||||
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
|
||||
fn lock_holder_child() {
|
||||
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
|
||||
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Issue #101: an append that fails part-way (disk full, a dropped network share) and is then
|
||||
//! retried must not read back as log damage, and must lose nothing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::journal::LOG;
|
||||
use crate::*;
|
||||
|
||||
/// A store whose next append writes only a prefix of its data, then fails.
|
||||
#[derive(Clone, Default)]
|
||||
struct Torn {
|
||||
files: MemStore,
|
||||
/// The next append writes this many bytes, then returns an error.
|
||||
torn_at: Arc<Mutex<Option<usize>>>,
|
||||
/// `truncate` fails (the cut-back after a failed append can't be done right away).
|
||||
truncate_fails: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Torn {
|
||||
fn tear_next_append(&self, bytes: usize) {
|
||||
*self.torn_at.lock().unwrap() = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Torn {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(n) = self.torn_at.lock().unwrap().take() {
|
||||
self.files.append(name, &data[..n.min(data.len())])?;
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
if self.truncate_fails.load(Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("share unavailable"));
|
||||
}
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"torn".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn add_photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// Apply `ops` to the live catalog (like a command) and return them for the journal.
|
||||
fn apply(c: &mut Catalog, ops: Vec<Op>) -> Vec<Op> {
|
||||
for op in &ops {
|
||||
c.apply(op.clone()).unwrap();
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// Every tear position of a batch, then the retry (same ops, same seqs, like the engine's queue)
|
||||
/// and more appends: reloading replays everything, reports no damage and no torn bytes.
|
||||
#[test]
|
||||
fn partial_append_then_retry_loads_cleanly() {
|
||||
let probe = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(probe.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
let before = probe.get(LOG).unwrap().len();
|
||||
j.append(&batch).unwrap();
|
||||
let batch_len = probe.get(LOG).unwrap().len() - before;
|
||||
|
||||
for tear in 1..batch_len {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 3 }, Op::SetFlag { id, flag: Flag::Pick }, Op::SetRating { id, rating: 4 }]);
|
||||
t.tear_next_append(tear);
|
||||
assert!(j.append(&batch).is_err(), "tear at {tear}");
|
||||
// cut back to the last whole record
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len, "tear at {tear}");
|
||||
j.append(&batch).unwrap();
|
||||
let more = apply(&mut c, vec![Op::SetLabel { id, label: Some(ColorLabel::ALL[0]) }]);
|
||||
j.append(&more).unwrap();
|
||||
drop(j);
|
||||
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!((r.torn_bytes, r.torn_fragments, r.replayed), (0, 0, 5), "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
}
|
||||
|
||||
/// When cutting back fails too (the share is gone), the next append cuts back first; until that
|
||||
/// works nothing more is appended after the fragment.
|
||||
#[test]
|
||||
fn failed_cut_back_is_done_before_the_next_append() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good_len = t.files.get(LOG).unwrap().len();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetRating { id, rating: 5 }]);
|
||||
t.truncate_fails.store(true, Ordering::SeqCst);
|
||||
t.tear_next_append(30);
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30, "the fragment is still there");
|
||||
// still unreachable: the retry fails without writing anything after the fragment
|
||||
assert!(j.append(&batch).is_err());
|
||||
assert_eq!(t.files.get(LOG).unwrap().len(), good_len + 30);
|
||||
t.truncate_fails.store(false, Ordering::SeqCst);
|
||||
j.append(&batch).unwrap();
|
||||
drop(j);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert!(r.damaged.is_none(), "{r:?}");
|
||||
assert_eq!(r.replayed, 3);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// Logs written before the fix: the fragment of the failed append is glued to the first retried
|
||||
/// record on one line, followed by the rest of the retry. They load in full (the fragment is
|
||||
/// skipped), instead of stopping at the fragment and setting the later records aside as damage.
|
||||
#[test]
|
||||
fn legacy_fragment_glued_to_the_retry_is_skipped() {
|
||||
let m = MemStore::new();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let good = m.get(LOG).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let batch = apply(
|
||||
&mut c,
|
||||
vec![
|
||||
Op::SetRating { id, rating: 3 },
|
||||
Op::SetFlag { id, flag: Flag::Pick },
|
||||
Op::SetLabelName { label: ColorLabel::ALL[1], name: Some("Größe ✓".into()) },
|
||||
],
|
||||
);
|
||||
j.append(&batch).unwrap();
|
||||
let full = m.get(LOG).unwrap();
|
||||
let batch_bytes = &full[good.len()..];
|
||||
let later = apply(&mut c, vec![Op::SetRating { id, rating: 1 }]);
|
||||
j.append(&later).unwrap();
|
||||
let later_bytes = m.get(LOG).unwrap()[full.len()..].to_vec();
|
||||
drop(j);
|
||||
|
||||
// every tear (inside the first record, after it, inside a multi-byte character…)
|
||||
for tear in 1..batch_bytes.len() {
|
||||
if batch_bytes[tear - 1] == b'\n' {
|
||||
continue; // a tear on a record boundary leaves no fragment
|
||||
}
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(&batch_bytes[..tear]);
|
||||
log.extend_from_slice(batch_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, loaded, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_none(), "tear at {tear}: {r:?}");
|
||||
assert_eq!(r.torn_fragments, 1, "tear at {tear}");
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot(), "tear at {tear}");
|
||||
}
|
||||
|
||||
// a damaged record whose tail happens to be a whole record from the future is still damage
|
||||
let mut log = good.clone();
|
||||
log.extend_from_slice(b"garbage");
|
||||
log.extend_from_slice(&later_bytes);
|
||||
log.extend_from_slice(&later_bytes);
|
||||
let t = MemStore::new();
|
||||
t.set(LOG, log);
|
||||
let (_, _, r) = Journal::open(Box::new(t)).unwrap();
|
||||
assert!(r.damaged.is_some(), "{r:?}");
|
||||
}
|
||||
|
||||
/// A snapshot can absorb ops whose append failed: it records them as saved (`seq` moves past
|
||||
/// them), and appending after it continues from there.
|
||||
#[test]
|
||||
fn snapshot_with_unlogged_ops_saves_them() {
|
||||
let t = Torn::default();
|
||||
let (mut j, mut c, _) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
let add = add_photo(&mut c);
|
||||
let first = apply(&mut c, vec![add]);
|
||||
j.append(&first).unwrap();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let queued = apply(&mut c, vec![Op::SetRating { id, rating: 2 }, Op::SetFlag { id, flag: Flag::Reject }]);
|
||||
t.tear_next_append(10);
|
||||
assert!(j.append(&queued).is_err());
|
||||
j.snapshot_with_unlogged(&c, queued.len() as u64).unwrap();
|
||||
assert_eq!(j.seq(), 3);
|
||||
let after = apply(&mut c, vec![Op::SetRating { id, rating: 5 }]);
|
||||
j.append(&after).unwrap();
|
||||
drop(j);
|
||||
let (j, loaded, r) = Journal::open(Box::new(t.clone())).unwrap();
|
||||
assert_eq!((r.snapshot_seq, r.replayed), (3, 1));
|
||||
assert_eq!(j.seq(), 4);
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
}
|
||||
|
||||
/// `FsStore::truncate` on a file that was never created (an append that failed first) is a no-op.
|
||||
#[test]
|
||||
fn fs_store_truncates_a_missing_file_quietly() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = FsStore::open(&dir).unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert!(!dir.join(LOG).exists());
|
||||
s.append(LOG, b"abc\n").unwrap();
|
||||
s.truncate(LOG, 0).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(LOG)).unwrap(), b"");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -217,58 +217,85 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
|
||||
/// point the photos back at a folder that no longer exists.
|
||||
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
use std::path::Path;
|
||||
/// Rename `from` to `to` on disk (a folder; everything in it goes along). Never overwrites:
|
||||
/// refused when `to` exists (a case-only rename on a case-insensitive file system goes through
|
||||
/// a temporary name). Missing parents of `to` are created.
|
||||
pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
return Err(format!("{from} is not a folder (moved or deleted?)"));
|
||||
}
|
||||
if dst.exists() {
|
||||
let case_only = from != to && from.to_lowercase() == to.to_lowercase();
|
||||
if dst.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
if dst.starts_with(src) {
|
||||
return Err("a folder can't move into itself".into());
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
let mut n = 0;
|
||||
let ops: Vec<lightcraft_catalog::Op> = s
|
||||
if case_only {
|
||||
let tmp = src.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
if tmp.exists() {
|
||||
return Err(format!("{} already exists", tmp.display()));
|
||||
}
|
||||
std::fs::rename(src, &tmp).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
return std::fs::rename(&tmp, dst).map_err(|e| {
|
||||
let _ = std::fs::rename(&tmp, src);
|
||||
format!("{from} → {to}: {e}")
|
||||
});
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
b.path = Path::new(to).join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
|
||||
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
|
||||
pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, file_name, np)| {
|
||||
n += 1;
|
||||
lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
}
|
||||
.map(|(id, file_name, np)| Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
})
|
||||
.collect();
|
||||
for op in ops {
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
// a browsed folder at or below it follows
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
|
||||
{
|
||||
b.path = dst.join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
let n = ops.len();
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
let folder = crate::FolderMove { from: to.to_string(), to: from.to_string() };
|
||||
if let Err(e) = s.commit_with_folder(&label, Op::Batch { ops }, folder) {
|
||||
// the catalog didn't take it: put the folder back where the photos still point
|
||||
let back = rename_folder_on_disk(to, from).err().map(|b| format!("; moving it back failed too: {b}")).unwrap_or_default();
|
||||
return Err(format!("{e}{back}"));
|
||||
}
|
||||
follow_folder(s, from, to);
|
||||
Ok(n)
|
||||
}
|
||||
@@ -10,22 +10,21 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bad, cmd, has_active, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
|
||||
fn dng_path(path: &str) -> String {
|
||||
/// `<stem>.dng`, then `<stem>-2.dng`, `<stem>-3.dng`… next to `path`.
|
||||
fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
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<String, String> {
|
||||
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<Value> {
|
||||
|
||||
@@ -867,6 +867,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// settings files that were unreadable or damaged at open (kept, defaults used)
|
||||
"settingsWarnings": lib.settings_warnings,
|
||||
// changes applied in memory whose write failed (retried by every save)
|
||||
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
||||
"unsavedError": s.unsaved().map(|u| u.1),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Missing originals: photos whose file is no longer where the library expects it (moved,
|
||||
//! renamed, on an unplugged drive), and relinking them — one at a time (`photo.relink`) or by
|
||||
//! searching a folder for files with the same name and size (`library.findMissing`).
|
||||
//! searching a folder for files with the same name and size, or the same content
|
||||
//! (`library.findMissing`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source};
|
||||
@@ -83,8 +84,29 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "path": abs}))
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file name — and its size when the
|
||||
/// library knows it — and relink every match in one undoable step.
|
||||
/// The stored content hash of a photo, without the `:dupN` suffix duplicates get.
|
||||
fn stored_hash(p: &Photo) -> Option<&str> {
|
||||
p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// The content hash of the file at `path` (as import computes it), if it can be read.
|
||||
fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
|
||||
/// undoable step.
|
||||
///
|
||||
/// Matching, per photo:
|
||||
/// 1. a file with the same name (any letter case), the same size and the same content hash;
|
||||
/// 2. renamed: a file of the same size with the same content hash (only same-size files are
|
||||
/// read, so this stays cheap);
|
||||
/// 3. without a hash to confirm (or the file was edited in place): the one file with the same
|
||||
/// name and size. Several such files are ambiguous: the photo is skipped and reported in
|
||||
/// `ambiguous`, never guessed (two cameras' `IMG_0001.CR3` of one size are different photos).
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
@@ -93,27 +115,72 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lost = missing(s);
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0}));
|
||||
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
|
||||
}
|
||||
// file name (lower case) → candidate paths
|
||||
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
|
||||
// file name (lower case) → candidate paths; size → paths (filled lazily)
|
||||
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for f in crate::import::expand(&[folder.to_string()], None) {
|
||||
if let Some(n) = Path::new(&f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f);
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
|
||||
for f in &files {
|
||||
if let Some(n) = Path::new(f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
let mut by_size: Option<HashMap<u64, Vec<String>>> = None;
|
||||
let mut hashes: HashMap<String, Option<String>> = HashMap::new();
|
||||
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
|
||||
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
let mut ambiguous = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = s.catalog.photo(*id).map(|p| p.file_size).unwrap_or(0);
|
||||
let hit = by_name.get(&name).and_then(|c| {
|
||||
c.iter().find(|f| size == 0 || std::fs::metadata(f).is_ok_and(|m| m.len() == size)).or_else(|| (c.len() == 1 && size == 0).then(|| &c[0]))
|
||||
});
|
||||
if let Some(path) = hit {
|
||||
ops.push(relink_op(*id, path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path}));
|
||||
let size = ph.file_size;
|
||||
let hash = stored_hash(ph).map(str::to_string);
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.unwrap_or_default();
|
||||
// 1. same name and size, confirmed by the content hash
|
||||
let mut hit: Option<(String, &str)> =
|
||||
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
|
||||
// 2. renamed: same size and content
|
||||
if hit.is_none()
|
||||
&& let Some(h) = &hash
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
let mut m: HashMap<u64, Vec<String>> = HashMap::new();
|
||||
for f in &files {
|
||||
if let Some(n) = size_of(f) {
|
||||
m.entry(n).or_default().push(f.clone());
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
|
||||
.map(|f| (f.clone(), "content"));
|
||||
}
|
||||
// 3. same name and size only (no hash, or the file was edited in place): one candidate
|
||||
// is taken, several are ambiguous and skipped rather than guessed
|
||||
if hit.is_none() {
|
||||
match named.len() {
|
||||
1 => hit = Some((named[0].clone(), "name")),
|
||||
0 => {}
|
||||
_ => {
|
||||
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
ops.push(relink_op(*id, &path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
|
||||
used.insert(path);
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
@@ -124,7 +191,7 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still}))
|
||||
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -146,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file (same name and size) is somewhere in the folder → {found: [{id, from, to}], missing}",
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
always,
|
||||
find_missing
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod before;
|
||||
mod browse;
|
||||
pub(crate) mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
|
||||
@@ -102,7 +102,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
/// 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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.xmp.naming).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
|
||||
@@ -9,14 +9,26 @@ use crate::{Result, Session};
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut written = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let owners = crate::sidecar::StemOwners::of(&s.catalog);
|
||||
for id in s.targets(p) {
|
||||
match s.save_sidecar(id) {
|
||||
Ok(path) => written.push(path.display().to_string()),
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
Ok(r) => {
|
||||
let path = r.path.display().to_string();
|
||||
if r.merged {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
if let Some(b) = r.backup {
|
||||
backups.push(json!({"path": path, "backup": b.display().to_string()}));
|
||||
}
|
||||
written.push(path);
|
||||
}
|
||||
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"written": written, "failed": failed}))
|
||||
Ok(json!({"written": written, "merged": merged, "backups": backups, "failed": failed}))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Metadata to File",
|
||||
["Photo"],
|
||||
Some("Cmd+S"),
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
|
||||
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original, merged into an existing one (other apps' data kept; an unreadable one is backed up first) → {written: [path], merged: [path], backups: [{path, backup}], failed}",
|
||||
has_selection,
|
||||
save
|
||||
),
|
||||
|
||||
@@ -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<String, String> {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.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
|
||||
|
||||
@@ -169,6 +169,27 @@ pub(crate) fn place(src: &Path, dir: &Path, name: &str) -> Result<Placed, String
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Import → **Copy**: copy `src` into `dir` as `name` (-1, -2… when taken) with the same checks as
|
||||
/// a move's copy — a new file (never overwriting one, even one that appears meanwhile), synced to
|
||||
/// disk and compared byte for byte with the source; a bad copy is removed and reported, so a card
|
||||
/// is never wiped on the strength of it. Returns the new path.
|
||||
pub(crate) fn copy_new(src: &Path, dir: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
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) {
|
||||
@@ -213,7 +234,7 @@ pub(crate) fn finish(p: &Placed) -> Result<Vec<(String, String)>, String> {
|
||||
}
|
||||
|
||||
/// A photo (by extension) still beside a stem-named sidecar, sharing its stem.
|
||||
fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
pub(crate) fn sibling_photo(sidecar: &Path) -> Option<String> {
|
||||
let dir = sidecar.parent()?;
|
||||
let stem = sidecar.file_stem()?.to_string_lossy().to_string();
|
||||
fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find_map(|f| {
|
||||
|
||||
+71
-11
@@ -57,6 +57,9 @@ pub enum EngineError {
|
||||
/// be written. They stay queued and are written by the next successful save.
|
||||
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
|
||||
NotSaved(String),
|
||||
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
|
||||
#[error("{0}")]
|
||||
LibraryInUse(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -68,6 +71,22 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
pub struct UndoEntry {
|
||||
pub label: String,
|
||||
pub op: Op,
|
||||
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
|
||||
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
|
||||
pub folder: Option<FolderMove>,
|
||||
}
|
||||
|
||||
/// A folder renamed or moved on disk as part of an undo step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FolderMove {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl FolderMove {
|
||||
fn reversed(&self) -> Self {
|
||||
Self { from: self.to.clone(), to: self.from.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress slider drag / brush stroke: one undo step when it ends.
|
||||
@@ -302,7 +321,7 @@ impl Session {
|
||||
let fwd = op.clone();
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -310,6 +329,16 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
|
||||
/// step renames it back (`folder` is the undo direction: current place → old place).
|
||||
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
|
||||
self.commit(label, op)?;
|
||||
if let Some(e) = self.undo.last_mut() {
|
||||
e.folder = Some(folder);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
||||
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
||||
/// Saved with the library but not an undo step.
|
||||
@@ -340,9 +369,13 @@ impl Session {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
}
|
||||
// a step that moves a folder on disk stays on its own
|
||||
if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) {
|
||||
return;
|
||||
}
|
||||
let tail = self.undo.split_off(self.undo.len() - n);
|
||||
let ops = tail.into_iter().rev().map(|e| e.op).collect();
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None });
|
||||
}
|
||||
|
||||
/// Apply without recording undo (interactive previews).
|
||||
@@ -353,7 +386,7 @@ impl Session {
|
||||
|
||||
pub fn undo_step(&mut self) -> Result<String> {
|
||||
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let redo = match self.apply_with_files(&e.op) {
|
||||
let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.undo.push(e);
|
||||
@@ -361,13 +394,13 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo });
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
pub fn redo_step(&mut self) -> Result<String> {
|
||||
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let undo = match self.apply_with_files(&e.op) {
|
||||
let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.redo.push(e);
|
||||
@@ -375,19 +408,44 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo });
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing).
|
||||
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
||||
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
|
||||
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
|
||||
/// library follows them (a logged change outside the undo history), so none goes missing; the
|
||||
/// folder is only moved back when no file was left behind at its new path.
|
||||
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
|
||||
}
|
||||
let undo_folder = || {
|
||||
if let Some(f) = folder {
|
||||
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
|
||||
}
|
||||
};
|
||||
let moves = self.file_moves(op);
|
||||
Session::move_files(&moves)?;
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
if e.stuck.is_empty() {
|
||||
undo_folder();
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
|
||||
}
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(inv) => Ok(inv),
|
||||
Ok(inv) => {
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::follow_folder(self, &f.from, &f.to);
|
||||
}
|
||||
Ok(inv)
|
||||
}
|
||||
Err(e) => {
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
let _ = Session::move_files(&back);
|
||||
if let Err(be) = rename::move_all(&fs, &back) {
|
||||
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
|
||||
}
|
||||
undo_folder();
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
@@ -635,6 +693,8 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
+149
-17
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
|
||||
use lightcraft_catalog::{FsStore, Journal, LibraryLock, LoadReport, Store};
|
||||
use lightcraft_develop::Preset;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -64,6 +64,15 @@ pub struct Library {
|
||||
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
|
||||
presets_written: String,
|
||||
view_written: Vec<u8>,
|
||||
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
|
||||
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
|
||||
pub settings_warnings: Vec<String>,
|
||||
warnings_reported: usize,
|
||||
/// Settings files that couldn't be read or set aside: never overwritten this session.
|
||||
blocked: Vec<&'static str>,
|
||||
/// Keeps other processes out of this library while it's open (last: released after the
|
||||
/// journal's background snapshot has landed).
|
||||
lock: Option<LibraryLock>,
|
||||
}
|
||||
|
||||
/// Where a library's files live (see [`Session::open_library_in`]).
|
||||
@@ -171,23 +180,112 @@ fn poll_compaction(lib: &mut Library) {
|
||||
/// How long the frame loop waits before retrying a failed append (commands retry at once).
|
||||
const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
/// `a` and `b` name the same directory.
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// This program, for the lock owner note ("LightCraft", "lightcraft-cli").
|
||||
fn program_name() -> String {
|
||||
let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()));
|
||||
match exe.as_deref() {
|
||||
Some("lightcraft") | None => "LightCraft".into(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems reading the settings files (`presets.json`, `prefs.json`, `view.json`) when the library
|
||||
/// opened (issue #103). A missing file is not a problem (defaults). A file that doesn't parse is
|
||||
/// kept as `<name>.corrupt-<unix time>` before the defaults are used (so the next save can't
|
||||
/// lose it); a file that can't be read at all (locked by another program, I/O error) is never
|
||||
/// written this session, so its content survives until the library is opened again.
|
||||
#[derive(Default)]
|
||||
struct SettingsLoad {
|
||||
warnings: Vec<String>,
|
||||
/// Files not to overwrite this session.
|
||||
blocked: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl SettingsLoad {
|
||||
fn read<T: serde::de::DeserializeOwned>(&mut self, store: &mut dyn Store, name: &'static str) -> Option<T> {
|
||||
let bytes = match store.read(name) {
|
||||
Ok(b) => b?,
|
||||
Err(e) => {
|
||||
log::error!("library: {name}: {e}");
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} couldn't be read ({e}). LightCraft uses the defaults for now and won't overwrite the file; reopen the library to try again."
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let err = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = format!("{name}.corrupt-{secs}");
|
||||
log::error!("library: {name} is damaged: {err}");
|
||||
match store.write_atomic(&keep, &bytes) {
|
||||
Ok(()) => self.warnings.push(format!("{name} is damaged ({err}). It was kept as {keep}, and the defaults are used.")),
|
||||
Err(w) => {
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} is damaged ({err}) and couldn't be set aside ({w}). LightCraft uses the defaults and won't overwrite the file."
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
|
||||
/// `seed_demo`, a newly created library starts with the procedural demo photos.
|
||||
///
|
||||
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
|
||||
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
|
||||
/// Reopening the library this session already has open keeps its lock.
|
||||
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
|
||||
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
|
||||
self.open_library_in(stores, seed_demo)
|
||||
// the library open in this session (same directory): hand its lock over
|
||||
let reused = self.library.as_mut().filter(|l| same_dir(&l.dir, &dir)).and_then(|l| l.lock.take());
|
||||
let reusing = reused.is_some();
|
||||
let mut lock = match reused {
|
||||
Some(l) => Some(l),
|
||||
None => Some(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?),
|
||||
};
|
||||
if let Err(e) = self.open_stores(stores, seed_demo, &mut lock) {
|
||||
if reusing && let Some(lib) = self.library.as_mut() {
|
||||
lib.lock = lock.take();
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
/// Open (or create) a library whose files live in `stores` (e.g. browser storage), replacing
|
||||
/// this session's catalog.
|
||||
/// this session's catalog. Not locked: the host guards against a second opener (see
|
||||
/// [`Session::open_library`] for directories).
|
||||
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
|
||||
self.open_stores(stores, seed_demo, &mut None)?;
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
fn loaded_report(&self) -> Result<&LoadReport> {
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `stores`; the new library takes `lock` once nothing can fail any more.
|
||||
fn open_stores(&mut self, stores: LibraryStores, seed_demo: bool, lock: &mut Option<LibraryLock>) -> Result<()> {
|
||||
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
|
||||
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
|
||||
// the current library may be these same files: let its background snapshot land first
|
||||
@@ -208,8 +306,9 @@ impl Session {
|
||||
crate::demo::load(self);
|
||||
journal.snapshot(&self.catalog)?;
|
||||
}
|
||||
let mut settings = SettingsLoad::default();
|
||||
// presets
|
||||
if let Some(f) = read_json::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
@@ -223,7 +322,7 @@ impl Session {
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// preferences
|
||||
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
@@ -244,7 +343,7 @@ impl Session {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.filter = v.filter;
|
||||
@@ -277,6 +376,10 @@ impl Session {
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
settings_warnings: settings.warnings,
|
||||
warnings_reported: 0,
|
||||
blocked: settings.blocked,
|
||||
lock: lock.take(),
|
||||
});
|
||||
// forget untouched Local records of folders not browsed for a while (journaled at once)
|
||||
if self.forget_local_days > 0 {
|
||||
@@ -290,10 +393,7 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
@@ -332,7 +432,7 @@ impl Session {
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if presets != lib.presets_written {
|
||||
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
|
||||
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
log::error!("library: presets: {e}");
|
||||
} else {
|
||||
@@ -362,16 +462,35 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
///
|
||||
/// The snapshot is written even when appending the queued ops fails: it is a fresh file
|
||||
/// holding everything in memory, so it saves those ops too (the log handle may be the only
|
||||
/// thing that's broken). Fails only if nothing could be saved; the ops then stay queued.
|
||||
pub fn close_library(&mut self) -> Result<()> {
|
||||
if self.library.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = self.end_interaction();
|
||||
self.persist()?;
|
||||
let persisted = self.persist();
|
||||
self.save_view();
|
||||
let unlogged = if persisted.is_err() { self.pending_log.len() as u64 } else { 0 };
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
lib.journal.snapshot(&self.catalog)?;
|
||||
Ok(())
|
||||
let before = lib.journal.seq();
|
||||
let snapshot = lib.journal.snapshot_with_unlogged(&self.catalog, unlogged);
|
||||
if unlogged > 0 && lib.journal.seq() == before + unlogged {
|
||||
// the snapshot holds the queued ops: they are saved, never append them again
|
||||
log::info!("library: {unlogged} queued change(s) saved in the closing snapshot");
|
||||
self.pending_log.clear();
|
||||
lib.unsaved_error = None;
|
||||
lib.retry_at = None;
|
||||
lib.last_error = None;
|
||||
}
|
||||
match (persisted, snapshot) {
|
||||
(_, Ok(())) => Ok(()),
|
||||
// neither the log nor the snapshot took the queued ops
|
||||
(Err(EngineError::NotSaved(e)), Err(s)) if lib.journal.seq() == before => Err(EngineError::NotSaved(format!("{e}; snapshot: {s}"))),
|
||||
(_, Err(s)) => Err(s.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
@@ -394,7 +513,7 @@ impl Session {
|
||||
}
|
||||
let v = self.view_json();
|
||||
let Some(lib) = self.library.as_mut() else { return };
|
||||
if v != lib.view_written {
|
||||
if v != lib.view_written && !lib.blocked.contains(&"view.json") {
|
||||
match lib.files.write_atomic("view.json", &v) {
|
||||
Ok(()) => lib.view_written = v,
|
||||
Err(e) => log::error!("library: view: {e}"),
|
||||
@@ -423,9 +542,22 @@ impl Session {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if lib.blocked.contains(&"prefs.json") {
|
||||
return Err(EngineError::Other(
|
||||
"prefs: prefs.json couldn't be read when the library opened, so it isn't overwritten; reopen the library to save preferences".into(),
|
||||
));
|
||||
}
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
|
||||
pub fn take_library_warnings(&mut self) -> Vec<String> {
|
||||
let Some(lib) = self.library.as_mut() else { return vec![] };
|
||||
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
|
||||
lib.warnings_reported = lib.settings_warnings.len();
|
||||
new
|
||||
}
|
||||
|
||||
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) {
|
||||
s.color.saturation = a.saturation;
|
||||
}
|
||||
|
||||
/// A free file name `dir/<stem>-<suffix>.dng` (`-2`, `-3`… when taken).
|
||||
fn output_path(first: &str, suffix: &str) -> String {
|
||||
/// `dir/<stem>-<suffix>.dng`, then `-2`, `-3`… (the candidates for a merge's output).
|
||||
fn output_names(first: &str, suffix: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
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<Value> {
|
||||
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
|
||||
|
||||
+522
-65
@@ -11,7 +11,11 @@
|
||||
//!
|
||||
//! Safety:
|
||||
//! - a target that exists on disk, or that another photo in the batch gets, receives a `-1`, `-2`…
|
||||
//! suffix — no file is ever overwritten;
|
||||
//! suffix — no file is ever overwritten. Whether an existing target is the photo itself (a
|
||||
//! change of letter case on a case-insensitive volume) is decided by file identity, not by
|
||||
//! comparing names, so on a case-sensitive volume `img_1.jpg` next to `IMG_1.JPG` is a collision
|
||||
//! (issue #95); moves never replace a file even if one appears after the check (hard link +
|
||||
//! unlink where the volume supports it);
|
||||
//! - the moves happen before the catalog changes; if one fails, the ones already done are moved
|
||||
//! back and nothing is committed;
|
||||
//! - the catalog op ([`Op::SetFile`]) is undoable: undo/redo move the files back and forth (see
|
||||
@@ -297,58 +301,251 @@ fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error nothing
|
||||
/// is left moved.
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
/// The file-system primitives renaming uses. [`RealFs`] is the disk; tests substitute a model of
|
||||
/// a case-insensitive or case-sensitive volume (and inject failures).
|
||||
pub(crate) trait MoveFs {
|
||||
/// Something (file, folder, link) answers to `p`.
|
||||
fn exists(&self, p: &Path) -> bool;
|
||||
fn is_file(&self, p: &Path) -> bool;
|
||||
/// `a` and `b` name the same file (e.g. `IMG.JPG` and `img.jpg` on a case-insensitive
|
||||
/// volume). Decided by file identity, never by comparing names: on a case-sensitive volume
|
||||
/// they are two different photos. When in doubt: `false`.
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool;
|
||||
/// Rename `a` to `b`, failing (never replacing) when `b` exists.
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
/// Copy `a` to a new file `b` (an independent copy, never a link), failing when `b` exists.
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()>;
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()>;
|
||||
/// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g.
|
||||
/// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar.
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String>;
|
||||
}
|
||||
|
||||
/// The real file system.
|
||||
pub(crate) struct RealFs;
|
||||
|
||||
impl MoveFs for RealFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
std::fs::symlink_metadata(p).is_ok()
|
||||
}
|
||||
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::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))]
|
||||
{
|
||||
// no stable file id in std here: `b` is `a` under another spelling when they look
|
||||
// alike and the folder has no entry spelled exactly like `b` (on a case-sensitive
|
||||
// folder holding both `IMG.JPG` and `img.jpg`, both entries are listed)
|
||||
let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false };
|
||||
if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return false };
|
||||
!entries.flatten().any(|e| e.file_name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// A hard link fails when `b` exists — even if it appeared since the check above — so
|
||||
// nothing is ever replaced. Volumes without hard links (FAT, some network shares) fall back
|
||||
// to a plain rename after the check.
|
||||
match std::fs::hard_link(a, b) {
|
||||
Ok(()) => std::fs::remove_file(a).inspect_err(|_| {
|
||||
// both names are the same file: dropping the new one loses nothing
|
||||
let _ = std::fs::remove_file(b);
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
|
||||
Err(_) => std::fs::rename(a, b),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(p)
|
||||
}
|
||||
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
crate::import_move::sibling_photo(sidecar)
|
||||
}
|
||||
}
|
||||
|
||||
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
|
||||
/// volume): the rename must go through a temporary name.
|
||||
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
|
||||
from != to && from.to_string_lossy().to_lowercase() == to.to_string_lossy().to_lowercase() && fs.exists(to) && fs.same_file(from, to)
|
||||
}
|
||||
|
||||
/// Rename `a` to `b` without ever replacing a file; a change of letter case only goes through a
|
||||
/// unique temporary name so case-insensitive volumes see a real change.
|
||||
fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if !is_respelling(fs, a, b) {
|
||||
return fs.rename_no_replace(a, b);
|
||||
}
|
||||
let tmp = (0..1000u32)
|
||||
.map(|k| a.with_file_name(format!(".lc-rename-{}-{k}", std::process::id())))
|
||||
.find(|t| !fs.exists(t))
|
||||
.ok_or_else(|| std::io::Error::other("no free temporary name"))?;
|
||||
fs.rename_no_replace(a, &tmp)?;
|
||||
fs.rename_no_replace(&tmp, b).map_err(|e| match fs.rename_no_replace(&tmp, a) {
|
||||
Ok(()) => e,
|
||||
Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Why [`move_file`] failed, and what it left behind.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MoveError {
|
||||
/// What went wrong, including every step that could not be undone and where that file is now.
|
||||
pub message: String,
|
||||
/// The file itself ended up at the new path: it moved, a sidecar didn't, and moving the file
|
||||
/// back failed too. Its sidecars that did move stay with it.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MoveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `from` to `to` (never over an existing file) together with its sidecars. On error the
|
||||
/// moves are undone; whatever can't be undone is named in the error (see [`MoveError`]).
|
||||
pub fn move_file(from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
move_file_with(&RealFs, from, to)
|
||||
}
|
||||
|
||||
/// [`move_file`] on the file system `fs`.
|
||||
pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::result::Result<(), MoveError> {
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
let fail = |message: String| MoveError { message, moved: false };
|
||||
let (f, t) = (Path::new(from), Path::new(to));
|
||||
// a case-only change on a case-insensitive file system "exists" already: that is this file
|
||||
let case_only = from.to_lowercase() == to.to_lowercase();
|
||||
if t.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
// `to` may be this very file spelled differently (case-insensitive volume); any other file
|
||||
// there — e.g. `img.jpg` next to `IMG.JPG` on a case-sensitive volume — is never replaced
|
||||
if fs.exists(t) && !is_respelling(fs, f, t) {
|
||||
return Err(fail(format!("{to} already exists")));
|
||||
}
|
||||
let mut done: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
let mv = |a: &Path, b: &Path| -> std::io::Result<()> {
|
||||
if case_only {
|
||||
// via a temporary name, so case-insensitive file systems see a real change
|
||||
let tmp = a.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
std::fs::rename(a, &tmp)?;
|
||||
std::fs::rename(&tmp, b).inspect_err(|_| {
|
||||
let _ = std::fs::rename(&tmp, a);
|
||||
})
|
||||
} else {
|
||||
std::fs::rename(a, b)
|
||||
}
|
||||
};
|
||||
mv(f, t).map_err(|e| format!("rename {from}: {e}"))?;
|
||||
done.push((f.to_path_buf(), t.to_path_buf()));
|
||||
move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?;
|
||||
// sidecars moved (or copied: `true`)
|
||||
let mut done: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (sc, naming, upper) in sidecars(from) {
|
||||
if !sc.is_file() {
|
||||
if !fs.is_file(&sc) {
|
||||
continue;
|
||||
}
|
||||
let mut dst = crate::sidecar::sidecar_path(to, naming);
|
||||
if upper {
|
||||
dst = dst.with_extension("XMP");
|
||||
}
|
||||
if dst.exists() && !case_only {
|
||||
// a stem sidecar another file still uses (`IMG_1.xmp` of `IMG_1.CR3` and `IMG_1.JPG`)
|
||||
// is copied, not taken away from it (issue #92)
|
||||
let shared = naming == SidecarNaming::Stem && fs.stem_sibling(&sc).is_some();
|
||||
if fs.exists(&dst) && (shared || !is_respelling(fs, &sc, &dst)) {
|
||||
continue; // never overwrite: the old sidecar stays where it was
|
||||
}
|
||||
if let Err(e) = mv(&sc, &dst) {
|
||||
for (a, b) in done.iter().rev() {
|
||||
let _ = std::fs::rename(b, a);
|
||||
let r = if shared { fs.copy_no_replace(&sc, &dst) } else { move_one(fs, &sc, &dst) };
|
||||
if let Err(e) = r {
|
||||
let mut message = format!("rename {}: {e}", sc.display());
|
||||
// the file first: if it can't go back, the sidecars that moved stay with it
|
||||
if let Err(e) = move_one(fs, t, f) {
|
||||
message.push_str(&format!("; {from} could not be moved back ({e}): it is now {to}"));
|
||||
if !done.is_empty() {
|
||||
let names: Vec<String> = done.iter().map(|(_, b, _)| b.display().to_string()).collect();
|
||||
message.push_str(&format!(" with its sidecar {}", names.join(", ")));
|
||||
}
|
||||
return Err(MoveError { message, moved: true });
|
||||
}
|
||||
return Err(format!("rename {}: {e}", sc.display()));
|
||||
for (a, b, copied) in done.iter().rev() {
|
||||
if *copied {
|
||||
// the original is still there: the copy just goes (a leftover copy loses nothing)
|
||||
let _ = fs.remove_file(b);
|
||||
} else if let Err(e) = move_one(fs, b, a) {
|
||||
message.push_str(&format!("; sidecar {} could not be moved back ({e}): it is now {}", a.display(), b.display()));
|
||||
}
|
||||
}
|
||||
return Err(fail(message));
|
||||
}
|
||||
done.push((sc, dst));
|
||||
done.push((sc, dst, shared));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a failed batch of moves ([`move_all`]) left behind.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BatchMoveError {
|
||||
/// The first failure and every move that could not be undone.
|
||||
pub message: String,
|
||||
/// Moves that stayed done, (from, to): those files are at `to` now.
|
||||
pub stuck: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Move the files `moves` names, all or nothing: after a failure the earlier moves are undone.
|
||||
/// Moves that can't be undone (e.g. the network share went away) are reported, not ignored.
|
||||
pub(crate) fn move_all(fs: &dyn MoveFs, moves: &[(String, String)]) -> std::result::Result<(), BatchMoveError> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
let Err(e) = move_file_with(fs, &m.0, &m.1) else {
|
||||
done.push(m);
|
||||
continue;
|
||||
};
|
||||
let mut message = e.message;
|
||||
let mut stuck = Vec::new();
|
||||
if e.moved {
|
||||
stuck.push(m.clone());
|
||||
}
|
||||
for d in done.iter().rev() {
|
||||
if let Err(e) = move_file_with(fs, &d.1, &d.0) {
|
||||
message.push_str(&format!("; {} could not be moved back to {}: {}", d.1, d.0, e.message));
|
||||
if !e.moved {
|
||||
stuck.push((*d).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(BatchMoveError { message, stuck });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `SetFile` ops in `op` (nested batches included) that point a photo at one of `to`.
|
||||
fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
match op {
|
||||
Op::Batch { ops } => ops.iter().for_each(|o| set_file_ops_to(o, to, out)),
|
||||
Op::SetFile { source: Source::File { path }, .. } if to.contains(path.as_str()) => out.push(op.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
|
||||
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
self.plan_rename_with(&RealFs, ids, template, start)
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] on the file system `fs`.
|
||||
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
// photos sharing one file (virtual copies) rename once
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
@@ -382,10 +579,12 @@ impl Session {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
let same = key == path.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
let on_disk = Path::new(&tp).exists() && !same;
|
||||
let on_disk = fs.exists(Path::new(&tp)) && !same;
|
||||
if !taken.contains(&key) && !on_disk {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
@@ -415,21 +614,16 @@ impl Session {
|
||||
|
||||
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
|
||||
/// that updates the photos (and their virtual copies).
|
||||
///
|
||||
/// When a failure can't be fully rolled back, the files that kept their new names are
|
||||
/// committed as a smaller undoable rename, so the library points at them (nothing goes
|
||||
/// missing), and the error lists them (old → new).
|
||||
pub fn apply_rename(&mut self, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut moved: Vec<(String, String)> = Vec::new();
|
||||
for pl in plans {
|
||||
if let (Some(a), Some(b)) = (&pl.from_path, &pl.to_path)
|
||||
&& a != b
|
||||
{
|
||||
if let Err(e) = move_file(a, b) {
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
moved.push((a.clone(), b.clone()));
|
||||
}
|
||||
}
|
||||
self.apply_rename_with(&RealFs, plans)
|
||||
}
|
||||
|
||||
/// [`Session::apply_rename`] on the file system `fs`.
|
||||
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
|
||||
let mut ops = Vec::new();
|
||||
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
|
||||
for p in self.catalog.photos() {
|
||||
@@ -447,17 +641,66 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
let op = Op::Batch { ops };
|
||||
let moves: Vec<(String, String)> = plans
|
||||
.iter()
|
||||
.filter_map(|pl| match (&pl.from_path, &pl.to_path) {
|
||||
(Some(a), Some(b)) if a != b => Some((a.clone(), b.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = move_all(fs, &moves) {
|
||||
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
|
||||
}
|
||||
if n > 0
|
||||
&& let Err(e) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
|
||||
{
|
||||
for (a, b) in moved.iter().rev() {
|
||||
let _ = move_file(b, a);
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(e) = move_all(fs, &back) {
|
||||
return Err(EngineError::Other(format!("{err}; the renamed files could not all be moved back: {}", e.message)));
|
||||
}
|
||||
return Err(e);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// After a failed batch of moves for `op`: point the photos whose files stayed moved at
|
||||
/// their new paths (an undoable "partly" rename when `undoable`, otherwise a plain logged
|
||||
/// change), so the library matches the disk. Returns the message for the user.
|
||||
pub(crate) fn follow_stuck(&mut self, op: &Op, e: BatchMoveError, undoable: bool) -> String {
|
||||
let mut msg = e.message;
|
||||
if e.stuck.is_empty() {
|
||||
return msg;
|
||||
}
|
||||
let to: HashSet<&str> = e.stuck.iter().map(|m| m.1.as_str()).collect();
|
||||
let mut keep = Vec::new();
|
||||
set_file_ops_to(op, &to, &mut keep);
|
||||
let list: Vec<String> = e.stuck.iter().map(|(a, b)| format!("{a} → {b}")).collect();
|
||||
let n = e.stuck.len();
|
||||
let s = if n == 1 { "" } else { "s" };
|
||||
let fix = Op::Batch { ops: keep };
|
||||
let applied = if undoable {
|
||||
self.commit(&format!("Rename {n} Photo{s} (partly)"), fix)
|
||||
} else {
|
||||
match self.catalog.apply(fix.clone()) {
|
||||
Ok(_) => {
|
||||
self.pending_log.push(fix);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
match applied {
|
||||
Ok(()) => msg
|
||||
.push_str(&format!(". {n} file{s} could not be moved back and kept the new name; the library now points there: {}", list.join(", "))),
|
||||
Err(err) => msg.push_str(&format!(
|
||||
". {n} file{s} could not be moved back and the library could not follow ({err}); relink with Find Missing Photos: {}",
|
||||
list.join(", ")
|
||||
)),
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// File moves an undo/redo op implies: `SetFile` ops whose source path differs from the photo's
|
||||
/// current one.
|
||||
pub(crate) fn file_moves(&self, op: &Op) -> Vec<(String, String)> {
|
||||
@@ -479,26 +722,240 @@ impl Session {
|
||||
rec(self, op, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Move the files `moves` names; all or nothing.
|
||||
pub(crate) fn move_files(moves: &[(String, String)]) -> Result<()> {
|
||||
let mut done: Vec<&(String, String)> = Vec::new();
|
||||
for m in moves {
|
||||
if let Err(e) = move_file(&m.0, &m.1) {
|
||||
for (a, b) in done.iter().rev().map(|m| (&m.0, &m.1)) {
|
||||
let _ = move_file(b, a);
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't undo the rename: {e}")));
|
||||
}
|
||||
done.push(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or
|
||||
/// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail.
|
||||
pub(crate) struct FakeFs {
|
||||
pub ci: bool,
|
||||
pub files: RefCell<Vec<(String, Vec<u8>)>>,
|
||||
pub fail: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
pub fn new(ci: bool, files: &[(&str, &str)]) -> FakeFs {
|
||||
FakeFs {
|
||||
ci,
|
||||
files: RefCell::new(files.iter().map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())).collect()),
|
||||
fail: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn idx(&self, p: &Path) -> Option<usize> {
|
||||
let p = p.to_string_lossy();
|
||||
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
|
||||
}
|
||||
/// The listing: (exact path, contents), sorted.
|
||||
pub fn listing(&self) -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl MoveFs for FakeFs {
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.idx(p).is_some()
|
||||
}
|
||||
fn is_file(&self, p: &Path) -> bool {
|
||||
self.exists(p)
|
||||
}
|
||||
fn same_file(&self, a: &Path, b: &Path) -> bool {
|
||||
self.idx(a).is_some() && self.idx(a) == self.idx(b)
|
||||
}
|
||||
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
|
||||
return Err(std::io::Error::other("injected failure"));
|
||||
}
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
|
||||
Ok(())
|
||||
}
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
|
||||
}
|
||||
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
let c = self.files.borrow()[i].1.clone();
|
||||
self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
|
||||
Ok(())
|
||||
}
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
let i = self.idx(p).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
|
||||
self.files.borrow_mut().remove(i);
|
||||
Ok(())
|
||||
}
|
||||
fn stem_sibling(&self, sidecar: &Path) -> Option<String> {
|
||||
let stem = sidecar.with_extension("").to_string_lossy().to_string();
|
||||
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
|
||||
let p = Path::new(f);
|
||||
p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_photo(id: u64, path: &str) -> Photo {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
|
||||
}
|
||||
|
||||
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
|
||||
/// replaces a different file that differs only by case on a case-sensitive one.
|
||||
#[test]
|
||||
fn case_only_renames_never_replace_another_file() {
|
||||
// case-sensitive: IMG_1.JPG and img_1.JPG are two photos
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.JPG", "first"), ("/p/img_1.JPG", "second"), ("/p/IMG_1.xmp", "sc1"), ("/p/img_1.xmp", "sc2")]);
|
||||
assert!(move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap_err().message.contains("already exists"));
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/img_1.JPG".into(), "second".into())));
|
||||
// the planner picks a free name instead
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1-1.JPG");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", plans[0].to_path.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/img_1-1.JPG".into(), "first".into()),
|
||||
("/p/img_1-1.xmp".into(), "sc1".into()),
|
||||
("/p/img_1.JPG".into(), "second".into()),
|
||||
("/p/img_1.xmp".into(), "sc2".into())
|
||||
]
|
||||
);
|
||||
// case-sensitive, target free: a plain rename
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_2.JPG", "x")]);
|
||||
move_file_with(&fs, "/p/IMG_2.JPG", "/p/img_2.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_2.JPG".into(), "x".into())]);
|
||||
|
||||
// case-insensitive: the "existing" target is the photo itself → renamed via a temp name
|
||||
let fs = FakeFs::new(true, &[("/p/IMG_1.JPG", "first"), ("/p/IMG_1.xmp", "sc1")]);
|
||||
let mut s = Session::new();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(1, "/p/IMG_1.JPG")) }).unwrap();
|
||||
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "img_1", 1);
|
||||
assert_eq!(plans[0].to, "img_1.JPG", "not a collision with itself");
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/img_1.JPG").unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/p/img_1.JPG".into(), "first".into()), ("/p/img_1.xmp".into(), "sc1".into())]);
|
||||
// case-insensitive, another file under another name: still a collision
|
||||
let fs = FakeFs::new(true, &[("/p/A.JPG", "a"), ("/p/b.JPG", "b")]);
|
||||
assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err());
|
||||
}
|
||||
|
||||
/// Issue #105: a rollback that fails is reported, and the library follows the files that kept
|
||||
/// their new names (an undoable partial rename), so nothing goes missing.
|
||||
#[test]
|
||||
fn failed_rollbacks_are_reported_and_followed() {
|
||||
let fs = FakeFs::new(false, &[("/p/a.jpg", "A"), ("/p/a.xmp", "As"), ("/p/b.jpg", "B"), ("/p/c.jpg", "C")]);
|
||||
let mut s = Session::new();
|
||||
for (i, f) in ["/p/a.jpg", "/p/b.jpg", "/p/c.jpg"].iter().enumerate() {
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(i as u64 + 1, f)) }).unwrap();
|
||||
}
|
||||
let ids = [PhotoId(1), PhotoId(2), PhotoId(3)];
|
||||
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
|
||||
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
|
||||
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
|
||||
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
|
||||
assert!(err.contains("rename /p/c.jpg"), "{err}");
|
||||
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
|
||||
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/Trip-1.jpg".into(), "A".into()),
|
||||
("/p/Trip-1.xmp".into(), "As".into()),
|
||||
("/p/b.jpg".into(), "B".into()),
|
||||
("/p/c.jpg".into(), "C".into())
|
||||
]
|
||||
);
|
||||
// the catalog matches the disk
|
||||
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::Demo { .. } => String::new(),
|
||||
};
|
||||
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
|
||||
assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().file_name, "Trip-1.jpg");
|
||||
assert_eq!((path(&s, 2), path(&s, 3)), ("/p/b.jpg".into(), "/p/c.jpg".into()));
|
||||
assert_eq!(s.undo.last().map(|e| e.label.as_str()), Some("Rename 1 Photo (partly)"));
|
||||
|
||||
// a sidecar that won't move, and a file that won't go back: the file keeps its new name
|
||||
// with the sidecars that did move, and says so
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().extend(["d.jpg.xmp".to_string(), "/p/e.jpg".to_string()]);
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(e.moved, "{e}");
|
||||
assert!(e.message.contains("it is now /p/e.jpg with its sidecar /p/e.xmp"), "{e}");
|
||||
assert_eq!(fs.listing(), vec![("/p/d.jpg.xmp".into(), "Df".into()), ("/p/e.jpg".into(), "D".into()), ("/p/e.xmp".into(), "Ds".into())]);
|
||||
// … and when the file does go back, its sidecars follow it
|
||||
let fs = FakeFs::new(false, &[("/p/d.jpg", "D"), ("/p/d.xmp", "Ds"), ("/p/d.jpg.xmp", "Df")]);
|
||||
fs.fail.borrow_mut().push("d.jpg.xmp".to_string());
|
||||
let e = move_file_with(&fs, "/p/d.jpg", "/p/e.jpg").unwrap_err();
|
||||
assert!(!e.moved);
|
||||
assert_eq!(fs.listing().len(), 3);
|
||||
assert!(fs.listing().iter().all(|(p, _)| p.starts_with("/p/d.")));
|
||||
}
|
||||
|
||||
/// Issue #92 (comment): renaming one of `IMG_1.CR3` + `IMG_1.JPG` copies their shared
|
||||
/// `IMG_1.xmp` instead of taking it away from the other; the last one renamed takes it.
|
||||
#[test]
|
||||
fn shared_stem_sidecar_is_copied_not_taken() {
|
||||
let fs = FakeFs::new(false, &[("/p/IMG_1.CR3", "raw"), ("/p/IMG_1.JPG", "jpg"), ("/p/IMG_1.xmp", "shared"), ("/p/IMG_1.CR3.xmp", "own")]);
|
||||
move_file_with(&fs, "/p/IMG_1.CR3", "/p/Trip_1.CR3").unwrap();
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![
|
||||
("/p/IMG_1.JPG".into(), "jpg".into()),
|
||||
("/p/IMG_1.xmp".into(), "shared".into()),
|
||||
("/p/Trip_1.CR3".into(), "raw".into()),
|
||||
("/p/Trip_1.CR3.xmp".into(), "own".into()),
|
||||
("/p/Trip_1.xmp".into(), "shared".into()),
|
||||
]
|
||||
);
|
||||
// the JPEG is the last one using it: it moves
|
||||
move_file_with(&fs, "/p/IMG_1.JPG", "/p/Trip_2.JPG").unwrap();
|
||||
assert!(fs.listing().contains(&("/p/Trip_2.xmp".into(), "shared".into())));
|
||||
assert!(!fs.exists(Path::new("/p/IMG_1.xmp")));
|
||||
// a failure after the copy removes the copy and keeps the original
|
||||
let fs = FakeFs::new(false, &[("/p/A.CR3", "raw"), ("/p/A.JPG", "jpg"), ("/p/A.xmp", "shared"), ("/p/A.CR3.xmp", "own")]);
|
||||
fs.fail.borrow_mut().push("A.CR3.xmp".into());
|
||||
assert!(move_file_with(&fs, "/p/A.CR3", "/p/B.CR3").is_err());
|
||||
assert_eq!(fs.listing().len(), 4);
|
||||
assert!(fs.listing().contains(&("/p/A.xmp".into(), "shared".into())) && !fs.exists(Path::new("/p/B.xmp")));
|
||||
}
|
||||
|
||||
/// Issue #95 on the real disk, whichever kind of volume the temp folder is on.
|
||||
#[test]
|
||||
fn case_only_rename_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-rename-case-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (upper, lower) = (dir.join("IMG_1.JPG"), dir.join("img_1.JPG"));
|
||||
std::fs::write(&upper, b"first").unwrap();
|
||||
let case_sensitive = !lower.exists();
|
||||
let p = |q: &Path| q.to_string_lossy().to_string();
|
||||
if case_sensitive {
|
||||
std::fs::write(&lower, b"second").unwrap();
|
||||
assert!(move_file(&p(&upper), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"second");
|
||||
assert_eq!(std::fs::read(&upper).unwrap(), b"first");
|
||||
} else {
|
||||
move_file(&p(&upper), &p(&lower)).unwrap();
|
||||
let names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(names, vec!["img_1.JPG".to_string()]);
|
||||
assert_eq!(std::fs::read(&lower).unwrap(), b"first");
|
||||
}
|
||||
// a plain rename never replaces
|
||||
std::fs::write(dir.join("other.JPG"), b"other").unwrap();
|
||||
assert!(move_file(&p(&dir.join("other.JPG")), &p(&lower)).is_err());
|
||||
assert_eq!(std::fs::read(dir.join("other.JPG")).unwrap(), b"other");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_templates_stay_inside_the_destination() {
|
||||
|
||||
@@ -5,12 +5,25 @@
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
//! everything else — another editor's `crs:` develop settings, `xmpMM` history, unknown
|
||||
//! namespaces — is kept byte for byte. A sidecar that can't be read as XMP is first copied to
|
||||
//! `<name>.xmp.bak-<time>` (never overwritten), then replaced. Writes are atomic (temp file,
|
||||
//! fsync, rename).
|
||||
//!
|
||||
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
|
||||
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
|
||||
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -258,11 +271,101 @@ 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 + sync + rename).
|
||||
/// The properties LightCraft writes and owns in a sidecar: replaced on every save (removed when
|
||||
/// LightCraft has no value, so clearing a field clears it in the file too). All of them are read
|
||||
/// back on import and by Read Metadata from File.
|
||||
pub const OWNED: &[&str] = &[
|
||||
"xmp:Rating",
|
||||
"xmp:Label",
|
||||
"dc:title",
|
||||
"dc:description",
|
||||
"dc:rights",
|
||||
"dc:creator",
|
||||
"dc:subject",
|
||||
"Iptc4xmpCore:Location",
|
||||
"Iptc4xmpCore:AltTextAccessibility",
|
||||
"Iptc4xmpCore:ExtDescrAccessibility",
|
||||
"photoshop:City",
|
||||
"photoshop:State",
|
||||
"photoshop:Country",
|
||||
"xmpRights:Marked",
|
||||
"xmpRights:UsageTerms",
|
||||
"xmpRights:WebStatement",
|
||||
"lc:*",
|
||||
];
|
||||
|
||||
/// Properties replaced only when LightCraft has a value (capture time; GPS, which LightCraft
|
||||
/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else LightCraft
|
||||
/// writes (`xmp:CreatorTool`) goes only into new sidecars.
|
||||
pub const OWNED_IF_STATED: &[&str] = &["exif:DateTimeOriginal", "photoshop:DateCreated", "exif:GPSLatitude", "exif:GPSLongitude"];
|
||||
|
||||
/// Write `data` to `path` atomically: a temp file next to it, synced, then renamed over it.
|
||||
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
lightcraft_catalog::safe_file::write_atomic(path, data)
|
||||
}
|
||||
|
||||
/// Copy `path` to a new `<name>.bak-<stamp>` (`-1`, `-2`… if taken; never overwriting).
|
||||
fn backup(path: &Path, stamp: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(path)?;
|
||||
let stamp: String = stamp.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for k in 0..1000 {
|
||||
let b = path.with_file_name(if k == 0 { format!("{name}.bak-{stamp}") } else { format!("{name}.bak-{stamp}-{k}") });
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&b) {
|
||||
Ok(mut f) => {
|
||||
f.write_all(&bytes)?;
|
||||
f.sync_all()?;
|
||||
return Ok(b);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::other("no free backup name"))
|
||||
}
|
||||
|
||||
/// What [`Session::save_sidecar`] did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SidecarSaved {
|
||||
pub path: PathBuf,
|
||||
/// An existing sidecar was merged into (its other contents kept).
|
||||
pub merged: bool,
|
||||
/// An existing sidecar that couldn't be read as XMP was copied here before being replaced.
|
||||
pub backup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The new contents for the sidecar at `path`: `fresh` merged into the existing file, if any.
|
||||
/// Returns (contents, merged, backup).
|
||||
fn sidecar_contents(path: &Path, fresh: String, stamp: &str) -> std::io::Result<(String, bool, Option<PathBuf>)> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((fresh, false, None)),
|
||||
// can't see what is there: never overwrite it blindly
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok((fresh, false, None));
|
||||
}
|
||||
let rules = lightcraft_meta::MergeRules { owned: OWNED, owned_if_present: OWNED_IF_STATED };
|
||||
match std::str::from_utf8(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|old| lightcraft_meta::merge_xmp(old, &fresh, rules).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(merged) => Ok((merged, true, None)),
|
||||
Err(why) => {
|
||||
let b = backup(path, stamp)?;
|
||||
log::warn!("{}: not readable as XMP ({why}); kept a copy as {} before replacing it", path.display(), b.display());
|
||||
Ok((fresh, false, Some(b)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the stem sidecar a file maps to (case-insensitive).
|
||||
fn stem_key(original: &str) -> String {
|
||||
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
|
||||
}
|
||||
|
||||
fn file_path(p: &Photo) -> Option<&str> {
|
||||
match &p.source {
|
||||
Source::File { path } => Some(path),
|
||||
@@ -298,24 +401,70 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which photo owns each stem sidecar that several catalogued files map to (see
|
||||
/// [`Session::sidecar_naming`]).
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
|
||||
impl StemOwners {
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
(e.0, e.1) = (rank, p.id);
|
||||
}
|
||||
}
|
||||
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
|
||||
}
|
||||
|
||||
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
|
||||
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
|
||||
match (naming, file_path(p)) {
|
||||
(SidecarNaming::Stem, Some(path)) if self.0.get(&stem_key(path)).is_some_and(|owner| *owner != p.id) => SidecarNaming::Full,
|
||||
_ => naming,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Write the photo's XMP sidecar; returns its path.
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
|
||||
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
|
||||
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
|
||||
/// sidecar — a raw first, else the first by file name. The others use Full naming
|
||||
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
|
||||
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
|
||||
match self.catalog.photo(id) {
|
||||
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
|
||||
_ => self.xmp.naming,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
|
||||
pub fn save_sidecar(&self, id: PhotoId) -> Result<SidecarSaved> {
|
||||
self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
|
||||
}
|
||||
|
||||
pub(crate) fn save_sidecar_with(&self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
if p.copy_of.is_some() {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
let path = sidecar_path(orig, self.xmp.naming);
|
||||
write_atomic(&path, sidecar_packet(p, &self.catalog).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
let path = sidecar_path(orig, owners.naming(p, self.xmp.naming));
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
|
||||
write_atomic(&path, data.as_bytes()).map_err(err)?;
|
||||
Ok(SidecarSaved { path, merged, backup })
|
||||
}
|
||||
|
||||
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
|
||||
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
@@ -340,9 +489,13 @@ impl Session {
|
||||
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
|
||||
let mut ids = Vec::new();
|
||||
ops.iter().for_each(|o| op_photos(o, &mut ids));
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = StemOwners::of(&self.catalog);
|
||||
for id in ids {
|
||||
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
|
||||
&& let Err(e) = self.save_sidecar(id)
|
||||
&& let Err(e) = self.save_sidecar_with(id, &owners)
|
||||
{
|
||||
log::warn!("auto-write XMP: {e}");
|
||||
}
|
||||
|
||||
@@ -130,11 +130,16 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
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<String>) {
|
||||
(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::<serde_json::Value>(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<Arc<Rgb32f>, 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);
|
||||
|
||||
@@ -144,6 +144,45 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let dir = temp_dir("missing-content");
|
||||
std::fs::create_dir_all(dir.join("old")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
|
||||
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
|
||||
write_png(&dir.join("old").join(n), seed);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
||||
// an impostor: same name and size, other bytes
|
||||
let impostor = |src: &Path, dst: &Path| {
|
||||
let mut b = std::fs::read(src).unwrap();
|
||||
let k = b.len() - 20;
|
||||
b[k] ^= 0xff;
|
||||
std::fs::write(dst, b).unwrap();
|
||||
};
|
||||
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
|
||||
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
|
||||
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
|
||||
std::fs::remove_file(dir.join("old/c.png")).unwrap();
|
||||
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
||||
let found = r["found"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
let to = |name: &str| {
|
||||
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
|
||||
};
|
||||
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
|
||||
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
|
||||
assert_eq!(r["missing"], 1);
|
||||
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_are_found_and_relinked() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
||||
@@ -352,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]
|
||||
@@ -441,6 +518,70 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Rename / Move Folder are undo steps (issue #97): undo renames the folder back on disk (files and
|
||||
/// sidecars along) and restores the photos' paths, redo repeats it; a destination taken in the
|
||||
/// meantime is never overwritten — the step is refused, reported and stays on the stack.
|
||||
#[test]
|
||||
fn folder_rename_and_move_undo_and_redo_on_disk() {
|
||||
let root = temp_dir("folders-undo");
|
||||
let a = root.join("Trip");
|
||||
write_png(&a.join("one.png"), 3);
|
||||
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let b = root.join("Italy");
|
||||
s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy"})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo0 + 1, "one undo step");
|
||||
assert!(s.undo.last().unwrap().label.contains("Rename Folder"));
|
||||
assert!(b.join("one.png").exists() && !a.exists());
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(a.join("one.png").exists() && a.join("one.xmp").exists() && !b.exists(), "renamed back on disk");
|
||||
assert_eq!(path(&s), a.join("one.png").to_string_lossy());
|
||||
s.media.forget(id);
|
||||
assert!(s.render_now(id, 16, 16).is_ok());
|
||||
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
|
||||
// the old name is taken now: undo is refused, nothing is overwritten, the step stays
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::write(a.join("keep.txt"), "mine").unwrap();
|
||||
let undo_n = s.undo.len();
|
||||
let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert_eq!(s.undo.len(), undo_n, "the step stays on the stack");
|
||||
assert_eq!(std::fs::read_to_string(a.join("keep.txt")).unwrap(), "mine");
|
||||
assert!(b.join("one.png").exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
std::fs::remove_dir_all(&a).unwrap();
|
||||
|
||||
// move into another folder, undo, redo
|
||||
let into = root.join("Archive");
|
||||
s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": into.to_string_lossy()})).unwrap();
|
||||
let moved = into.join("Italy");
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && !moved.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
// the redo target is taken (even by an empty folder): refused, nothing replaced
|
||||
std::fs::create_dir_all(&moved).unwrap();
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
assert!(b.join("one.png").exists());
|
||||
std::fs::remove_dir(&moved).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(moved.join("one.png").exists() && moved.join("one.xmp").exists() && !b.exists());
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
|
||||
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
|
||||
#[test]
|
||||
@@ -590,6 +731,7 @@ fn smart_previews_folder_is_chosen_per_library() {
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
|
||||
|
||||
// the setting is saved with the library
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
|
||||
|
||||
@@ -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::<String>::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]
|
||||
|
||||
@@ -113,6 +113,7 @@ fn close_writes_snapshot_and_view_state() {
|
||||
let expect = s.catalog.to_snapshot();
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
|
||||
drop(s); // one session per library (issue #99)
|
||||
|
||||
let s2 = open(&dir, true);
|
||||
let r = &s2.library.as_ref().unwrap().report;
|
||||
@@ -178,6 +179,7 @@ fn new_library_without_seed_is_empty_and_compacts() {
|
||||
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
|
||||
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
|
||||
drop(s); // one session per library (issue #99)
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||
assert_eq!(s2.catalog.albums().count(), 1);
|
||||
@@ -376,3 +378,32 @@ fn scale_100k_library_queries() {
|
||||
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
eprintln!("slowest: {:?}", &worst[..3]);
|
||||
}
|
||||
|
||||
/// Issue #99: a library is open in one session at a time. A second opener is refused (nothing
|
||||
/// read or written), the owning session can reopen it, and it is free again once closed.
|
||||
#[test]
|
||||
fn a_library_open_elsewhere_is_refused() {
|
||||
let dir = temp_dir("locked");
|
||||
let mut s = open(&dir, true);
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
let log = std::fs::read(dir.join("catalog.log")).unwrap();
|
||||
|
||||
let mut other = Session::new();
|
||||
let e = other.open_library(&dir, true).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::LibraryInUse(_)), "{e:?}");
|
||||
assert!(e.to_string().contains("already open in"), "{e}");
|
||||
assert!(other.library.is_none());
|
||||
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), log, "the refused opener wrote nothing");
|
||||
|
||||
// the session that has it open may reopen it (Settings → Open Library on the same folder)
|
||||
s.close_library().unwrap();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
assert!(other.open_library(&dir, true).is_err(), "still locked after reopening");
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let expect = s.catalog.to_snapshot();
|
||||
|
||||
drop(s);
|
||||
other.open_library(&dir, true).unwrap();
|
||||
assert_eq!(other.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ struct Flaky {
|
||||
fail: Arc<AtomicBool>,
|
||||
/// Only snapshots fail (appends work).
|
||||
snapshots_only: bool,
|
||||
/// Only appends fail (a dead log handle; fresh files can still be written).
|
||||
appends_only: bool,
|
||||
}
|
||||
|
||||
impl Flaky {
|
||||
@@ -39,7 +41,7 @@ impl Store for Flaky {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.failing() {
|
||||
if self.failing() && !self.appends_only {
|
||||
return Err(std::io::Error::other("disk full"));
|
||||
}
|
||||
self.files.write_atomic(name, data)
|
||||
@@ -145,3 +147,47 @@ fn failed_snapshot_does_not_fail_the_command() {
|
||||
store.set_failing(false);
|
||||
assert_eq!(open(&store, false).catalog.albums().count(), 2);
|
||||
}
|
||||
|
||||
/// Issue #101: quitting while the log can't be appended to still saves the queued changes in the
|
||||
/// closing snapshot (a fresh file), and reports success; they are not appended again later.
|
||||
#[test]
|
||||
fn close_saves_queued_changes_in_the_snapshot_when_appends_fail() {
|
||||
let store = Flaky { appends_only: true, ..Default::default() };
|
||||
let mut s = open(&store, true);
|
||||
let id = s.selection.active.unwrap();
|
||||
store.set_failing(true);
|
||||
assert!(matches!(s.execute("photo.rate", &json!({"rating": 4})), Err(EngineError::NotSaved(_))));
|
||||
assert!(matches!(s.execute("photo.flag", &json!({"flag": "pick"})), Err(EngineError::NotSaved(_))));
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(2));
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
assert!(s.pending_log.is_empty());
|
||||
let expect = s.catalog.to_snapshot();
|
||||
drop(s);
|
||||
store.set_failing(false);
|
||||
let mut s2 = open(&store, true);
|
||||
assert_eq!(s2.catalog.to_snapshot(), expect);
|
||||
let p = s2.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.rating, p.flag), (4, Flag::Pick));
|
||||
// later edits continue after the absorbed ops
|
||||
s2.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
let expect = s2.catalog.to_snapshot();
|
||||
drop(s2);
|
||||
assert_eq!(open(&store, true).catalog.to_snapshot(), expect);
|
||||
}
|
||||
|
||||
/// When neither the log nor the snapshot can be written, closing fails with `NotSaved` and the
|
||||
/// changes stay queued (a later retry can still save them).
|
||||
#[test]
|
||||
fn close_fails_when_nothing_can_be_saved() {
|
||||
let store = Flaky::default();
|
||||
let mut s = open(&store, true);
|
||||
store.set_failing(true);
|
||||
assert!(s.execute("photo.rate", &json!({"rating": 4})).is_err());
|
||||
let e = s.close_library().unwrap_err();
|
||||
assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}");
|
||||
assert_eq!(s.unsaved().map(|u| u.0), Some(1));
|
||||
store.set_failing(false);
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
}
|
||||
@@ -126,11 +126,13 @@ fn preferences_persist_with_the_library() {
|
||||
assert_eq!(r["persistent"], true);
|
||||
// export presets are library preferences too
|
||||
s.execute("export.savePreset", &json!({"name": "Proof", "params": {"format": "jpeg", "percent": 25}})).unwrap();
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.export_params(&json!({"preset": "Proof"})).unwrap(), json!({"format": "jpeg", "percent": 25}));
|
||||
// older prefs.json files (snake_case `last_export`, no import section) still load
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_export, Some(json!({"format": "png"})));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Issue #103: a damaged or unreadable settings file (prefs.json, presets.json, view.json) is
|
||||
//! never silently replaced by the defaults: a damaged one is kept aside, an unreadable one is not
|
||||
//! written this session, and the user is told.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::library::LibraryStores;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-settings-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_prefs_are_kept_aside_and_reported() {
|
||||
let lib = temp_dir("damaged");
|
||||
let bad = br#"{"exportPresets": [{"name": "Proof", "par"#;
|
||||
std::fs::write(lib.join("prefs.json"), bad).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let kept: Vec<_> = std::fs::read_dir(&lib)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.starts_with("prefs.json.corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "{kept:?}");
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("prefs.json is damaged") && warnings[0].contains(&kept[0]), "{warnings:?}");
|
||||
assert!(s.take_library_warnings().is_empty(), "each warning is handed out once");
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["settingsWarnings"].as_array().map(Vec::len), Some(1), "{info}");
|
||||
// saving goes on (the damaged original is safe in its copy)
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
assert!(String::from_utf8(std::fs::read(lib.join("prefs.json")).unwrap()).unwrap().contains("300"));
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// `presets.json` and `prefs.json` can't be read (locked by another program, an I/O error).
|
||||
#[derive(Clone, Default)]
|
||||
struct Unreadable {
|
||||
files: MemStore,
|
||||
unreadable: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Store for Unreadable {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
if self.unreadable.lock().unwrap().iter().any(|n| n == name) {
|
||||
return Err(std::io::Error::other("locked by another process"));
|
||||
}
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"unreadable".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_settings_are_not_overwritten() {
|
||||
let files = Unreadable::default();
|
||||
let presets = br#"{"user": [{"id": "user.mine", "name": "Mine", "group": "User Presets", "settings": {}}]}"#.to_vec();
|
||||
let prefs = br#"{"cacheMb": 777}"#.to_vec();
|
||||
files.files.set("presets.json", presets.clone());
|
||||
files.files.set("prefs.json", prefs.clone());
|
||||
*files.unreadable.lock().unwrap() = vec!["presets.json".into(), "prefs.json".into()];
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "unreadable".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.iter().all(|w| w.contains("couldn't be read") && w.contains("won't overwrite")), "{warnings:?}");
|
||||
|
||||
// a preset change and a preference change: neither file is replaced by defaults
|
||||
s.execute("preset.create", &json!({"name": "New"})).unwrap();
|
||||
let e = s.execute("library.preferences", &json!({"cacheMb": 300}));
|
||||
assert!(e.is_err(), "saving preferences is refused, not done over the unread file");
|
||||
assert_eq!(files.files.get("presets.json").unwrap(), presets);
|
||||
assert_eq!(files.files.get("prefs.json").unwrap(), prefs);
|
||||
}
|
||||
@@ -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<String> = 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]
|
||||
@@ -576,3 +625,132 @@ fn copyright_status_usage_terms_and_url_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// A sidecar another raw developer wrote: develop settings in attributes and elements, a
|
||||
/// structured edit history, an unknown namespace.
|
||||
const OTHER_APP_SIDECAR: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Other Toolkit">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
|
||||
xmlns:stEvt="http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"
|
||||
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmlns:other="http://example.com/other/1.0/"
|
||||
xmp:Rating="2"
|
||||
crs:Version="99.0"
|
||||
crs:Exposure2012="+0.65"
|
||||
crs:HasSettings="True"
|
||||
other:Secret="keep me">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li stEvt:action="derived" stEvt:parameters="converted from image/x-raw to image/png"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>128, 140</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
/// Issue #92: saving merges into another app's sidecar instead of replacing it.
|
||||
#[test]
|
||||
fn saving_merges_into_another_apps_sidecar() {
|
||||
let src = temp_dir("merge");
|
||||
let lib = temp_dir("merge-lib");
|
||||
write_png(&src.join("shot.png"), 1);
|
||||
std::fs::write(src.join("shot.xmp"), OTHER_APP_SIDECAR).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("shot.png").to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Harbour", "keywords": ["boats"]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert_eq!(r["merged"][0], src.join("shot.xmp").to_string_lossy().as_ref(), "{r}");
|
||||
let out = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
// the other app's data, byte for byte
|
||||
for part in [
|
||||
"crs:Exposure2012=\"+0.65\"",
|
||||
"other:Secret=\"keep me\"",
|
||||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"derived\" stEvt:parameters=\"converted from image/x-raw to image/png\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>\n <rdf:li>128, 140</rdf:li>",
|
||||
] {
|
||||
assert!(out.contains(part), "lost {part:?}:\n{out}");
|
||||
}
|
||||
let d = lightcraft_meta::parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.metadata.rating, Some(5), "ours replaces theirs");
|
||||
assert_eq!(d.metadata.title.as_deref(), Some("Harbour"));
|
||||
assert_eq!(d.properties.get("crs:Version"), Some(&vec!["99.0".to_string()]));
|
||||
let saved = s.catalog.photo(id).unwrap().develop.clone();
|
||||
// reading it back restores our settings (lc:settings wins over crs:)
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().develop, saved);
|
||||
// saving again keeps one LightCraft description and the other app's data
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let again = std::fs::read_to_string(src.join("shot.xmp")).unwrap();
|
||||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||||
assert!(again.contains("other:Secret=\"keep me\"") && again.contains("crs:Exposure2012=\"+0.65\""));
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&again).unwrap().metadata.rating, Some(3));
|
||||
|
||||
// a sidecar that isn't XMP is kept as a backup before it is replaced
|
||||
std::fs::write(src.join("shot.xmp"), b"<not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let backup = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert!(backup.contains("shot.xmp.bak-"), "{r}");
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join("shot.xmp")).unwrap()).unwrap().metadata.rating, Some(3));
|
||||
// … and another one never overwrites the first backup
|
||||
std::fs::write(src.join("shot.xmp"), b"<still not xmp").unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
let second = r["backups"][0]["backup"].as_str().unwrap().to_string();
|
||||
assert_ne!(second, backup);
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"<not xmp");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), b"<still not xmp");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #92: two catalogued files with one stem (`IMG_1.jpg` + `IMG_1.png`) don't share a
|
||||
/// Stem-named sidecar: the first by name keeps `IMG_1.xmp`, the other writes and reads
|
||||
/// `IMG_1.png.xmp`.
|
||||
#[test]
|
||||
fn files_sharing_a_stem_get_their_own_sidecars() {
|
||||
let src = temp_dir("stem");
|
||||
let lib = temp_dir("stem-lib");
|
||||
write_png(&src.join("IMG_1.png"), 1);
|
||||
let img = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 7, 255]);
|
||||
let jpg =
|
||||
crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Jpeg, ..Default::default() }).unwrap();
|
||||
std::fs::write(src.join("IMG_1.jpg"), jpg).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (j, p) = (id(&s, "IMG_1.jpg"), id(&s, "IMG_1.png"));
|
||||
assert_eq!(s.sidecar_naming(j), crate::sidecar::SidecarNaming::Stem);
|
||||
assert_eq!(s.sidecar_naming(p), crate::sidecar::SidecarNaming::Full);
|
||||
s.execute("photo.rate", &json!({"ids": [j.0], "rating": 2})).unwrap();
|
||||
s.execute("photo.rate", &json!({"ids": [p.0], "rating": 5})).unwrap();
|
||||
let r = s.execute("photo.saveMetadataToFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!(r["written"].as_array().unwrap().len(), 2, "{r}");
|
||||
let rating = |f: &str| lightcraft_meta::parse_xmp(&std::fs::read_to_string(src.join(f)).unwrap()).unwrap().metadata.rating;
|
||||
assert_eq!(rating("IMG_1.xmp"), Some(2));
|
||||
assert_eq!(rating("IMG_1.png.xmp"), Some(5));
|
||||
// each reads its own back
|
||||
s.execute("photo.rate", &json!({"ids": [j.0, p.0], "rating": 0})).unwrap();
|
||||
s.execute("photo.readMetadataFromFile", &json!({"ids": [j.0, p.0]})).unwrap();
|
||||
assert_eq!((s.catalog.photo(j).unwrap().rating, s.catalog.photo(p).unwrap().rating), (2, 5));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod gpx;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
mod xmp;
|
||||
mod xmp_merge;
|
||||
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
@@ -26,6 +27,7 @@ pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use tags::{TagRow, file_tag_rows, tag_rows};
|
||||
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
|
||||
pub use xmp_merge::{MergeRules, merge_xmp};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
@@ -145,6 +145,11 @@ impl Node {
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_NODES: usize = 200_000;
|
||||
|
||||
/// `prefix:local` with the prefix canonicalised by namespace URI (see [`NAMESPACES`]).
|
||||
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>]) -> String {
|
||||
canonical(qname, scopes)
|
||||
}
|
||||
|
||||
fn canonical(qname: &str, scopes: &[Vec<(String, String)>]) -> String {
|
||||
let (prefix, local) = qname.split_once(':').unwrap_or(("", qname));
|
||||
if prefix == "xml" || prefix == "xmlns" {
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Merging a freshly written XMP packet into an existing one (a sidecar another application
|
||||
//! wrote, e.g. with its develop settings and edit history), keeping everything the writer does not
|
||||
//! own byte for byte.
|
||||
//!
|
||||
//! The existing packet is scanned once (namespace-resolved, with byte positions). Only top-level
|
||||
//! properties of the `rdf:Description` elements directly under `rdf:RDF` are considered, in
|
||||
//! attribute form (`xmp:Rating="3"`) and element form (`<dc:subject>…</dc:subject>`):
|
||||
//! - properties the writer owns ([`MergeRules::owned`]) are removed, and written from the fresh
|
||||
//! packet when it has them (so a cleared field is cleared in the file too);
|
||||
//! - properties owned only when stated ([`MergeRules::owned_if_present`]) are replaced when the
|
||||
//! fresh packet has them and kept otherwise;
|
||||
//! - any other property of the fresh packet (e.g. `xmp:CreatorTool`) is not written: the file
|
||||
//! keeps describing itself as its creator left it;
|
||||
//! - everything else — other namespaces (e.g. `crs:`), `xmpMM:History`, unknown properties,
|
||||
//! comments, the packet wrapper and padding — is copied unchanged.
|
||||
//!
|
||||
//! The written properties go into one new `rdf:Description` (declaring its own namespaces, so
|
||||
//! the existing prefix bindings never matter) just before `</rdf:RDF>`. A description left empty
|
||||
//! by the removals is dropped, so repeated saves don't accumulate. The result is checked by
|
||||
//! parsing it again: every written property must read back as in the fresh packet.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::xmp::{XmpError, canonical_name, parse_xmp};
|
||||
|
||||
/// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical
|
||||
/// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct MergeRules<'a> {
|
||||
/// Always replaced: removed from the existing packet, written from the fresh one if stated.
|
||||
pub owned: &'a [&'a str],
|
||||
/// Replaced only when the fresh packet states them; otherwise the existing value stays.
|
||||
pub owned_if_present: &'a [&'a str],
|
||||
}
|
||||
|
||||
fn listed(list: &[&str], name: &str) -> bool {
|
||||
list.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(ns) => name.starts_with(ns),
|
||||
None => *p == name,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Attr {
|
||||
raw: String,
|
||||
name: String,
|
||||
/// The value as written (still escaped).
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Prop {
|
||||
name: String,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Desc {
|
||||
tag_start: usize,
|
||||
tag_end: usize,
|
||||
/// The element name as written (`rdf:Description`, or another prefix for the RDF namespace).
|
||||
raw_name: String,
|
||||
empty: bool,
|
||||
attrs: Vec<Attr>,
|
||||
props: Vec<Prop>,
|
||||
/// The end of `</rdf:Description>` (`tag_end` when self-closing).
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Scan {
|
||||
descs: Vec<Desc>,
|
||||
/// Where the first `</rdf:RDF>` starts.
|
||||
rdf_close: Option<usize>,
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Other,
|
||||
Rdf,
|
||||
Desc(usize),
|
||||
Prop { desc: usize, start: usize, name: String },
|
||||
}
|
||||
|
||||
fn is_meta_attr(name: &str) -> bool {
|
||||
name.starts_with("xmlns")
|
||||
|| matches!(name, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype")
|
||||
}
|
||||
|
||||
fn pos(r: &quick_xml::Reader<&[u8]>) -> usize {
|
||||
usize::try_from(r.buffer_position()).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Locate the descriptions, their top-level properties and `</rdf:RDF>` in a packet.
|
||||
fn scan(s: &str) -> Result<Scan, XmpError> {
|
||||
let mut reader = quick_xml::Reader::from_reader(s.as_bytes());
|
||||
reader.config_mut().check_end_names = true;
|
||||
let mut out = Scan::default();
|
||||
let mut stack: Vec<Kind> = Vec::new();
|
||||
let mut scopes: Vec<Vec<(String, String)>> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]];
|
||||
let mut count = 0usize;
|
||||
loop {
|
||||
let before = pos(&reader);
|
||||
let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?;
|
||||
let after = pos(&reader);
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
count += 1;
|
||||
if count > 200_000 || stack.len() > 64 {
|
||||
return Err(XmpError::TooComplex);
|
||||
}
|
||||
let empty = matches!(ev, Event::Empty(_));
|
||||
let mut scope = Vec::new();
|
||||
let mut raw_attrs = Vec::new();
|
||||
for a in e.attributes() {
|
||||
let a = a.map_err(|e| XmpError::Xml(e.to_string()))?;
|
||||
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
||||
let v = String::from_utf8_lossy(&a.value).into_owned();
|
||||
if k == "xmlns" {
|
||||
scope.push((String::new(), v.clone()));
|
||||
} else if let Some(p) = k.strip_prefix("xmlns:") {
|
||||
scope.push((p.to_string(), v.clone()));
|
||||
}
|
||||
raw_attrs.push((k, v));
|
||||
}
|
||||
scopes.push(scope);
|
||||
let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = canonical_name(&raw_name, &scopes);
|
||||
let kind = match stack.last() {
|
||||
Some(Kind::Rdf) if name == "rdf:Description" => {
|
||||
let attrs = raw_attrs.into_iter().map(|(raw, value)| Attr { name: canonical_name(&raw, &scopes), raw, value }).collect();
|
||||
out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after });
|
||||
Kind::Desc(out.descs.len() - 1)
|
||||
}
|
||||
Some(Kind::Desc(i)) => Kind::Prop { desc: *i, start: before, name },
|
||||
_ if name == "rdf:RDF" && out.rdf_close.is_none() => Kind::Rdf,
|
||||
_ => Kind::Other,
|
||||
};
|
||||
if empty {
|
||||
scopes.pop();
|
||||
if let Kind::Prop { desc, start, name } = kind
|
||||
&& let Some(d) = out.descs.get_mut(desc)
|
||||
{
|
||||
d.props.push(Prop { name, start, end: after });
|
||||
}
|
||||
} else {
|
||||
stack.push(kind);
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
scopes.pop();
|
||||
match stack.pop() {
|
||||
Some(Kind::Prop { desc, start, name }) => {
|
||||
if let Some(d) = out.descs.get_mut(desc) {
|
||||
d.props.push(Prop { name, start, end: after });
|
||||
}
|
||||
}
|
||||
Some(Kind::Desc(i)) => {
|
||||
if let Some(d) = out.descs.get_mut(i) {
|
||||
d.end = after;
|
||||
}
|
||||
}
|
||||
Some(Kind::Rdf) => out.rdf_close = out.rdf_close.or(Some(before)),
|
||||
Some(Kind::Other) => {}
|
||||
None => return Err(XmpError::Xml("unbalanced end tag".into())),
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return Err(XmpError::Xml("unclosed element".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `start` moved back over the indentation and line break before it (so a removed property
|
||||
/// takes its line with it).
|
||||
fn line_start(s: &str, start: usize) -> usize {
|
||||
let b = s.as_bytes();
|
||||
let mut i = start;
|
||||
while i > 0 && matches!(b.get(i - 1), Some(b' ' | b'\t')) {
|
||||
i -= 1;
|
||||
}
|
||||
if i > 0 && b.get(i - 1) == Some(&b'\n') {
|
||||
i -= 1;
|
||||
if i > 0 && b.get(i - 1) == Some(&b'\r') {
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Merge `fresh` (a packet as [`crate::write_xmp_lc`] writes it) into `existing` following
|
||||
/// `rules` (see the module docs). Fails — and the caller must not overwrite `existing` blindly —
|
||||
/// when `existing` isn't a well-formed XMP packet.
|
||||
pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result<String, XmpError> {
|
||||
let (bom, old) = match existing.strip_prefix('\u{feff}') {
|
||||
Some(rest) => ("\u{feff}", rest),
|
||||
None => ("", existing),
|
||||
};
|
||||
parse_xmp(old)?;
|
||||
let ex = scan(old)?;
|
||||
let rdf_close = ex.rdf_close.ok_or_else(|| XmpError::Xml("no rdf:RDF element".into()))?;
|
||||
let new = scan(fresh)?;
|
||||
let fresh_desc = new.descs.first().ok_or_else(|| XmpError::Xml("the new packet has no rdf:Description".into()))?;
|
||||
let fresh_names: BTreeSet<&str> = fresh_desc.props.iter().map(|p| p.name.as_str()).collect();
|
||||
let remove = |name: &str| listed(rules.owned, name) || (listed(rules.owned_if_present, name) && fresh_names.contains(name));
|
||||
let write = |name: &str| listed(rules.owned, name) || listed(rules.owned_if_present, name);
|
||||
|
||||
// edits on `old`: (start, end, replacement), applied back to front
|
||||
let mut edits: Vec<(usize, usize, String)> = Vec::new();
|
||||
for d in &ex.descs {
|
||||
let gone_attrs: Vec<&Attr> = d.attrs.iter().filter(|a| !is_meta_attr(&a.name) && remove(&a.name)).collect();
|
||||
let gone_props: Vec<&Prop> = d.props.iter().filter(|p| remove(&p.name)).collect();
|
||||
if gone_attrs.is_empty() && gone_props.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let attrs_left = d.attrs.iter().any(|a| !is_meta_attr(&a.name) && !remove(&a.name));
|
||||
if !attrs_left && gone_props.len() == d.props.len() {
|
||||
// nothing of its own left: drop the whole description
|
||||
edits.push((line_start(old, d.tag_start), d.end, String::new()));
|
||||
continue;
|
||||
}
|
||||
if !gone_attrs.is_empty() {
|
||||
let mut tag = format!("<{}", d.raw_name);
|
||||
for a in d.attrs.iter().filter(|a| is_meta_attr(&a.name) || !remove(&a.name)) {
|
||||
tag.push_str(&format!("\n {}=\"{}\"", a.raw, a.value.replace('"', """)));
|
||||
}
|
||||
tag.push_str(if d.empty { "/>" } else { ">" });
|
||||
edits.push((d.tag_start, d.tag_end, tag));
|
||||
}
|
||||
for p in gone_props {
|
||||
edits.push((line_start(old, p.start), p.end, String::new()));
|
||||
}
|
||||
}
|
||||
// the new description: its own namespace declarations, the properties to write
|
||||
let mut block = String::from("\n <rdf:Description rdf:about=\"\"\n xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"");
|
||||
for a in fresh_desc.attrs.iter().filter(|a| a.raw.starts_with("xmlns:") && a.raw != "xmlns:rdf") {
|
||||
block.push_str(&format!("\n {}=\"{}\"", a.raw, a.value));
|
||||
}
|
||||
block.push('>');
|
||||
let mut written = 0;
|
||||
for p in fresh_desc.props.iter().filter(|p| write(&p.name)) {
|
||||
let text = fresh.get(p.start..p.end).ok_or_else(|| XmpError::Xml("bad property span".into()))?;
|
||||
block.push_str("\n ");
|
||||
block.push_str(text);
|
||||
written += 1;
|
||||
}
|
||||
block.push_str("\n </rdf:Description>");
|
||||
if written > 0 {
|
||||
let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len());
|
||||
edits.push((at, at, block));
|
||||
}
|
||||
edits.sort_by_key(|e| (e.0, e.1));
|
||||
// overlapping edits can't happen (properties sit inside their description, which is either
|
||||
// dropped whole or edited piecewise), but never splice blindly
|
||||
let mut out = String::with_capacity(old.len() + 4096);
|
||||
out.push_str(bom);
|
||||
let mut at = 0;
|
||||
for (s, e, r) in &edits {
|
||||
if *s < at {
|
||||
return Err(XmpError::Xml("overlapping edits".into()));
|
||||
}
|
||||
out.push_str(old.get(at..*s).ok_or_else(|| XmpError::Xml("bad span".into()))?);
|
||||
out.push_str(r);
|
||||
at = *e;
|
||||
}
|
||||
out.push_str(old.get(at..).ok_or_else(|| XmpError::Xml("bad span".into()))?);
|
||||
|
||||
// read back: everything written must come out as written
|
||||
let merged = parse_xmp(&out)?;
|
||||
let want = parse_xmp(fresh)?;
|
||||
for (k, v) in &want.properties {
|
||||
let top = k.split('/').next().unwrap_or(k);
|
||||
if write(top) && merged.properties.get(k) != Some(v) {
|
||||
return Err(XmpError::Xml(format!("{k} did not survive the merge")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RULES: MergeRules =
|
||||
MergeRules { owned: &["xmp:Rating", "xmp:Label", "dc:subject", "dc:title", "lc:*"], owned_if_present: &["exif:DateTimeOriginal"] };
|
||||
|
||||
/// A sidecar as another raw developer writes it: attribute-form simple properties, develop
|
||||
/// settings, a structured history, an unknown namespace.
|
||||
const FOREIGN: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>
|
||||
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Some Toolkit 1.0\">
|
||||
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">
|
||||
<rdf:Description rdf:about=\"\"
|
||||
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"
|
||||
xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\"
|
||||
xmlns:stEvt=\"http://ns.adobe.com/xap/1.0/sType/ResourceEvent#\"
|
||||
xmlns:crs=\"http://ns.adobe.com/camera-raw-settings/1.0/\"
|
||||
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"
|
||||
xmlns:foo=\"http://example.com/foo/\"
|
||||
xmp:Rating=\"2\"
|
||||
xmp:CreatorTool=\"Other App\"
|
||||
crs:Exposure2012=\"+0.50\"
|
||||
crs:Contrast2012=\"-12\"
|
||||
foo:Thing=\"a & b\">
|
||||
<xmpMM:History>
|
||||
<rdf:Seq>
|
||||
<rdf:li stEvt:action=\"saved\" stEvt:when=\"2026-01-02T03:04:05\"/>
|
||||
</rdf:Seq>
|
||||
</xmpMM:History>
|
||||
<crs:ToneCurvePV2012>
|
||||
<rdf:Seq>
|
||||
<rdf:li>0, 0</rdf:li>
|
||||
<rdf:li>255, 255</rdf:li>
|
||||
</rdf:Seq>
|
||||
</crs:ToneCurvePV2012>
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
<rdf:li>old</rdf:li>
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
";
|
||||
|
||||
/// With the packet's whitespace padding and trailer.
|
||||
fn foreign() -> String {
|
||||
format!("{FOREIGN}{}\n<?xpacket end=\"w\"?>", " ".repeat(100))
|
||||
}
|
||||
|
||||
fn fresh(rating: i8, keywords: &[&str]) -> String {
|
||||
let m = crate::Metadata {
|
||||
software: Some("LightCraft".into()),
|
||||
rating: Some(rating),
|
||||
keywords: keywords.iter().map(|k| k.to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
crate::write_xmp_lc(&m, &[("settings", "{\"a\":1}"), ("flag", "pick")])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_foreign_properties_and_replaces_owned_ones() {
|
||||
let out = merge_xmp(&foreign(), &fresh(4, &["new", "two"]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
let p = |k: &str| d.properties.get(k).cloned().unwrap_or_default();
|
||||
assert_eq!(p("xmp:Rating"), vec!["4"]);
|
||||
assert_eq!(p("dc:subject"), vec!["new", "two"]);
|
||||
assert_eq!(p("lc:settings"), vec!["{\"a\":1}"]);
|
||||
assert_eq!(p("lc:flag"), vec!["pick"]);
|
||||
assert_eq!(p("xmp:CreatorTool"), vec!["Other App"], "not ours: kept");
|
||||
assert_eq!(p("crs:Exposure2012"), vec!["+0.50"]);
|
||||
assert_eq!(p("foo:Thing").len(), 1);
|
||||
// untouched parts are byte for byte the same
|
||||
for part in [
|
||||
" <xmpMM:History>\n <rdf:Seq>\n <rdf:li stEvt:action=\"saved\" stEvt:when=\"2026-01-02T03:04:05\"/>\n </rdf:Seq>\n </xmpMM:History>",
|
||||
" <crs:ToneCurvePV2012>\n <rdf:Seq>\n <rdf:li>0, 0</rdf:li>",
|
||||
"crs:Contrast2012=\"-12\"",
|
||||
"foo:Thing=\"a & b\"",
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"Some Toolkit 1.0\">",
|
||||
&format!("</x:xmpmeta>\n{}\n<?xpacket end=\"w\"?>", " ".repeat(100)),
|
||||
] {
|
||||
assert!(out.contains(part), "lost {part:?} in\n{out}");
|
||||
}
|
||||
assert!(!out.contains("xmp:Rating=\"2\""));
|
||||
assert!(!out.contains("<rdf:li>old</rdf:li>"));
|
||||
// saving again changes only our part, and doesn't pile up descriptions
|
||||
let again = merge_xmp(&out, &fresh(1, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&again).unwrap();
|
||||
assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["1".to_string()]));
|
||||
assert!(!d.properties.contains_key("dc:subject"), "a cleared field is cleared");
|
||||
assert_eq!(again.matches("<rdf:Description").count(), 2, "{again}");
|
||||
assert_eq!(merge_xmp(&again, &fresh(1, &[]), RULES).unwrap(), again, "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_if_present_and_prefix_independence() {
|
||||
// the existing packet binds the namespaces to other prefixes, and has a capture time
|
||||
let ex = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><r:RDF xmlns:r=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
|
||||
<r:Description r:about=\"\" xmlns:a=\"http://ns.adobe.com/xap/1.0/\" xmlns:e=\"http://ns.adobe.com/exif/1.0/\" \
|
||||
a:Rating=\"5\" e:DateTimeOriginal=\"2020-01-01T00:00:00\"/></r:RDF></x:xmpmeta>";
|
||||
let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["3".to_string()]));
|
||||
assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2020-01-01T00:00:00".to_string()]), "fresh has none: kept");
|
||||
let mut m = crate::Metadata { rating: Some(3), ..Default::default() };
|
||||
m.capture_time = crate::DateTime::parse_iso("2024-05-06T07:08:09");
|
||||
let out = merge_xmp(ex, &crate::write_xmp_lc(&m, &[]), RULES).unwrap();
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_it_cannot_read() {
|
||||
for bad in ["", "not xml at all", "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF", "<a><b></a>", "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>"] {
|
||||
assert!(merge_xmp(bad, &fresh(1, &[]), RULES).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use lightcraft_meta::{Metadata, extract, parse_iptc, parse_xmp, read_exif, write_xmp};
|
||||
use lightcraft_meta::{MergeRules, Metadata, extract, merge_xmp, parse_iptc, parse_xmp, read_exif, write_xmp};
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
@@ -21,6 +21,23 @@ proptest! {
|
||||
let _ = parse_xmp(&s);
|
||||
}
|
||||
|
||||
/// Merging into arbitrary (also damaged) packets never panics, and a merge that succeeds
|
||||
/// reads back with the written values.
|
||||
#[test]
|
||||
fn merge_never_panics(cut in 0usize..900, junk in ".{0,20}", rating in 0i8..=5) {
|
||||
let base = write_xmp(&Metadata { title: Some("t".into()), rating: Some(1), ..Default::default() }, Some("{}"));
|
||||
let mut s: String = base.chars().take(cut).collect();
|
||||
s.push_str(&junk);
|
||||
s.extend(base.chars().skip(cut));
|
||||
let fresh = write_xmp(&Metadata { rating: Some(rating), ..Default::default() }, Some("{\"x\":1}"));
|
||||
let rules = MergeRules { owned: &["xmp:Rating", "dc:title", "lc:*"], owned_if_present: &[] };
|
||||
if let Ok(out) = merge_xmp(&s, &fresh, rules) {
|
||||
let d = parse_xmp(&out).unwrap();
|
||||
prop_assert_eq!(d.metadata.rating, Some(rating));
|
||||
prop_assert_eq!(d.lc_settings.as_deref(), Some("{\"x\":1}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_text_roundtrips(title in "[a-zA-Z0-9 &<>\"'éü€;|/\\\\]{1,40}", kw in proptest::collection::vec("[a-zA-Z0-9 &<>\"'|]{1,12}", 0..5), rating in -1i8..=5) {
|
||||
let title = title.trim().to_string();
|
||||
|
||||
@@ -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<alphanumeric>` 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<Vec<u8>> {
|
||||
if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -82,7 +82,10 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
"notices": app.notices,
|
||||
"quitPrompt": app.quit_prompt,
|
||||
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
|
||||
"libraryProblem": app.library_problem.as_ref().map(crate::panels::library_problem::LibraryProblem::to_json),
|
||||
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
|
||||
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
||||
"memory": memory(app),
|
||||
|
||||
@@ -29,10 +29,14 @@ mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_quit_unsaved;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_unsaved;
|
||||
@@ -123,6 +127,12 @@ pub struct LightcraftApp {
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
pub notices: Vec<String>,
|
||||
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
|
||||
pub quit_prompt: Option<String>,
|
||||
/// Quit Anyway was chosen: the window may close with unsaved changes.
|
||||
pub quit_confirmed: bool,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<PendingShot>,
|
||||
screenshot_token: u64,
|
||||
@@ -170,6 +180,9 @@ pub struct LightcraftApp {
|
||||
gpu_applied: Option<bool>,
|
||||
/// The memory budget setting last applied (MB, 0 = automatic).
|
||||
memory_applied: Option<u32>,
|
||||
/// The library failed to open at launch: the blocking window, then the temporary-session
|
||||
/// banner (issue #100). Cleared once a library opens.
|
||||
pub library_problem: Option<panels::library_problem::LibraryProblem>,
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
@@ -187,6 +200,9 @@ impl LightcraftApp {
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
headless_host: false,
|
||||
notices: vec![],
|
||||
quit_prompt: None,
|
||||
quit_confirmed: false,
|
||||
control_rx: None,
|
||||
pending_screenshots: vec![],
|
||||
screenshot_token: 0,
|
||||
@@ -213,6 +229,7 @@ impl LightcraftApp {
|
||||
window_is_fullscreen: false,
|
||||
gpu_applied: None,
|
||||
memory_applied: None,
|
||||
library_problem: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +457,7 @@ impl LightcraftApp {
|
||||
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
let t0 = now_ms();
|
||||
panels::library_problem::logic(self);
|
||||
self.logic_inner(ctx);
|
||||
self.perf.logic_ms = now_ms() - t0;
|
||||
}
|
||||
@@ -455,6 +473,11 @@ impl LightcraftApp {
|
||||
} else {
|
||||
self.fonts_ready = true;
|
||||
}
|
||||
panels::notices::logic(self);
|
||||
// closing the window (or Quit) with changes only in memory: retry, else ask first
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dt = now - self.last_time;
|
||||
if dt > 0.0 {
|
||||
@@ -601,7 +624,9 @@ impl LightcraftApp {
|
||||
// full-screen preview: the photo alone on black
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
|
||||
panels::second::show(self, &ctx);
|
||||
panels::notices::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.end_frame(t0);
|
||||
@@ -610,6 +635,7 @@ impl LightcraftApp {
|
||||
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
|
||||
// right panels and left panel run to the bottom; the bottom bar sits between them).
|
||||
panels::topbar::show(self, ui);
|
||||
panels::library_problem::banner(self, ui);
|
||||
panels::strip::show(self, ui);
|
||||
if self.ui.right != state::RightPanel::None {
|
||||
panels::right::show(self, ui);
|
||||
@@ -636,7 +662,9 @@ impl LightcraftApp {
|
||||
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
|
||||
});
|
||||
panels::second::show(self, &ctx);
|
||||
panels::notices::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
|
||||
@@ -932,7 +932,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
if let Ok(v) = &r {
|
||||
let n = v["found"].as_array().map_or(0, Vec::len);
|
||||
let left = v["missing"].as_u64().unwrap_or(0);
|
||||
app.toast(&egui::Context::default(), format!("Found {n} missing photo{}; {left} still missing", if n == 1 { "" } else { "s" }));
|
||||
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
|
||||
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
|
||||
app.toast(
|
||||
&egui::Context::default(),
|
||||
format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }),
|
||||
);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! The library couldn't be opened at launch (issue #100): never a silent in-memory session. A
|
||||
//! blocking window says which library and why, and offers Try Again, Choose Another Library…,
|
||||
//! Continue Without Saving and Quit. Continuing shows a banner under the top bar for as long as the
|
||||
//! session is temporary.
|
||||
|
||||
use egui::{Align2, RichText, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// A library that failed to open (set by the host at launch).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LibraryProblem {
|
||||
/// The library folder ("" when there was none to try, e.g. no home folder).
|
||||
pub path: String,
|
||||
/// Why it didn't open.
|
||||
pub error: String,
|
||||
/// Continue Without Saving was chosen: the banner shows instead of the window.
|
||||
pub dismissed: bool,
|
||||
/// Files from the command line, imported once a library opens.
|
||||
pub pending_import: Vec<String>,
|
||||
/// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't
|
||||
/// a folder: reloading the page tries again).
|
||||
pub can_retry: bool,
|
||||
}
|
||||
|
||||
impl LibraryProblem {
|
||||
pub fn new(path: impl Into<String>, error: impl Into<String>) -> LibraryProblem {
|
||||
LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// `ui.inspect` → `libraryProblem`.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len()})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user chose.
|
||||
enum Choice {
|
||||
Retry,
|
||||
Choose,
|
||||
Temporary,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is
|
||||
/// over (and command-line files are imported), else it shows the new error.
|
||||
fn open(app: &mut LightcraftApp, path: Option<String>) {
|
||||
let params = match &path {
|
||||
Some(p) => json!({"path": p}),
|
||||
None => json!({}),
|
||||
};
|
||||
match app.run("app.openLibrary", params) {
|
||||
Ok(serde_json::Value::Null) => {} // folder dialog cancelled
|
||||
Ok(_) => {
|
||||
let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(p) = app.library_problem.as_mut() {
|
||||
if let Some(path) = path {
|
||||
p.path = path;
|
||||
}
|
||||
p.error = e;
|
||||
p.dismissed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the problem once a library is open (e.g. through Settings → Open Library…).
|
||||
pub fn logic(app: &mut LightcraftApp) {
|
||||
if app.library_problem.is_some() && app.session.library.is_some() {
|
||||
app.library_problem = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking window (until a choice is made).
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(problem) = app.library_problem.clone() else { return };
|
||||
if problem.dismissed {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {
|
||||
// swallows clicks: nothing behind the window can be used meanwhile
|
||||
ui.allocate_rect(screen, egui::Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
|
||||
});
|
||||
let mut choice = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
|
||||
egui::Window::new("Your library couldn't be opened")
|
||||
.id(egui::Id::new("library-problem"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.frame(frame)
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(460.0)
|
||||
.show(ctx, |ui| {
|
||||
// a fixed width: a wrapped label in an auto-sized, centred window would move it every frame
|
||||
ui.set_width(440.0);
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
if problem.path.is_empty() {
|
||||
ui.label(RichText::new("LightCraft couldn't find where to keep your library.").color(t.text));
|
||||
} else {
|
||||
ui.label(RichText::new("LightCraft couldn't open the library at").color(t.text_label));
|
||||
ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap());
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \
|
||||
or reconnecting its drive), open a different library or create a new one, or continue without a library — \
|
||||
then nothing you do is saved.",
|
||||
)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(label).min_size(vec2(0.0, 26.0)));
|
||||
register(ui.ctx(), format!("button:{id}"), r.rect);
|
||||
if r.clicked() {
|
||||
choice = Some(c);
|
||||
}
|
||||
};
|
||||
button(ui, "libraryRetry", "Try Again", problem.can_retry && !problem.path.is_empty(), Choice::Retry);
|
||||
button(ui, "libraryChoose", "Choose Another Library…", app.services.pick_folder.is_some(), Choice::Choose);
|
||||
button(ui, "libraryTemporary", "Continue Without Saving", true, Choice::Temporary);
|
||||
button(ui, "libraryQuit", "Quit", true, Choice::Quit);
|
||||
});
|
||||
});
|
||||
match choice {
|
||||
Some(Choice::Retry) => open(app, Some(problem.path)),
|
||||
Some(Choice::Choose) => open(app, None),
|
||||
Some(Choice::Temporary) => {
|
||||
if let Some(p) = app.library_problem.as_mut() {
|
||||
p.dismissed = true;
|
||||
}
|
||||
let files = app.library_problem.as_mut().map(|p| std::mem::take(&mut p.pending_import)).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
}
|
||||
}
|
||||
Some(Choice::Quit) => app.ui.quit = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The banner under the top bar while the session is temporary (Continue Without Saving).
|
||||
pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if !app.library_problem.as_ref().is_some_and(|p| p.dismissed) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut reopen = false;
|
||||
egui::Panel::top("library-problem-banner")
|
||||
.exact_size(30.0)
|
||||
.frame(egui::Frame::NONE.fill(t.caution).inner_margin(egui::Margin::symmetric(12, 0)))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
let r = ui.label(
|
||||
RichText::new("Temporary session — your library isn't open, and nothing you do here is saved.")
|
||||
.font(t.semibold(12.5))
|
||||
.color(t.canvas),
|
||||
);
|
||||
register(ui.ctx(), "indicator:temporarySession", r.rect);
|
||||
let b = ui.add(egui::Button::new(RichText::new("Open Library…").color(t.canvas)).fill(egui::Color32::from_black_alpha(40)));
|
||||
register(ui.ctx(), "button:libraryReopen", b.rect);
|
||||
reopen = b.clicked();
|
||||
});
|
||||
});
|
||||
if reopen && let Some(p) = app.library_problem.as_mut() {
|
||||
p.dismissed = false;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ pub mod edit;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
pub mod notices;
|
||||
pub mod presets;
|
||||
pub mod profiles;
|
||||
pub mod right;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Things the user must not miss (issue #103): settings files that were damaged or unreadable when
|
||||
//! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes
|
||||
//! are only in memory.
|
||||
|
||||
use egui::{Align2, RichText, vec2};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Collect the library's settings-file warnings (each is shown once).
|
||||
pub fn logic(app: &mut LightcraftApp) {
|
||||
let new = app.session.take_library_warnings();
|
||||
app.notices.extend(new);
|
||||
}
|
||||
|
||||
/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that
|
||||
/// couldn't be written are retried first — the log, then a snapshot of everything in memory.
|
||||
pub fn may_close(app: &mut LightcraftApp) -> bool {
|
||||
if app.quit_confirmed || app.session.unsaved().is_none() {
|
||||
return true;
|
||||
}
|
||||
let _ = app.session.persist();
|
||||
if app.session.unsaved().is_some()
|
||||
&& let Err(e) = app.session.close_library()
|
||||
{
|
||||
log::error!("quit: library not saved: {e}");
|
||||
}
|
||||
match app.session.unsaved() {
|
||||
None => true,
|
||||
Some((n, e)) => {
|
||||
app.quit_prompt = Some(format!(
|
||||
"{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
|
||||
if n == 1 { "1 change".to_string() } else { format!("{n} changes") },
|
||||
if n == 1 { "it is" } else { "they are" }
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Choice {
|
||||
Retry,
|
||||
QuitAnyway,
|
||||
Cancel,
|
||||
Ok,
|
||||
}
|
||||
|
||||
fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)]) -> Option<usize> {
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
egui::Area::new(egui::Id::new((id, "dim"))).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
ui.allocate_rect(screen, egui::Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
|
||||
});
|
||||
let mut chosen = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
|
||||
egui::Window::new(title)
|
||||
.id(egui::Id::new(id))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.frame(frame)
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(440.0)
|
||||
.show(ctx, |ui| {
|
||||
// a fixed width: a wrapped label in an auto-sized, centred window would move it every frame
|
||||
ui.set_width(420.0);
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
ui.add(egui::Label::new(RichText::new(text).color(t.text_label)).wrap());
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, (bid, label, _)) in buttons.iter().enumerate() {
|
||||
let r = ui.add(egui::Button::new(*label).min_size(vec2(0.0, 26.0)));
|
||||
register(ui.ctx(), format!("button:{bid}"), r.rect);
|
||||
if r.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if let Some(text) = app.quit_prompt.clone() {
|
||||
let buttons = [
|
||||
("quitRetry", "Try Saving Again", Choice::Retry),
|
||||
("quitAnyway", "Quit Anyway", Choice::QuitAnyway),
|
||||
("quitCancel", "Cancel", Choice::Cancel),
|
||||
];
|
||||
let Some(i) = window(ctx, "quit-unsaved", "Quit with unsaved changes?", &text, &buttons) else { return };
|
||||
match buttons[i].2 {
|
||||
Choice::Retry => {
|
||||
app.quit_prompt = None;
|
||||
if may_close(app) {
|
||||
app.ui.quit = true;
|
||||
}
|
||||
}
|
||||
Choice::QuitAnyway => {
|
||||
app.quit_prompt = None;
|
||||
app.quit_confirmed = true;
|
||||
app.ui.quit = true;
|
||||
}
|
||||
Choice::Cancel | Choice::Ok => app.quit_prompt = None,
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(text) = app.notices.first().cloned() {
|
||||
let buttons = [("noticeOk", "OK", Choice::Ok)];
|
||||
if window(ctx, "settings-notice", "Settings file problem", &text, &buttons).is_some() {
|
||||
app.notices.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Issue #100: a library that can't be opened at launch is never replaced by a silent in-memory
|
||||
//! demo session. The window says why and offers Try Again / Choose Another Library… / Continue
|
||||
//! Without Saving / Quit; a temporary session shows a banner and never writes to the library.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::panels::library_problem::LibraryProblem;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_library_asks_instead_of_running_a_demo() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-libproblem-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let lib = dir.join("lib");
|
||||
std::fs::create_dir_all(&lib).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let photo = dir.join("a.png");
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
|
||||
// another program has the library open: the launch fails like the desktop host's would
|
||||
let held = lightcraft_catalog::LibraryLock::acquire(&lib, "another LightCraft").unwrap();
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let err = session.open_library(&lib, true).unwrap_err().to_string();
|
||||
let png = |img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
};
|
||||
let mut app = LightcraftApp::new(
|
||||
session,
|
||||
Services {
|
||||
png: Some(Box::new(png)),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
app.library_problem =
|
||||
Some(LibraryProblem { pending_import: vec![photo.to_string_lossy().to_string()], ..LibraryProblem::new(lib.to_string_lossy(), err) });
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.session.catalog.is_empty(), "no demo photos");
|
||||
for b in ["button:libraryRetry", "button:libraryChoose", "button:libraryTemporary", "button:libraryQuit"] {
|
||||
assert!(has(&h, b), "{b}");
|
||||
}
|
||||
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
|
||||
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("dialog.png").to_string_lossy(), "headless": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
let inspect = h.request("ui.inspect", json!({}), T);
|
||||
let p = &inspect["result"]["libraryProblem"];
|
||||
assert!(p["error"].as_str().unwrap().contains("already open in another LightCraft"), "{p}");
|
||||
assert_eq!(p["temporarySession"], false);
|
||||
|
||||
// still locked: Try Again keeps the window, with the error
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert!(has(&h, "button:libraryRetry"));
|
||||
|
||||
// Continue Without Saving: a banner, the command-line photo in the temporary session, and
|
||||
// nothing written into the library folder
|
||||
let before: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!has(&h, "button:libraryRetry"));
|
||||
assert!(has(&h, "indicator:temporarySession"));
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session");
|
||||
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
|
||||
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 3}}), T);
|
||||
let after: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(before, after, "the temporary session never writes to the library");
|
||||
|
||||
// the other program quits; Open Library… → Try Again opens it, the banner goes away
|
||||
drop(held);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryReopen"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert!(has(&h, "button:libraryRetry"));
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.session.library.is_some());
|
||||
assert!(h.app.library_problem.is_none());
|
||||
assert!(!has(&h, "indicator:temporarySession") && !has(&h, "button:libraryRetry"));
|
||||
assert!(h.app.session.catalog.is_empty(), "an opened (new) library is never seeded with demo photos here");
|
||||
drop(h);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Issue #103: quitting with changes that couldn't be saved asks first (after one more try), and
|
||||
//! settings-file problems found when the library opened are shown, not swallowed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use lightcraft_engine::library::LibraryStores;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Every write fails while `fail` is set (an unplugged drive).
|
||||
#[derive(Clone, Default)]
|
||||
struct Unplugged {
|
||||
files: MemStore,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Unplugged {
|
||||
fn check(&self) -> std::io::Result<()> {
|
||||
if self.fail.load(Ordering::SeqCst) { Err(std::io::Error::other("drive unplugged")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Unplugged {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.check()?;
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.check()?;
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"unplugged".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
/// Click a widget and let the synthetic events (move, press, release) play out.
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
// a window that just appeared is placed on its second frame
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}");
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_with_unsaved_changes_asks_first() {
|
||||
let store = Unplugged::default();
|
||||
let mut s = lightcraft_engine::Session::new();
|
||||
let stores = LibraryStores { dir: "unplugged".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.step();
|
||||
// nothing unsaved: closing is fine
|
||||
assert!(crate::panels::notices::may_close(&mut h.app));
|
||||
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Kept"}}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask");
|
||||
h.step();
|
||||
let prompt = h.app.quit_prompt.clone().unwrap();
|
||||
assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}");
|
||||
for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] {
|
||||
assert!(has(&h, b), "{b}");
|
||||
}
|
||||
assert_eq!(h.request("ui.inspect", json!({}), T)["result"]["quitPrompt"], json!(prompt));
|
||||
// Cancel: back to work, nothing quit
|
||||
click(&mut h, "button:quitCancel");
|
||||
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
|
||||
|
||||
// the drive is back: Try Saving Again saves and quits
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
h.step();
|
||||
store.fail.store(false, Ordering::SeqCst);
|
||||
click(&mut h, "button:quitRetry");
|
||||
assert!(h.app.session.unsaved().is_none(), "saved");
|
||||
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
|
||||
|
||||
// Quit Anyway is a deliberate choice
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let _ = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Lost"}}), T);
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
h.step();
|
||||
click(&mut h, "button:quitAnyway");
|
||||
assert!(h.app.quit_confirmed && crate::panels::notices::may_close(&mut h.app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_settings_file_is_shown() {
|
||||
let files = MemStore::new();
|
||||
files.set("presets.json", b"{\"user\": [ {\"id\": ".to_vec());
|
||||
let mut s = lightcraft_engine::Session::new();
|
||||
let stores = LibraryStores { dir: "lib".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.notices.len(), 1);
|
||||
assert!(h.app.notices[0].contains("presets.json is damaged"), "{:?}", h.app.notices);
|
||||
assert!(has(&h, "button:noticeOk"));
|
||||
assert!(files.files.lock().unwrap().keys().any(|k| k.starts_with("presets.json.corrupt-")));
|
||||
click(&mut h, "button:noticeOk");
|
||||
assert!(h.app.notices.is_empty(), "{:?}", h.app.notices);
|
||||
h.step();
|
||||
assert!(!has(&h, "button:noticeOk"));
|
||||
}
|
||||
@@ -9,7 +9,16 @@
|
||||
← {"id": 2, "ok": false, "error": "unknown command `nope`"}
|
||||
```
|
||||
|
||||
Requests are answered on the UI thread between frames (timeout 60 s). The MCP server's connect
|
||||
Requests are answered on the UI thread between frames (timeout 60 s).
|
||||
|
||||
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are
|
||||
optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without
|
||||
`method`, invalid UTF-8, or a line longer than 4 MiB — gets one error reply
|
||||
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**; nothing sent after
|
||||
it on that connection runs. This keeps an HTTP request (for example a web page's cross-origin `fetch` to
|
||||
`127.0.0.1:<port>`) from smuggling a command in its body: its request line is rejected first. Junk never reaches the
|
||||
UI thread, and at most 16 connections are served at once (further ones get an error line and are closed). Clients
|
||||
that hit an error reply should reconnect. The port has no authentication, so only enable it when you need it. The MCP server's connect
|
||||
mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport).
|
||||
|
||||
@@ -21,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`) |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
@@ -49,6 +58,15 @@ shows a warning (widget `indicator:unsaved`). Queries and commands that change n
|
||||
compaction (snapshot) is not a failed command — the log is kept whole — and only shows in `library.info` →
|
||||
`lastError`.
|
||||
|
||||
### When the library can't be opened
|
||||
|
||||
If the desktop app can't open its library at launch (another program has it open, an unreadable or newer-format
|
||||
catalog, a missing drive), the session starts empty and in memory — never with demo photos — and a window asks what
|
||||
to do: `ui.inspect` → `libraryProblem` is `{path, error, temporarySession, pendingImport}` (else `null`); its buttons
|
||||
are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Continue Without Saving) and
|
||||
`button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and
|
||||
writes nothing. `app.openLibrary` opening a library ends it.
|
||||
|
||||
## Headless rendering (no window, no GPU)
|
||||
|
||||
The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ It runs in one of two modes:
|
||||
|
||||
Options: `--library DIR` opens (or creates) a persistent LightCraft library — the same crash-safe
|
||||
format the desktop app uses (`~/Pictures/LightCraft Library` by default there) — so ratings, edits and albums
|
||||
survive between sessions (with `--demo`, a new library is seeded with the demo photos);
|
||||
survive between sessions (with `--demo`, a new library is seeded with the demo photos). A library
|
||||
is open in one program at a time (`catalog.lock` in the library folder): while the desktop app has
|
||||
it open, `--library` on the same folder fails with "This library is already open in LightCraft
|
||||
(process N …)" — use connect mode to work with the running app instead;
|
||||
`--demo` starts the headless session with the procedurally generated demo library;
|
||||
`--compact` lists only the helper tools (see below). In connect mode the server starts even when
|
||||
the app is not running yet and connects on the first call (and reconnects if the app restarts).
|
||||
|
||||
+14
-13
@@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 2 | 2 | 1 | 8/8 (100%) | 6/7 (86%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) |
|
||||
| **Total** | 386 | 28 | 91 | 37 | 194/200 (97%) | 138/147 (94%) |
|
||||
| Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 387 | 28 | 91 | 37 | 194/200 (97%) | 139/148 (94%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 505 in-scope rows — P0 98.5% of 200 · P1 95.9% of 147 · P2 39.2% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 506 in-scope rows — P0 98.5% of 200 · P1 95.9% of 148 · P2 39.2% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -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)
|
||||
|
||||
@@ -153,7 +153,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
|
||||
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
|
||||
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
|
||||
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) |
|
||||
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
@@ -210,7 +210,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
|
||||
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw) |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
@@ -463,7 +463,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-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs` | General / Import / Performance / Interface tabs; app settings in ui.json, library settings in prefs.json |
|
||||
| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks |
|
||||
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
|
||||
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
|
||||
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
|
||||
@@ -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 | 🚫 | | |
|
||||
|
||||
@@ -488,7 +488,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-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved |
|
||||
| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to; versioned catalog format (`journal::VERSION`): older libraries are upgraded on open, a library from a newer LightCraft is refused untouched ("written by a newer version") |
|
||||
| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits |
|
||||
| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |
|
||||
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
|
||||
@@ -713,7 +713,8 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; like the file manager, not an undo step) |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
@@ -727,8 +728,8 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
|
||||
| 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… (same name and size anywhere in a folder, 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-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; 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 |
|
||||
|
||||
@@ -80,6 +80,10 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources).
|
||||
the background within a frame or two, each file replaced atomically, in modification order
|
||||
(`apps/lightcraft-web/src/files.rs`). View state and UI prefs are saved every second when they
|
||||
change (a tab can close without notice).
|
||||
- Known limitation: the browser storage has no file locks, so two tabs of the same origin open
|
||||
the same library and the last one to write a snapshot wins (the desktop app and the CLI lock
|
||||
a library folder: `catalog.lock`, issue #99). Use one tab at a time; a guard through the Web
|
||||
Locks API (`navigator.locks`) is still to do.
|
||||
- `originals/<content hash>`: the bytes of every imported photo. The catalog refers to them as
|
||||
`web/<hash>/<file name>`; the main thread keeps recently used originals in memory (≤ 768 MB)
|
||||
and loads the rest on demand (the active photo is prefetched).
|
||||
|
||||
@@ -17,6 +17,31 @@
|
||||
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.
|
||||
- Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its
|
||||
fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A
|
||||
sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a
|
||||
JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,
|
||||
the JPEG uses `IMG_0001.JPG.xmp`.
|
||||
- A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog
|
||||
afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every
|
||||
change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written
|
||||
still saves your queued changes in the closing snapshot.
|
||||
- The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library
|
||||
written by a newer LightCraft is refused with "this library was written by a newer version of LightCraft" and left
|
||||
untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,
|
||||
LightCraft 0.2.0 and older refuse it ("unsupported format … v2").
|
||||
- The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request
|
||||
(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at
|
||||
4 MiB and connections at 16.
|
||||
- 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.
|
||||
@@ -26,8 +51,26 @@
|
||||
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.
|
||||
- Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept
|
||||
as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone
|
||||
for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your
|
||||
library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while
|
||||
changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.
|
||||
|
||||
### Library
|
||||
- Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive
|
||||
volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets
|
||||
`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.
|
||||
- Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops
|
||||
mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),
|
||||
so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking
|
||||
it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match
|
||||
over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.
|
||||
- Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with
|
||||
its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo
|
||||
is refused and nothing is overwritten.
|
||||
- Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.
|
||||
- Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.
|
||||
- Colour-label filter with several labels at once; expandable folder tree in Local.
|
||||
@@ -45,6 +88,14 @@
|
||||
- Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once
|
||||
their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them
|
||||
again. Change the period (or turn it off) in Settings → Performance.
|
||||
- A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already
|
||||
has open — on this computer or another one sharing the folder — says who has it ("already open in LightCraft (process
|
||||
123 on studio-mac)") instead of letting both write and silently drop each other's edits. A crash never leaves the
|
||||
library locked: the lock is the operating system's and goes away with the program.
|
||||
- If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,
|
||||
written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue
|
||||
Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and
|
||||
lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.
|
||||
|
||||
### Editing
|
||||
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
|
||||
|
||||
+35
-2
@@ -8,8 +8,8 @@ other tools), and let LightCraft pick up edits made elsewhere.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. |
|
||||
| Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. Written atomically (temp file + rename). |
|
||||
| Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. |
|
||||
| Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. |
|
||||
| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. |
|
||||
| Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. |
|
||||
|
||||
@@ -37,6 +37,39 @@ used — on import (it then also files a copied photo in its date folder) and by
|
||||
`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
|
||||
`lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate).
|
||||
|
||||
## Saving into an existing sidecar
|
||||
|
||||
A sidecar may already hold another application's data — e.g. its `crs:` develop settings and `xmpMM:History` — often
|
||||
the only copy of those edits outside that application's catalog. Saving never drops it:
|
||||
|
||||
- LightCraft **owns** the properties in the table above: `xmp:Rating`, `xmp:Label`, `dc:title`, `dc:description`,
|
||||
`dc:rights`, `dc:creator`, `dc:subject`, `Iptc4xmpCore:Location`, `Iptc4xmpCore:AltTextAccessibility`,
|
||||
`Iptc4xmpCore:ExtDescrAccessibility`, `photoshop:City`/`State`/`Country`, `xmpRights:Marked`/`UsageTerms`/
|
||||
`WebStatement` and everything in `lc:`. They are replaced on every save, and removed when LightCraft has no value
|
||||
(clearing a title in LightCraft clears it in the file). All of them are read back on import, so the library starts
|
||||
from what the sidecar said.
|
||||
- The **capture time** (`exif:DateTimeOriginal`, `photoshop:DateCreated`) and **GPS** (`exif:GPSLatitude`,
|
||||
`exif:GPSLongitude`) are replaced only when LightCraft has a value; otherwise the file's stay.
|
||||
- **Everything else is kept byte for byte**: other namespaces (`crs:`, `xmpMM:`, `lr:hierarchicalSubject`, unknown
|
||||
ones), `xmp:CreatorTool`, comments, the packet wrapper and padding. LightCraft's properties go into one
|
||||
`rdf:Description` of their own; owned properties written by another application (in attribute or element form) are
|
||||
removed from its description, which otherwise stays as it was.
|
||||
- A sidecar that can't be read as XMP (not well-formed, not UTF-8, no `rdf:RDF`) is first copied to
|
||||
`<name>.xmp.bak-<date><time>` (`-1`, `-2`… if taken — a backup is never overwritten), then replaced. A sidecar that
|
||||
can't be read at all (permissions) is left alone and the save fails.
|
||||
|
||||
Limits: LightCraft writes keywords to `dc:subject` only, so another application's `lr:hierarchicalSubject` is kept as
|
||||
it was and may still list keywords removed in LightCraft. LightCraft doesn't write `crs:`: the other application's
|
||||
develop settings stay as that application left them, next to LightCraft's own (`lc:settings`, which LightCraft reads
|
||||
first).
|
||||
|
||||
## Shared names
|
||||
|
||||
With stem naming, `IMG_0001.CR3` and `IMG_0001.JPG` map to the same `IMG_0001.xmp`. When two or more files in the
|
||||
library share a stem, the stem sidecar belongs to one of them — a raw first, otherwise the first by file name — and
|
||||
the others write and read `<file>.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they
|
||||
have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`).
|
||||
|
||||
## Reading `crs:` develop fields
|
||||
|
||||
Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in
|
||||
|
||||
Reference in new issue
Block a user