When open_library failed, the desktop app (and the web build) quietly ran
an in-memory session seeded with the demo photos: it looked like a reset
library and everything done in it was lost at quit.
- The session now starts empty and in memory (no demo photos) and
LightcraftApp::library_problem shows a blocking window with the path and
the error: Try Again, Choose Another Library… (app.openLibrary), Continue
Without Saving, Quit. Command-line files are imported once the user has
chosen.
- A temporary session shows a banner under the top bar (with Open
Library…) for as long as it lasts; it has no library, so nothing is
written anywhere. Opening a library ends it.
- ui.inspect → libraryProblem; widget ids for automation; documented in
control-protocol.md.
- Web build: same window (Try Again disabled: reloading the page retries).
- Headless UI test: locked library → window → Continue Without Saving →
banner, nothing written to the library → lock released → Try Again opens
it. Checked in the real app over the control channel too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main added Op::SetBrowsed, Catalog.browsed and Photo.local_baseline while
the snapshot still said version 1, so v0.2.0 read such a library in part
(unknown ops as log damage, unknown fields dropped on its next snapshot).
- journal::VERSION = 2 (now public), with a format table and rules in the
journal docs; a test with an exhaustive match over Op makes adding a
variant fail until it is assigned a version.
- Journal::open reads the snapshot header first and refuses a newer
version with CatalogError::Newer ("written by a newer version of
LightCraft"), even when its catalog wouldn't parse.
- A log record whose CRC matches but whose op doesn't parse is "newer",
not damage or a torn tail: refused before any file is modified.
- Opening an older (or snapshot-less) library writes a snapshot in this
format before anything is appended (LoadReport::upgraded_from); a new
library gets one at once, so v0.2.0 refuses it instead of reading a bare
log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed append (disk full, dropped share) could leave part of its batch in
catalog.log; the retry (same seqs) then shared a line with the fragment and
the next load treated it as mid-log damage, setting later edits aside.
- Journal::append cuts the log back to the last whole record on error (or,
if that fails too, before the next append).
- FsStore drops a failed append handle so the next attempt reopens the file;
truncate of a never-created file is a no-op.
- The loader recognises a CRC-valid record glued after a fragment (logs
written by older builds) and skips only the fragment.
- log_bytes now counts the newline added to an unterminated last record.
- close_library writes the closing snapshot even when appending fails; the
snapshot absorbs the queued ops (Journal::snapshot_with_unlogged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Library settings (prefs.json, presets.json, view.json): only a missing
file means "defaults". A file that doesn't parse is kept as
<name>.corrupt-<unix time> before the defaults are used; one that can't
be read (locked, I/O error) is never written this session (save_prefs
refuses, presets/view saves skip it). Warnings go to library.info →
settingsWarnings and Session::take_library_warnings; the UI shows each
once in a window (ui.inspect → notices, button:noticeOk).
- Desktop ui.json (holds the library location): written atomically (temp +
fsync + rename + dir fsync), saved as soon as the library path changes and
every few seconds when it changed, failures reported; a damaged file is
renamed to ui.json.corrupt-<time> and reported, an unreadable one is not
overwritten.
- Quit/close with changes only in memory: retry the save (log, then a
snapshot of everything), and if it still fails cancel the close and ask:
Try Saving Again / Quit Anyway / Cancel (ui.inspect → quitPrompt).
- Tests: engine (damaged prefs kept, unreadable presets/prefs untouched),
desktop (damaged ui.json kept, atomic write), headless UI (quit prompt
buttons, settings notice).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two processes on one library each kept their own catalog and seq counter;
the last snapshot silently dropped the other's edits.
- lightcraft_catalog:🔒 LibraryLock holds an exclusive OS lock
(std File::try_lock: flock / LockFileEx) on <library>/catalog.lock for
the session; who holds it goes to catalog.lock.owner (separate file: a
Windows lock also blocks reads). Released on drop and by the OS when the
process dies, so leftover files from a crash never lock anyone out. File
systems without lock support open unprotected with a warning.
- Session::open_library takes the lock before reading anything and fails
with EngineError::LibraryInUse ("this library is already open in
LightCraft 0.x (process N on host)"); reopening the session's own
library hands its lock over.
- lightcraft-cli (mcp/run/snapshot --library) adds how to drive the
running app instead (--control + mcp --connect).
- Tests: in-process, leftover files, a killed child process, two engine
sessions, CLI e2e (mcp --library holds it, run --library is refused).
Existing tests that reopened a library while it was open now drop the
first session.
- docs: parity row LRC-LIB-CATALOG-LOCK, mcp.md, web.md (no guard across
browser tabs yet), whats-new.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The folder move and the photos' relinks form one undo step: undo renames
the folder back on disk (files and sidecars along) and restores the
paths, redo repeats it. A destination taken in the meantime is never
overwritten; the step is refused, reported and stays on the stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line that is not a JSON object with a string `method` (HTTP request
line, invalid UTF-8, non-object JSON, >4 MiB line) now gets one error
reply and the connection is closed, so a browser's cross-origin fetch
can't smuggle a command in its body. Connections are capped at 16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- File > Back Up Library... (web): a zip of the catalog files and every
stored original, built entry by entry; Restore Library from Backup...
reads it into a new library folder and switches only when complete
(the previous library stays in storage).
- Failed flushes to browser storage make the catalog Store refuse
writes, so changes show as unsaved (top-bar warning) and are retried
every 2 s instead of being lost silently.
- A picked/dropped photo whose bytes can't be stored is not added.
- One tab per library (Web Locks); a second tab shows a message.
- Notices: storage not persistent, library failed to open (temporary
session), experimental build; ?reset asks first; a panic shows a
message instead of a frozen page; long toasts wrap.
- Hosting: _headers/.htaccess no longer mark the unhashed .js/.wasm
immutable; HOSTING.md rewritten for the actual build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy import used exists() then std::fs::copy: no sync, no comparison, and
a file appearing at the chosen name in between was replaced. It now goes
through import_move::copy_new, which reuses Move's copy_verified: a new
file (create_new, retrying -1, -2… on AlreadyExists), synced and compared
byte for byte with the source; a copy that fails or differs is removed and
reported as a failed import. Copy as DNG's copy step uses the same path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Convert to DNG / Copy as DNG: the encoded DNG must decode to the same raw
data, then goes through safe_file::write_new_unique (temp file, sync,
read-back, hard-linked to the first free <stem>.dng / -2 / -3 name, never
replacing a file). On failure no DNG is left, the photo is not relinked
and the raw copy is kept.
- Photo Merge writes its DNG the same way.
- Smart previews are written atomically; smart::is_valid (header + complete
JPEG) decides "built", has_smart_preview and the offline fallback, so a
proxy cut short is rebuilt (reported as `repaired`); moving proxies to a
new folder never deletes a good one in favour of a damaged copy and
copies across drives atomically.
Stacked on the #93 branch (shared lightcraft_catalog::safe_file helper).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming IMG_1.CR3 alone took the shared IMG_1.xmp along and left
IMG_1.JPG without it. A Stem-named sidecar that a sibling photo file
still uses is now copied (independently, never linked) instead of moved;
the last file renamed takes it. A rollback removes the copy.
Stacked on fix/issue-105-rename-rollback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving (Cmd+S or auto-write) now merges LightCraft's properties into an
existing sidecar (lightcraft_meta::merge_xmp): owned properties (rating,
label, dc/IPTC/rights fields, lc:*) are replaced or removed, capture time
and GPS only when LightCraft has a value; everything else (crs:,
xmpMM:History, unknown namespaces, packet wrapper) is kept byte for byte.
The result is re-parsed and checked before it is written (temp + fsync +
rename). A sidecar that isn't valid XMP is copied to .xmp.bak-<time>
(never overwriting) before it is replaced; one that can't be read at all
is left alone and the save fails.
Shared stems: when several catalogued files map to one stem sidecar
(IMG.CR3 + IMG.JPG), it belongs to the raw (else the first by name) and
the others write and read <file>.xmp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes
(temp file in the same folder, sync, rename, folder sync; temp removed on
failure), write_new/write_new_unique that never replace a file and read
the result back, same_file, and a per-thread fault injection for tests.
- engine::originals::OriginalGuard: refuses a write whose target is a
catalogued photo's original or XMP sidecar (by file identity), whatever
the conflict policy or exact path. Used by run_batch (export, Export with
Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge
previewPath, MCP render_photo/screenshot paths (new query command
export.checkTarget) and lightcraft-cli render.
- The conflict policy now covers an export's sidecars together with the
file (Unique gives both the same free name, Skip skips when either is
taken).
- export::write_file replaces the plain std::fs::write in the desktop app,
CLI, MCP and engine writers; XMP sidecar writes use the shared helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>