The command sweep ran metadata.downloadPlaceNames with empty params, which
starts a real download from download.geonames.org into the settings folder.
The sweep now skips it (the command has its own loopback-server tests) and
asserts that no place names download was started.
Co-Authored-By: Claude <noreply@anthropic.com>
crates/engine/src/media.rs, crates/engine/src/smart.rs: DecodedSource keeps both new fields, from_proxy (#1809) and reduced (#2263); a smart preview loads with from_proxy true, reduced false.
crates/engine/src/cmd/previews.rs: original_only refuses both proxy-loaded and reduced sources.
crates/ui-egui/src/panels/grid.rs: badge keeps main's double-click guard and calls #2300's toggle_stack
crates/ui-egui/src/tests_grid.rs: both sides' badge tests
docs/parity-checklist.md: header and revision rows from both sides; rows take both changes
crates/engine/src/cmd/previews.rs: the opening refresh takes every photo on disk (#1809) with the preview_fitted_look flag of #1545 (vendor raws and DNGs); SmartJob and refresh docs name both.
crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month table with chrono as a workspace dependency:
- apps/lightcraft/src/logging.rs: timestamp (log line time)
- crates/catalog/src/dates.rs: civil + weekday
- crates/catalog/src/stacks.rs: iso_seconds (date -> seconds)
- crates/engine/src/cmd/face_recognize.rs: capture_secs (native only)
- crates/meta/src/datetime.rs: DateTime::unix_seconds and the valid() day check
Output formats and semantics are unchanged (pre-epoch clamp, day rollover that
normalize_iso relies on, leap-second tolerance, EXIF/XMP parsing). chrono uses
only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.
Follow-up to storytold/cadcraft#1785
* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed
chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock as the epoch, keeping the old pre-epoch clamp.
timestamp and catalog's civil write years past 9999 with {:04} like the old
{y:04} did, because %Y would add a '+'. Tests cover the out-of-range and
5-digit-year cases.
* Don't build an unrepresentable SystemTime in the far-future test
UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.
* time: preserve old out-of-range date conversion (no epoch fallback)
`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01, changing output the old hand-rolled
Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was
318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).
- apps/lightcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/catalog/src/dates.rs: `civil` cycle-based (the callers stay inside
0000-9999, but a far clock no longer resets to the epoch).
Pinned old outputs in tests:
logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
civil 10_000_000_000_000 -> 318857-05-20T17:46:40,
-10_000_000_000_000 -> -314918-08-13T06:13:20, i64 extremes
* Don't build a year-318857 SystemTime unchecked in the logging test
Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.
* Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
* Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
crates/ui-egui/locales/{cs,it,pl}-formats.json: kept the translations and added the track's new keys.
docs/parity-checklist.md: newer header line; revision rows from both sides.
docs/whats-new.md: both tracks' 2026-10-15 entries.
mask.applyEffect, saveEffect and updateEffect read an `id` that isn't a
mask id as the selected mask (applyEffect {"id": "1"} changed mask 2);
mask.update, mask.component and mask.refineDetail cast `component` with
as-casts and fell back to component 0 or the last selection. They share
the strict opt_mask_id / opt_index helpers now.
photo.label read its label with str_param, which is None both when the
label is missing and when it isn't a string, and None meant "none": so
photo.label {} or {"label": 3} cleared the colour label of every selected
photo. Only "none" or null clear now; anything else that isn't a known
label is an error. Every caller in the app already passes a label.
mask_id read `id` with `as_u64().map(|v| v as u32)`, so text fell back to
the selected mask (mask.delete {"id": "1"} deleted it) and an id past
u32 wrapped onto another mask (4294967297 as u32 is 1). spot_index and
spot.select did the same with `index`, and a colour range's `component`
fell back to the first colour range part. They now refuse such values,
as the photo ids do; a missing or null value still means the selection.
Several commands read their photo parameters leniently. An `ids` that
wasn't a list of whole numbers (`"ids": 3`, `["3"]`) fell back to the
selection or the active photo, and an `id` that wasn't a whole number
(`"id": "5"`) fell back to the active photo, so a call meant for one
photo acted on another:
- ids: album.addPhotos / removePhotos / toggleTarget, library.select,
develop.set, denoise.queue, faces.index, mask.applyEffect (mask ids)
and app.export;
- id: photo.relink (relinked the active photo to the file),
photo.removeRegion / setRegion, faces.setName, stack.setTop / moveUp /
moveDown / split, library.findSimilar, the photo queries,
denoise.cancel (cancelled every photo) / status and ui.render.
They now refuse such values with an error, as Session::targets already
does for the other commands (shared cmd::photo_ids / photo_id). A
missing or null value still means the selection or the active photo.
Reduced decoding is now opt-in: `files::load_vec_reduced` and the session's `reduced_loader` are used by grid
thumbnail jobs only; `load_bytes` / `load_vec` / `load_vec_pair` always decode the full image. A source developed
from a copy is marked (`DecodedSource::reduced`), so an export at a thumbnail's size reads the file again instead
of the cached thumbnail source, and Build Smart Previews never rebuilds a stale proxy from one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A badge click no longer selects the photo first: stack.toggle opts into explicit_targets, so
`ids` skip the selection guard and the command refuses ids with no stacked photo. Compare keeps
its pair. Both badges announce Expand Stack / Collapse Stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A bad `orientation` was refused only on the refit path: "current" and an
unlocked "toggle" returned before the check, so {"aspect": "current",
"orientation": "bogus"} succeeded silently, and a value that isn't a string
fell back to the default. It is parsed first now, so every path refuses a
wrong word, a number, a boolean or null, and nothing changes (CodeRabbit on
PR #2280).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
set_aspect always turned the ratio to the image's orientation, so a Custom
ratio taller than wide came out landscape on a landscape photo, and a
ratio picked after X (crop.rotateAspect) turned the crop back. A named
ratio now takes the crop's present orientation (landscape, or portrait
after X; a square crop follows the image), a custom [w, h] is used as
given, and crop.aspect takes orientation: "crop" | "given" to choose.
The window reapplies the remembered ratio to an untouched photo with
orientation "crop", as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
Found while testing crates/engine/src/cmd/cull.rs:23 (analyze: ids come from Session::targets, crates/engine/src/lib.rs:1205, which does not de-duplicate)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
GeoNames cities15000 holds towns of 15 000 people or more, so a photo in a
smaller town got the nearest larger one in City (Portofino → Rapallo,
5.3 km), another municipality. metadata.reverseGeocode takes maxDistance
(metres): past it, City and Sublocation stay empty, State and Country
still fill, and the reply names the town as nearestCity. Hostile values
are refused. The command help, the Fill Location tooltip and the parity
row say what the list holds. Default unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
Review of #2230: the skipped-image warnings were English text shown as is
in the result window. Skipped images are now data in the import and inspect
reports (`skipped`: image id and reason: missingFile, missingFolder,
missingRoot, incompletePath, masterSkipped) instead of warnings, and the
result window adds one line counting them through tr_format!, translated in
every UI language; the eight-warning cap is unchanged.
Reader-level tests now cover a missing folder and root record and a virtual
copy of a skipped master read from a catalog file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhZLBHaSnSko1eMByPibfh
Review of #1998. Each reload plan takes a ticket; apply skips a photo a
later-started reload has already been applied to, so an older read that
finishes last no longer restores the previous dimensions and content hash.
The app's background completion now commits through Session::execute_fn,
so the XMP sidecar auto-write (and the save) happen as for the command.
Regression tests: an_older_reload_does_not_replace_a_newer_one,
a_background_reload_writes_the_sidecar_with_auto_write_on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A preset without `format` lost its `bitDepth` before the call's `path` was merged in,
because it was judged against JPEG: `app.export {preset, path: a.png}` with a 16-bit
preset wrote an 8-bit PNG. Session::export_params now merges first and drops a preset's
depth only if the format written (with the caller's fallback) lacks it; a depth the call
passes is still validated. export.savePreset judges the depth at once only when the preset
has a `format`.