diff --git a/.github/README.md b/.github/README.md index c155f8929..e7d7fa849 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,7 +31,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Pick and reject flags for culling,** separate from stars: P, X and U as in Lightroom, a flag filter, and rejects that darktable, Bridge and Lightroom understand. - **Presets at your fingertips.** Presets sit in the left sidebar as compact rows: hover to preview one on your photo, click to apply, and star favorites to pin them on top. -52 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +54 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 2a3015df3..dbd39eba3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,10 +4,11 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**52 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. +**54 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices | feature | [@laurensiusadi](https://github.com/laurensiusadi), [@yojen7](https://github.com/yojen7) | not yet offered | | Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders | feature | [@Bennyyy27](https://github.com/Bennyyy27) | not yet offered | | Faster raw decoding: highlight recovery and pixel-format conversions run on all CPU cores; the GPU adapter is logged; `rapidraw bench` measures the image pipeline without a window | performance | [@elhigu](https://github.com/elhigu) | PR [#1790](https://github.com/CyberTimon/RapidRAW/pull/1790) open | | Batch rename treats a photo as one unit: RAW+JPEG/HEIF pairs and every sidecar (.rrdata incl. virtual copies, both XMP styles, .acr, .dop, .pp3) move together, with metadata, burst and group tokens, a preview with collision checks, a rollback-safe two-phase rename and undo | feature | [@yojen7](https://github.com/yojen7), [@SandeepSubba](https://github.com/SandeepSubba) | PR [#1309](https://github.com/CyberTimon/RapidRAW/issues/1309) | @@ -63,11 +64,21 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber | Import old Lightroom `.lrtemplate` presets stored as plain Lua tables, without running any Lua | feature | [@laurensiusadi](https://github.com/laurensiusadi) | not yet offered | | Pick and reject flags, separate from star ratings: P / X / U shortcuts, badges, a flag filter (picked, unflagged, hide rejected, rejected) and Delete rejected | feature | [@lalibertemarc](https://github.com/lalibertemarc), [@yojen7](https://github.com/yojen7) | [#1599](https://github.com/CyberTimon/RapidRAW/issues/1599); [#994](https://github.com/CyberTimon/RapidRAW/issues/994); [#1057](https://github.com/CyberTimon/RapidRAW/issues/1057) | | Presets move to the left sidebar with compact rows, hover previews, favorites and folder reordering | feature | [@laurensiusadi](https://github.com/laurensiusadi) | not yet offered | +| Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool | feature | [@lalibertemarc](https://github.com/lalibertemarc) | PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) | ⚑ changes rendered output on purpose. Upstream status as of 2026-10-04. ## Details +### Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@laurensiusadi](https://github.com/laurensiusadi), [@yojen7](https://github.com/yojen7), from laurensiusadi/RapidRAW, adapted in RapidRoom with the shared read-only catalog reader and current image-aware XMP mapper +- **Upstream:** not yet offered +- **Commits:** [dda6cc5](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb) +- **Notes:** Catalogs and originals are read only. Reuses the collections root resolver and bounded Lua-table parser; caps compressed rows at 4 MiB. Preview lists converted controls, unsupported settings, missing originals and unreadable sidecars before an explicit selection is applied. Existing edits, cleared ratings, keywords, EXIF and other metadata are preserved unless the relevant replacement choice is enabled. Uses atomic sidecars, Card mode guards, stale-preview checks and stable full-ID virtual-copy identities. As-shot white balance requires an explicit history reference; no folder median is estimated. Supported nested profile tone curves use the existing mapper; Adobe profiles, local/AI masks and other unsupported fields are reported. Synthetic native/frontend coverage and private sidecar-derived synthetic-catalog calibration only; real Lightroom catalogs, desktop interaction and other platforms still need human checks. Adobe rendering parity is not promised. Adapted with Codex, with source authorship retained. + ### Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders - **Type:** feature @@ -520,3 +531,12 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber - **Upstream:** not yet offered - **Commits:** [9535884](https://github.com/laurensiusadi/RapidRAW/commit/953588443099def3fd0c5e92a55e1966d994e385), [9e478b6](https://github.com/laurensiusadi/RapidRAW/commit/9e478b66eeee63be3e79238af708916655f52125) - **Notes:** Hovering a preset previews it on the image without touching the edit, its history or sidecars; only a click applies it. Saved workspaces keep their layout: Presets only moves when it is still in its old default spot on the right. Preset preview thumbnails are gone. Enter/Space apply a focused preset. Scrollbar thumbs across the app are more subtle. + +### Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@lalibertemarc](https://github.com/lalibertemarc), from lalibertemarc/RapidRAW +- **Upstream:** PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) +- **Commits:** [879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) +- **Notes:** New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools. diff --git a/CREDITS.md b/CREDITS.md index 83cf4f444..abfc343ae 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -91,6 +91,9 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Lua-table `.lrtemplate` preset import | [@laurensiusadi](https://github.com/laurensiusadi) (Laurensius Adi), adapted with Claude Code | [laurensiusadi/RapidRAW@bb60210](https://github.com/laurensiusadi/RapidRAW/commit/bb60210c609c6939d34deba9c1043fe9d01bde92) | | Pick and reject flags | [@lalibertemarc](https://github.com/lalibertemarc), adapted by @yojen7 with Claude Code | [lalibertemarc/RapidRAW@1b4b9db](https://github.com/lalibertemarc/RapidRAW/commit/1b4b9db115f0c1aa6f12e2b4bc0515ffb7fb0eb6) | | Presets sidebar with hover previews, favorites, folder reordering | [@laurensiusadi](https://github.com/laurensiusadi) (Laurensius Adi), adapted by Claude Code | [9535884](https://github.com/laurensiusadi/RapidRAW/commit/953588443099def3fd0c5e92a55e1966d994e385), [9e478b6](https://github.com/laurensiusadi/RapidRAW/commit/9e478b66eeee63be3e79238af708916655f52125) in laurensiusadi/RapidRAW | +| Separate visibility toggles for Color Grading and Color Mixer | [@lalibertemarc](https://github.com/lalibertemarc) with Claude | [lalibertemarc/RapidRAW@879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) (upstream PR `CyberTimon/RapidRAW#1833`) | + +| Lightroom catalog photo-edit previews and selective develop/virtual-copy import | [@laurensiusadi](https://github.com/laurensiusadi) (Laurensius Adi), adapted by yojen7 with Codex | [laurensiusadi/RapidRAW@dda6cc5](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb); shares the existing catalog reader and XMP mapper | ## RapidRoom changes diff --git a/rapidroom/changes.json b/rapidroom/changes.json index ea7ca985a..c4c42eacc 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -1,6 +1,19 @@ { "$comment": "Everything RapidRoom adds on top of upstream RapidRAW. Edit this file in the same PR as the change; `node rapidroom/status.mjs` renders the README table and CHANGES.md. Fields: id, title, category (fix | feature | performance | privacy | platform | ci), landed (YYYY-MM-DD, date it reached RapidRoom main), authors (GitHub logins), source (rapidroom, or the fork it was harvested from), commits (URLs), upstream_issues and upstream_prs ('owner/repo#n'), rendering_change, notes, highlight (optional: a short, user-facing Markdown line; entries with one are listed under Highlights in the README, everything is in CHANGES.md).", "changes": [ + { + "id": "lightroom-catalog-develop-import", + "title": "Preview and selectively import Lightroom catalog photo edits, crop, straighten, rotation, star ratings and virtual copies, with unsupported-setting reports and independent replacement choices", + "category": "feature", + "landed": "2026-10-04", + "authors": ["laurensiusadi", "yojen7"], + "source": "laurensiusadi/RapidRAW, adapted in RapidRoom with the shared read-only catalog reader and current image-aware XMP mapper", + "commits": ["https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb"], + "upstream_issues": [], + "upstream_prs": [], + "rendering_change": false, + "notes": "Catalogs and originals are read only. Reuses the collections root resolver and bounded Lua-table parser; caps compressed rows at 4 MiB. Preview lists converted controls, unsupported settings, missing originals and unreadable sidecars before an explicit selection is applied. Existing edits, cleared ratings, keywords, EXIF and other metadata are preserved unless the relevant replacement choice is enabled. Uses atomic sidecars, Card mode guards, stale-preview checks and stable full-ID virtual-copy identities. As-shot white balance requires an explicit history reference; no folder median is estimated. Supported nested profile tone curves use the existing mapper; Adobe profiles, local/AI masks and other unsupported fields are reported. Synthetic native/frontend coverage and private sidecar-derived synthetic-catalog calibration only; real Lightroom catalogs, desktop interaction and other platforms still need human checks. Adobe rendering parity is not promised. Adapted with Codex, with source authorship retained." + }, { "id": "lightroom-collections-import", "title": "Import Lightroom Classic collections and collection sets from a .lrcat catalog as albums, with a preview of found and missing photos and relinking of moved root folders", @@ -726,6 +739,19 @@ "rendering_change": false, "notes": "Hovering a preset previews it on the image without touching the edit, its history or sidecars; only a click applies it. Saved workspaces keep their layout: Presets only moves when it is still in its old default spot on the right. Preset preview thumbnails are gone. Enter/Space apply a focused preset. Scrollbar thumbs across the app are more subtle.", "highlight": "**Presets at your fingertips.** Presets sit in the left sidebar as compact rows: hover to preview one on your photo, click to apply, and star favorites to pin them on top." + }, + { + "id": "color-tool-visibility", + "title": "Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool", + "category": "feature", + "landed": "2026-10-04", + "authors": ["lalibertemarc"], + "source": "lalibertemarc/RapidRAW", + "commits": ["https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f"], + "upstream_issues": [], + "upstream_prs": ["CyberTimon/RapidRAW#1833"], + "rendering_change": false, + "notes": "New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools." } ] } diff --git a/rapidroom/validation/catalog-develop/README.md b/rapidroom/validation/catalog-develop/README.md new file mode 100644 index 000000000..322f617bd --- /dev/null +++ b/rapidroom/validation/catalog-develop/README.md @@ -0,0 +1,51 @@ +# Catalog photo-edit import + +The source is Laurensius Adi's catalog importer at +[dda6cc51c6](https://github.com/laurensiusadi/RapidRAW/commit/dda6cc51c69dc6a17906609dbb667eff3507aeeb). +The RapidRoom adaptation uses the existing read-only collections catalog reader, +root relinker, bounded Lua-table parser and image-aware XMP converter. + +Open **Import Lightroom Catalog**, review the shared root-folder mappings, then +choose **Photo edits**. Review each photo's converted controls and unsupported +settings. New readable photos are selected initially; existing sidecars are not. +Choose photos explicitly and import. Saved edits and ratings have independent +replacement choices. A new virtual copy inherits unrelated master metadata +without changing the master's sidecar. Repeating the import identifies the same +copy and preserves its existing edits by default. + +Use a closed or safely copied catalog. The existing catalog reader rejects +active lock files and snapshots a readable WAL database without modifying it. +Rows are limited to 4 MiB before and after decompression. Unknown schema, missing +photos, malformed settings and unreadable saved edits are reported. Catalog, +photo and sidecar changes invalidate the preview. Atomic writes preserve +unrelated metadata and report individual write failures; the batch is not a +multi-file transaction. Card mode remains read only. + +White balance uses explicit as-shot history when available, never a folder +median. Crop and straighten use the current converter. Catalog rotations are +supported relative to source EXIF for non-mirrored orientations; unknown or +mirrored geometry is omitted and reported. Nested profile luma curves use the +existing composition mapper, but profile color/LUTs, local and AI masks, virtual +copy titles and other unsupported settings are shown as not transferred. No +Adobe rendering parity is promised. + +## Verification and limits + +Native fixtures cover read-only previews, selection, preserved metadata and +cleared ratings, independent replacement, stale previews, repeated virtual +copies with formerly colliding IDs, compressed and malformed rows, portrait +crop/rotation, unsupported fields, Card mode and write failures. Frontend tests +exercise the two import tabs, folder relinking, default selection, independent +replacement choices, fresh fingerprints after errors, partial reports and Card +mode. The regular 60-render corpus checks unchanged rendering behavior. + +No real Lightroom catalog was available locally. Private calibration uses a +closed **synthetic catalog with global settings derived from a real Lightroom +sidecar**, on a copied portrait RAW and its Lightroom reference export. This +checks the shared mapper and rendered result, not Adobe's real catalog schema. +All private artifacts stay local and are excluded from this repository. + +A human still needs to test a real closed catalog in the desktop app, relinking +from another machine, the per-photo review and replacement flow, virtual-copy +visibility after import/restart, unsupported reports and appearance against +Lightroom, plus macOS and Windows behavior. Those checks belong in issue #63. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc957d4cb..4c5f91bd2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "chrono", "fern", "filetime", + "flate2", "futures", "fuzzy-matcher", "glam 0.33.5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da4482c46..ed386f740 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -70,6 +70,7 @@ rgb = "0.8.53" imgref = "1.12.2" sysinfo = "0.39.6" rusqlite = { version = "0.40.2", features = ["bundled"] } +flate2 = "=1.1.9" [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] trash = "5.2.6" diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 252cf4d39..d14146996 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -2110,18 +2110,24 @@ pub fn is_image_edited( bytemuck::bytes_of(¤t_adj) != bytemuck::bytes_of(&default_adj) } +fn is_section_visible(adjustments: &serde_json::Value, section: &str) -> bool { + adjustments + .get("sectionVisibility") + .and_then(|v| v.get(section)) + .and_then(|s| s.as_bool()) + .unwrap_or(true) +} + +fn is_color_tool_visible(adjustments: &serde_json::Value, tool: &str) -> bool { + is_section_visible(adjustments, "color") && is_section_visible(adjustments, tool) +} + fn get_global_adjustments_from_json( js_adjustments: &serde_json::Value, is_raw: bool, tonemapper_override: Option, ) -> GlobalAdjustments { - let visibility = js_adjustments.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(js_adjustments, section); let get_val = |section: &str, key: &str, scale: f32, default: Option| -> f32 { if is_visible(section) { @@ -2182,6 +2188,9 @@ fn get_global_adjustments_from_json( Vec::new() }; + let color_grading_visible = is_color_tool_visible(js_adjustments, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(js_adjustments, "colorMixer"); + let cg_obj = js_adjustments .get("colorGrading") .cloned() @@ -2342,32 +2351,32 @@ fn get_global_adjustments_from_json( _pad_cg2: 0.0, _pad_cg3: 0.0, _pad_cg4: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2377,7 +2386,7 @@ fn get_global_adjustments_from_json( color_calibration: color_cal_settings, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&js_adjustments.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] @@ -2412,13 +2421,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { return MaskAdjustments::default(); } - let visibility = adj.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(adj, section); let get_val = |section: &str, key: &str, scale: f32| -> f32 { if is_visible(section) { @@ -2449,6 +2452,9 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { } else { Vec::new() }; + let color_grading_visible = is_color_tool_visible(adj, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(adj, "colorMixer"); + let cg_obj = adj.get("colorGrading").cloned().unwrap_or_default(); MaskAdjustments { @@ -2485,32 +2491,32 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { hue: get_val("color", "hue", 1.0), _pad_cg1: 0.0, _pad_cg2: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2518,7 +2524,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { _pad5: 0.0, _pad6: 0.0, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&adj.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] @@ -3783,6 +3789,95 @@ mod white_balance_sample_tests { } } +#[cfg(test)] +mod color_tool_visibility_tests { + use super::*; + use serde_json::json; + + fn edited(visibility: Option) -> serde_json::Value { + let mut adj = json!({ + "saturation": 20, + "colorGrading": { + "shadows": { "hue": 200, "saturation": 40, "luminance": 0 }, + "midtones": { "hue": 30, "saturation": 10, "luminance": 5 }, + "highlights": { "hue": 60, "saturation": 25, "luminance": 0 }, + "global": { "hue": 0, "saturation": 0, "luminance": 0 }, + "blending": 70, + "balance": 15 + }, + "hsl": { "reds": { "hue": 10, "saturation": -20, "luminance": 5 } } + }); + if let Some(v) = visibility { + adj["sectionVisibility"] = v; + } + adj + } + + fn global(adj: &serde_json::Value) -> GlobalAdjustments { + get_global_adjustments_from_json(adj, true, None) + } + + fn has_grading(a: &GlobalAdjustments) -> bool { + a.color_grading_shadows.saturation != 0.0 && a.color_grading_balance != 0.0 + } + + fn has_mixer(a: &GlobalAdjustments) -> bool { + a.hsl[0].hue != 0.0 + } + + #[test] + fn absent_tool_keys_render_like_all_visible() { + let all_on = global(&edited(Some(json!({ + "basic": true, "curves": true, "color": true, + "colorGrading": true, "colorMixer": true, "details": true, "effects": true + })))); + for vis in [None, Some(json!({})), Some(json!({ "color": true }))] { + let a = global(&edited(vis)); + assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&all_on)); + } + assert!(has_grading(&all_on) && has_mixer(&all_on)); + } + + #[test] + fn tools_toggle_independently() { + let no_grading = global(&edited(Some(json!({ "colorGrading": false })))); + assert!(!has_grading(&no_grading) && has_mixer(&no_grading)); + assert_eq!(no_grading.color_grading_blending, 0.5); + + let no_mixer = global(&edited(Some(json!({ "colorMixer": false })))); + assert!(has_grading(&no_mixer) && !has_mixer(&no_mixer)); + assert!(no_mixer.saturation != 0.0); + } + + #[test] + fn color_panel_eye_bypasses_both_tools() { + let a = global(&edited(Some(json!({ + "color": false, "colorGrading": true, "colorMixer": true + })))); + assert!(!has_grading(&a) && !has_mixer(&a)); + assert_eq!(a.saturation, 0.0); + } + + #[test] + fn mask_tools_follow_the_same_rules() { + let on = get_mask_adjustments_from_json(&edited(None)); + let explicit = get_mask_adjustments_from_json(&edited(Some(json!({ "color": true })))); + assert_eq!(bytemuck::bytes_of(&on), bytemuck::bytes_of(&explicit)); + assert!(on.color_grading_shadows.saturation != 0.0 && on.hsl[0].hue != 0.0); + + let no_grading = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorGrading": false })))); + assert!(no_grading.color_grading_shadows.saturation == 0.0 && no_grading.hsl[0].hue != 0.0); + + let no_mixer = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorMixer": false })))); + assert!(no_mixer.color_grading_shadows.saturation != 0.0 && no_mixer.hsl[0].hue == 0.0); + + let parent_off = get_mask_adjustments_from_json(&edited(Some(json!({ "color": false })))); + assert!(parent_off.color_grading_shadows.saturation == 0.0 && parent_off.hsl[0].hue == 0.0); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e9ae33c6..968509e69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2396,6 +2396,8 @@ pub fn run() { file_management::get_album_images, lightroom::collections::preview_lightroom_collections, lightroom::collections::import_lightroom_collections, + lightroom::develop::preview_lightroom_develop, + lightroom::develop::import_lightroom_develop, tagging::start_background_indexing, tagging::clear_ai_tags, tagging::clear_all_tags, diff --git a/src-tauri/src/lightroom/catalog.rs b/src-tauri/src/lightroom/catalog.rs index e1968311f..f74f58b95 100644 --- a/src-tauri/src/lightroom/catalog.rs +++ b/src-tauri/src/lightroom/catalog.rs @@ -153,7 +153,7 @@ impl Catalog { } } - fn has_column(&self, table: &str, column: &str) -> bool { + pub(crate) fn has_column(&self, table: &str, column: &str) -> bool { self.connection .prepare(&format!("SELECT {column} FROM {table} LIMIT 0")) .is_ok() diff --git a/src-tauri/src/lightroom/develop.rs b/src-tauri/src/lightroom/develop.rs new file mode 100644 index 000000000..5f0c251de --- /dev/null +++ b/src-tauri/src/lightroom/develop.rs @@ -0,0 +1,1065 @@ +//! Catalog develop settings are adapted from Laurensius Adi's importer +//! (laurensiusadi/RapidRAW@dda6cc51c69dc6a17906609dbb667eff3507aeeb). +//! Catalog access/path resolution and adjustment mapping remain shared with +//! the existing collections importer and image-aware XMP mapper. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use rusqlite::types::ValueRef; +use serde::Serialize; +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::catalog::{Catalog, text}; +use crate::file_management::{is_card_read_only_path, parse_virtual_path, write_file_atomically}; +use crate::image_processing::ImageMetadata; +use crate::lrtemplate::{catalog_as_shot_white_balance, catalog_develop_to_xmp}; +use crate::preset_converter::{ + convert_xmp_sidecar_to_preset_for_image, extract_namespaced_scalar, + lightroom_settings_not_transferred, +}; + +const MAX_TEXT_BYTES: usize = 4 * 1024 * 1024; +const MAX_ROWS: usize = 50_000; +static IMPORT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopPhotoPreview { + pub id: i64, + pub path: String, + pub copy_name: String, + pub virtual_copy: bool, + pub found: bool, + pub existing_sidecar: bool, + pub existing_edits: bool, + pub rating: Option, + pub adjustments: Value, + pub unsupported: Vec, + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopPreview { + pub catalog_name: String, + pub fingerprint: String, + pub photos: Vec, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevelopImportResult { + pub imported: usize, + pub preserved: usize, + pub virtual_copies: usize, + pub errors: Vec, +} + +struct DevelopRow { + id: i64, + master: Option, + rating: Option, + orientation: String, + copy_name: String, + settings: Result, + width: Option, + height: Option, + xmp: String, +} + +struct PreparedPhoto { + preview: DevelopPhotoPreview, + sidecar: PathBuf, + sidecar_bytes: Option>, + document: Value, + inherit_from: Option<(PathBuf, Vec)>, + raw_identity: Option<(u64, u128)>, +} + +struct PreparedImport { + name: String, + fingerprint: String, + photos: Vec, +} + +fn decode_text(bytes: &[u8]) -> Result { + if bytes.len() > MAX_TEXT_BYTES { + return Err("Develop settings exceed the supported row size.".into()); + } + if let Ok(text) = std::str::from_utf8(bytes) + && text + .trim_start_matches('\u{feff}') + .trim_start() + .starts_with(['s', '<']) + { + return Ok(text.into()); + } + if bytes.len() <= 4 { + return Err("Develop settings are empty or unreadable.".into()); + } + let decoder = flate2::read::ZlibDecoder::new(&bytes[4..]); + let mut decoded = Vec::new(); + decoder + .take(MAX_TEXT_BYTES as u64 + 1) + .read_to_end(&mut decoded) + .map_err(|_| "Compressed develop settings could not be read.".to_string())?; + if decoded.len() > MAX_TEXT_BYTES { + return Err("Expanded develop settings exceed the supported row size.".into()); + } + String::from_utf8(decoded).map_err(|_| "Develop settings are not valid UTF-8.".into()) +} + +fn column(catalog: &Catalog, table: &str, alias: &str, name: &str) -> String { + if catalog.has_column(table, name) { + format!("{alias}.{name}") + } else { + "NULL".into() + } +} + +fn read_rows(catalog: &Catalog) -> Result, String> { + catalog.require_tables(&["Adobe_imageDevelopSettings"])?; + for (table, columns) in [ + ("Adobe_images", &["id_local"][..]), + ("Adobe_imageDevelopSettings", &["image", "text"][..]), + ] { + for name in columns { + if !catalog.has_column(table, name) { + return Err(format!("This catalog has no {table}.{name} column.")); + } + } + } + let additional = catalog.has_column("Adobe_AdditionalMetadata", "image") + && catalog.has_column("Adobe_AdditionalMetadata", "xmp"); + let query = format!( + "SELECT i.id_local, {}, {}, {}, {}, d.text, {}, {}, {} FROM Adobe_images i + JOIN Adobe_imageDevelopSettings d ON d.image = i.id_local {} ORDER BY i.id_local", + column(catalog, "Adobe_images", "i", "masterImage"), + column(catalog, "Adobe_images", "i", "rating"), + column(catalog, "Adobe_images", "i", "orientation"), + column(catalog, "Adobe_images", "i", "copyName"), + column(catalog, "Adobe_imageDevelopSettings", "d", "fileWidth"), + column(catalog, "Adobe_imageDevelopSettings", "d", "fileHeight"), + if additional { "m.xmp" } else { "NULL" }, + if additional { + "LEFT JOIN Adobe_AdditionalMetadata m ON m.image = i.id_local" + } else { + "" + }, + ); + let mut statement = catalog + .connection() + .prepare(&query) + .map_err(|e| e.to_string())?; + let mut rows = statement.query([]).map_err(|e| e.to_string())?; + let mut output = Vec::new(); + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + if output.len() >= MAX_ROWS { + return Err( + "This catalog has more than 50,000 develop rows; import a smaller catalog.".into(), + ); + } + let bytes = match row.get_ref(5).map_err(|e| e.to_string())? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => bytes, + _ => &[], + }; + let rating = text(row, 2) + .map_err(|e| e.to_string())? + .parse::() + .ok() + .filter(|value| { + value.is_finite() && value.fract() == 0.0 && (0.0..=5.0).contains(value) + }) + .map(|value| value as u8); + let positive_number = |index| -> Option { + text(row, index) + .ok()? + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 1.0) + }; + output.push(DevelopRow { + id: row.get(0).map_err(|e| e.to_string())?, + master: text(row, 1) + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0), + rating, + orientation: text(row, 3).map_err(|e| e.to_string())?, + copy_name: text(row, 4).map_err(|e| e.to_string())?, + settings: decode_text(bytes), + width: positive_number(6), + height: positive_number(7), + xmp: match row.get_ref(8).map_err(|e| e.to_string())? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => { + decode_text(bytes).unwrap_or_default() + } + _ => String::new(), + }, + }); + } + Ok(output) +} + +fn history_white_balance(catalog: &Catalog, id: i64) -> Option<(f64, f64)> { + if !catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "image") + || !catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "text") + { + return None; + } + let order = if catalog.has_column("Adobe_libraryImageDevelopHistoryStep", "dateCreated") { + " ORDER BY dateCreated" + } else { + "" + }; + let mut statement = catalog + .connection() + .prepare(&format!( + "SELECT text FROM Adobe_libraryImageDevelopHistoryStep WHERE image = ?1{order}" + )) + .ok()?; + let mut rows = statement.query([id]).ok()?; + while let Some(row) = rows.next().ok()? { + let bytes = match row.get_ref(0).ok()? { + ValueRef::Text(bytes) | ValueRef::Blob(bytes) => bytes, + _ => continue, + }; + if let Ok(text) = decode_text(bytes) + && let Ok(Some(white_balance)) = catalog_as_shot_white_balance(&text) + { + return Some(white_balance); + } + } + None +} + +fn rotation_steps(orientation: u16) -> Option { + match orientation { + 1 => Some(0), + 6 => Some(1), + 3 => Some(2), + 8 => Some(3), + _ => None, + } +} + +fn source_orientation(row: &DevelopRow, path: &Path) -> Option { + if let Some(value) = extract_namespaced_scalar(&row.xmp, "tiff", "Orientation") + .and_then(|v| v.parse::().ok()) + .filter(|v| (1..=8).contains(v)) + { + return Some(value); + } + let file = fs::File::open(path).ok()?; + let metadata = exif::Reader::new() + .read_from_container(&mut BufReader::new(file)) + .ok()?; + let value = metadata + .get_field(exif::Tag::Orientation, exif::In::PRIMARY) + .and_then(|v| v.value.get_uint(0)) + .unwrap_or(1); + (1..=8).contains(&value).then_some(value as u16) +} + +fn map_adjustments( + catalog: &Catalog, + row: &DevelopRow, + path: &Path, +) -> Result<(Value, Vec), String> { + let settings = row.settings.as_ref().map_err(Clone::clone)?; + let as_shot = history_white_balance(catalog, row.id) + .or_else(|| row.master.and_then(|id| history_white_balance(catalog, id))); + let converted = catalog_develop_to_xmp(settings, as_shot)?; + let mut xmp = converted.xmp; + let target_orientation = match row.orientation.as_str() { + "AB" => Some(1), + "BC" => Some(6), + "CD" => Some(3), + "DA" => Some(8), + _ => None, + }; + let source_steps = source_orientation(row, path).and_then(rotation_steps); + let target_steps = target_orientation.and_then(rotation_steps); + let geometry_supported = source_steps.is_some() && target_steps.is_some(); + let dimensions = match (row.width, row.height) { + (Some(width), Some(height)) => { + format!(" tiff:ImageWidth=\"{width}\" tiff:ImageLength=\"{height}\"") + } + _ => String::new(), + }; + if let Some(orientation) = target_orientation { + xmp = xmp.replacen(" String { + let path_text = path.to_string_lossy(); + if row.master.is_none() { + return path_text.into_owned(); + } + let identity = catalog + .path() + .canonicalize() + .unwrap_or_else(|_| catalog.path().to_path_buf()); + let hash = + blake3::hash(format!("lightroom:{}:{}", identity.to_string_lossy(), row.id).as_bytes()); + let id = Uuid::from_bytes(hash.as_bytes()[..16].try_into().expect("hash is 32 bytes")); + format!("{path_text}?vc={id}") +} + +fn sidecar_bytes(path: &Path) -> Result>, String> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("Could not read existing edits: {error}")), + } +} + +fn file_identity(path: &Path) -> Option<(u64, u128)> { + let metadata = fs::metadata(path).ok()?; + Some(( + metadata.len(), + metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(), + )) +} + +fn hash_field(hash: &mut blake3::Hasher, bytes: &[u8]) { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); +} + +fn hash_file(hash: &mut blake3::Hasher, path: &Path) -> Result<(), String> { + hash_field(hash, path.to_string_lossy().as_bytes()); + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + hash_field(hash, b"missing"); + return Ok(()); + } + Err(error) => return Err(format!("Could not inspect catalog: {error}")), + }; + let mut contents = blake3::Hasher::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = file.read(&mut buffer).map_err(|e| e.to_string())?; + if count == 0 { + break; + } + contents.update(&buffer[..count]); + } + hash_field(hash, contents.finalize().as_bytes()); + Ok(()) +} + +fn prepare(path: &Path, mappings: &HashMap) -> Result { + let catalog = Catalog::open(path)?; + let roots = catalog.resolve_roots(mappings)?; + let locations = catalog.image_locations()?; + let rows = read_rows(&catalog)?; + let mut hash = blake3::Hasher::new(); + let ordered: BTreeMap<_, _> = mappings.iter().collect(); + hash_field( + &mut hash, + &serde_json::to_vec(&ordered).map_err(|e| e.to_string())?, + ); + for suffix in ["", "-wal", "-journal"] { + let mut file = path.as_os_str().to_owned(); + file.push(suffix); + hash_file(&mut hash, Path::new(&file))?; + } + let mut photos = Vec::new(); + for row in rows { + let source = locations + .get(&row.id) + .or_else(|| row.master.and_then(|id| locations.get(&id))) + .and_then(|location| roots.file_path(location)); + let path = source.as_deref().unwrap_or_else(|| Path::new("")); + let virtual_path = virtual_path(&catalog, &row, path); + let (_, sidecar) = parse_virtual_path(&virtual_path); + let identity = file_identity(path); + let found = path.is_file() && identity.is_some(); + let mut error = (!found).then(|| { + "The original photo could not be found. Relink its root folder first.".to_string() + }); + let bytes = match sidecar_bytes(&sidecar) { + Ok(bytes) => bytes, + Err(message) => { + error = Some(message); + None + } + }; + let mut inherited = None; + let mut document = + serde_json::to_value(ImageMetadata::default()).map_err(|e| e.to_string())?; + let mut metadata = ImageMetadata::default(); + let existing = bytes.is_some(); + let document_bytes = if existing { + bytes.as_deref() + } else if row.master.is_some() && found { + let (_, physical_sidecar) = parse_virtual_path(&path.to_string_lossy()); + match sidecar_bytes(&physical_sidecar) { + Ok(Some(bytes)) => { + inherited = Some((physical_sidecar, bytes)); + inherited.as_ref().map(|(_, bytes)| bytes.as_slice()) + } + Ok(None) => None, + Err(message) => { + error = Some(message); + None + } + } + } else { + None + }; + if let Some(bytes) = document_bytes { + match serde_json::from_slice::(bytes).and_then(|value| { + serde_json::from_value::(value.clone()) + .map(|metadata| (value, metadata)) + }) { + Ok((value, saved)) => { + document = value; + metadata = saved; + } + Err(_) => { + error = Some( + "Existing edits are unreadable; repair or back them up before importing." + .into(), + ) + } + } + } + let (adjustments, unsupported) = if found { + match map_adjustments(&catalog, &row, path) { + Ok(mapped) => mapped, + Err(message) => { + error = Some(message); + (json!({}), Vec::new()) + } + } + } else { + (json!({}), Vec::new()) + }; + hash_field(&mut hash, &row.id.to_le_bytes()); + hash_field(&mut hash, virtual_path.as_bytes()); + hash_field( + &mut hash, + &serde_json::to_vec(&identity).map_err(|e| e.to_string())?, + ); + hash_field(&mut hash, bytes.as_deref().unwrap_or(b"missing")); + if let Some((path, bytes)) = &inherited { + hash_field(&mut hash, path.to_string_lossy().as_bytes()); + hash_field(&mut hash, bytes); + } + hash_field( + &mut hash, + &serde_json::to_vec(&adjustments).map_err(|e| e.to_string())?, + ); + photos.push(PreparedPhoto { + preview: DevelopPhotoPreview { + id: row.id, + path: virtual_path, + copy_name: row.copy_name, + virtual_copy: row.master.is_some(), + found, + existing_sidecar: existing, + existing_edits: existing + && metadata + .adjustments + .as_object() + .is_some_and(|map| !map.is_empty()), + rating: row.rating, + adjustments, + unsupported, + error, + }, + sidecar, + sidecar_bytes: bytes, + document, + inherit_from: inherited, + raw_identity: identity, + }); + } + let mut destinations: HashMap> = HashMap::new(); + for (index, photo) in photos.iter().enumerate() { + if photo.preview.found { + destinations + .entry(photo.sidecar.clone()) + .or_default() + .push(index); + } + } + for indices in destinations.values().filter(|indices| indices.len() > 1) { + for index in indices { + photos[*index].preview.error = Some( + "Several catalog rows map to the same edit file. Check the root mappings.".into(), + ); + } + } + Ok(PreparedImport { + name: catalog.name(), + fingerprint: hash.finalize().to_hex().to_string(), + photos, + }) +} + +impl PreparedImport { + fn into_preview(self) -> DevelopPreview { + DevelopPreview { + catalog_name: self.name, + fingerprint: self.fingerprint, + photos: self.photos.into_iter().map(|photo| photo.preview).collect(), + } + } +} + +fn apply_import( + path: &Path, + mappings: &HashMap, + fingerprint: &str, + selections: &[i64], + replace_edits: bool, + import_ratings: bool, + replace_ratings: bool, +) -> Result { + let _guard = IMPORT_LOCK + .lock() + .map_err(|_| "Catalog import lock failed.".to_string())?; + let prepared = prepare(path, mappings)?; + if prepared.fingerprint != fingerprint { + return Err("The catalog, photos, or saved edits changed after preview. Refresh the preview before importing.".into()); + } + let selected: HashSet<_> = selections.iter().copied().collect(); + if selected.len() != selections.len() { + return Err("The selection contains duplicate photo IDs.".into()); + } + let known: HashSet<_> = prepared.photos.iter().map(|p| p.preview.id).collect(); + if !selected.is_subset(&known) { + return Err("The selection contains photos outside this preview.".into()); + } + let photos: Vec<_> = prepared + .photos + .into_iter() + .filter(|p| selected.contains(&p.preview.id)) + .collect(); + for photo in &photos { + if let Some(error) = &photo.preview.error { + return Err(format!("{}: {error}", photo.preview.path)); + } + let (source, _) = parse_virtual_path(&photo.preview.path); + if is_card_read_only_path(&source) || is_card_read_only_path(&photo.sidecar) { + return Err( + "Card mode is read-only. Copy the photos to a local folder before importing edits." + .into(), + ); + } + } + let mut result = DevelopImportResult::default(); + for mut photo in photos { + let (source, _) = parse_virtual_path(&photo.preview.path); + let unchanged = sidecar_bytes(&photo.sidecar) + .is_ok_and(|bytes| bytes == photo.sidecar_bytes) + && file_identity(&source) == photo.raw_identity + && photo.inherit_from.as_ref().is_none_or(|(path, bytes)| { + sidecar_bytes(path) + .is_ok_and(|current| current.as_deref() == Some(bytes.as_slice())) + }); + if !unchanged { + result.errors.push(format!( + "{}: saved edits or the original changed; refresh the preview.", + photo.preview.path + )); + continue; + } + let mut changed = false; + if !photo.preview.existing_sidecar || replace_edits { + photo.document["adjustments"] = photo.preview.adjustments; + changed = true; + } + if import_ratings + && (!photo.preview.existing_sidecar || replace_ratings) + && let Some(rating) = photo.preview.rating + { + photo.document["rating"] = json!(rating); + photo.document["rating_is_explicit"] = json!(true); + changed = true; + } + if !changed { + result.preserved += 1; + continue; + } + let bytes = serde_json::to_vec_pretty(&photo.document).map_err(|e| e.to_string())?; + match write_file_atomically(&photo.sidecar, bytes) { + Ok(()) => { + result.imported += 1; + if photo.preview.virtual_copy && !photo.preview.existing_sidecar { + result.virtual_copies += 1; + } + } + Err(error) => result + .errors + .push(format!("{}: {error}", photo.preview.path)), + } + } + Ok(result) +} + +#[tauri::command] +pub async fn preview_lightroom_develop( + path: String, + mappings: HashMap, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + prepare(Path::new(&path), &mappings).map(PreparedImport::into_preview) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn import_lightroom_develop( + path: String, + mappings: HashMap, + fingerprint: String, + selections: Vec, + replace_edits: bool, + import_ratings: bool, + replace_ratings: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + apply_import( + Path::new(&path), + &mappings, + &fingerprint, + &selections, + replace_edits, + import_ratings, + replace_ratings, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_management::card_mode_test_support::CardMode; + use crate::lightroom::test_catalog::{TestCatalog, folder_state, touch}; + use rusqlite::params; + use std::io::Write; + + struct Library { + dir: tempfile::TempDir, + catalog: TestCatalog, + path: PathBuf, + photos: PathBuf, + mappings: HashMap, + } + + impl Library { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("source.lrcat"); + let photos = dir.path().join("photos"); + fs::create_dir(&photos).unwrap(); + let catalog = TestCatalog::create(&path); + catalog + .connection + .execute_batch( + "ALTER TABLE Adobe_images ADD COLUMN rating; + ALTER TABLE Adobe_images ADD COLUMN orientation; + CREATE TABLE Adobe_imageDevelopSettings (image, text, fileWidth, fileHeight); + CREATE TABLE Adobe_AdditionalMetadata (image, xmp); + CREATE TABLE Adobe_libraryImageDevelopHistoryStep (image, text, dateCreated);", + ) + .unwrap(); + catalog.root(1, "/old/photos/", None); + catalog.folder(1, 1, ""); + let mappings = + HashMap::from([("/old/photos/".into(), photos.to_string_lossy().into_owned())]); + Self { + dir, + catalog, + path, + photos, + mappings, + } + } + + fn photo(&self, id: i64, name: &str, settings: &[u8]) -> PathBuf { + self.catalog.image(id, 1, name, "ARW"); + let path = touch(&self.photos.join(format!("{name}.ARW"))); + self.develop(id, settings); + path + } + + fn develop(&self, id: i64, settings: &[u8]) { + self.catalog + .connection + .execute( + "UPDATE Adobe_images SET orientation='AB', rating=3 WHERE id_local=?1", + [id], + ) + .unwrap(); + self.catalog + .connection + .execute( + "INSERT INTO Adobe_imageDevelopSettings VALUES (?1, ?2, 8, 4)", + params![id, settings], + ) + .unwrap(); + self.catalog + .connection + .execute( + "INSERT INTO Adobe_AdditionalMetadata VALUES (?1, ?2)", + params![id, ""], + ) + .unwrap(); + } + + fn prepare(&self) -> PreparedImport { + prepare(&self.path, &self.mappings).unwrap() + } + + fn apply( + &self, + fingerprint: &str, + ids: &[i64], + edits: bool, + ratings: bool, + replace_ratings: bool, + ) -> Result { + apply_import( + &self.path, + &self.mappings, + fingerprint, + ids, + edits, + ratings, + replace_ratings, + ) + } + } + + const SETTINGS: &[u8] = b"s = { Exposure2012 = 0.5, FutureScalar = 7 }"; + + #[test] + fn preview_is_read_only_and_apply_requires_selected_photos() { + let _mode = CardMode::off(); + let library = Library::new(); + let first = library.photo(1, "first", SETTINGS); + let second = library.photo(2, "second", SETTINGS); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_eq!(folder_state(library.dir.path()), before); + assert_eq!(preview.photos.len(), 2); + assert_eq!( + preview.photos[0].preview.adjustments["exposure"], + json!(0.5) + ); + assert!( + preview.photos[0] + .preview + .unsupported + .contains(&"FutureScalar".into()) + ); + let result = library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap(); + assert_eq!(result.imported, 1); + let (_, saved) = parse_virtual_path(&first.to_string_lossy()); + let metadata: ImageMetadata = serde_json::from_slice(&fs::read(saved).unwrap()).unwrap(); + assert_eq!(metadata.adjustments["exposure"], json!(0.5)); + assert_eq!(metadata.rating, 3); + assert!(metadata.rating_is_explicit); + assert!(!parse_virtual_path(&second.to_string_lossy()).1.exists()); + for path in [first, second, library.path.clone()] { + assert_eq!(fs::read(&path).unwrap(), before[&path].0); + } + } + + #[test] + fn existing_edits_and_ratings_need_independent_replacement_choices() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "existing", SETTINGS); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + let original = json!({"version":1, "rating":5, "rating_is_explicit":true, + "adjustments":{"exposure":2}, "tags":["keep"], "flag":"pick", + "flag_is_explicit":true, "futureMetadata":{"keep":true}}); + fs::write(&sidecar, serde_json::to_vec(&original).unwrap()).unwrap(); + let preview = library.prepare(); + assert!(preview.photos[0].preview.existing_edits); + let result = library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap(); + assert_eq!(result.preserved, 1); + assert_eq!( + serde_json::from_slice::(&fs::read(&sidecar).unwrap()).unwrap(), + original + ); + let preview = library.prepare(); + library + .apply(&preview.fingerprint, &[1], true, true, false) + .unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&sidecar).unwrap()).unwrap(); + assert_eq!(saved["adjustments"]["exposure"], json!(0.5)); + assert_eq!(saved["rating"], json!(5)); + for key in ["tags", "flag", "flag_is_explicit", "futureMetadata"] { + assert_eq!(saved[key], original[key]); + } + let preview = library.prepare(); + library + .apply(&preview.fingerprint, &[1], false, true, true) + .unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&sidecar).unwrap()).unwrap(); + assert_eq!(saved["rating"], json!(3)); + assert_eq!(saved["adjustments"]["exposure"], json!(0.5)); + } + + #[test] + fn stale_previews_cannot_replace_newer_edits_or_catalog_rows() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "changing", SETTINGS); + let preview = library.prepare(); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + let saved = + serde_json::to_vec(&json!({"version":1,"rating":1,"adjustments":{"exposure":4}})) + .unwrap(); + fs::write(&sidecar, &saved).unwrap(); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .unwrap_err() + .contains("changed after preview") + ); + assert_eq!(fs::read(&sidecar).unwrap(), saved); + let preview = library.prepare(); + library.catalog.connection.execute("UPDATE Adobe_imageDevelopSettings SET text='s = { Exposure2012 = 1 }' WHERE image=1", []).unwrap(); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .is_err() + ); + assert_eq!(fs::read(&sidecar).unwrap(), saved); + } + + #[test] + fn virtual_copy_ids_are_stable_and_keep_master_edits_untouched() { + let _mode = CardMode::off(); + let library = Library::new(); + let master = library.photo(1, "master", SETTINGS); + library.catalog.virtual_copy(16_777_217, 1); + library.develop(16_777_217, SETTINGS); + library.catalog.virtual_copy(33_554_433, 1); + library.develop(33_554_433, SETTINGS); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_ne!( + preview.photos[1].preview.path, + preview.photos[2].preview.path + ); + assert_eq!( + preview.photos[1].preview.path, + library.prepare().photos[1].preview.path + ); + let result = library + .apply( + &preview.fingerprint, + &[16_777_217, 33_554_433], + false, + true, + false, + ) + .unwrap(); + assert_eq!(result.virtual_copies, 2); + assert!(!parse_virtual_path(&master.to_string_lossy()).1.exists()); + assert_eq!(fs::read(&master).unwrap(), before[&master].0); + let preview = library.prepare(); + let result = library + .apply( + &preview.fingerprint, + &[16_777_217, 33_554_433], + false, + true, + false, + ) + .unwrap(); + assert_eq!(result.preserved, 2); + assert_eq!(result.virtual_copies, 0); + } + + #[test] + fn compressed_rows_are_bounded_and_malformed_rows_block_a_selected_batch() { + let _mode = CardMode::off(); + let library = Library::new(); + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(SETTINGS).unwrap(); + let mut compressed = (SETTINGS.len() as u32).to_le_bytes().to_vec(); + compressed.extend(encoder.finish().unwrap()); + library.photo(1, "compressed", &compressed); + library.photo(2, "malformed", b"s = { Exposure2012 = os.execute('bad') }"); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert_eq!( + preview.photos[0].preview.adjustments["exposure"], + json!(0.5) + ); + assert!(preview.photos[1].preview.error.is_some()); + assert!( + library + .apply(&preview.fingerprint, &[1, 2], false, true, false) + .is_err() + ); + assert_eq!(folder_state(library.dir.path()), before); + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&vec![b' '; MAX_TEXT_BYTES + 1]).unwrap(); + let mut too_big = vec![0; 4]; + too_big.extend(encoder.finish().unwrap()); + assert!(decode_text(&too_big).unwrap_err().contains("Expanded")); + } + + #[test] + fn card_mode_is_checked_again_when_applying_a_preview() { + let library = Library::new(); + library.photo(1, "card", SETTINGS); + let preview = library.prepare(); + let before = folder_state(library.dir.path()); + let _mode = CardMode::on(&library.photos); + assert!( + library + .apply(&preview.fingerprint, &[1], false, true, false) + .unwrap_err() + .contains("Card mode") + ); + assert_eq!(folder_state(library.dir.path()), before); + } + + #[test] + fn portrait_crops_and_history_white_balance_use_the_shared_mapper() { + let _mode = CardMode::off(); + let library = Library::new(); + library.photo(1, "portrait", b"s = { CropLeft=0.125, CropRight=0.25, CropTop=0, CropBottom=0.25, WhiteBalance='Custom', Temperature=5550, Tint=12 }"); + library + .catalog + .connection + .execute( + "UPDATE Adobe_images SET orientation='BC' WHERE id_local=1", + [], + ) + .unwrap(); + library + .catalog + .connection + .execute( + "INSERT INTO Adobe_libraryImageDevelopHistoryStep VALUES (1, ?1, 0)", + ["s = { WhiteBalance='As Shot', Temperature=4440, Tint=-5 }"], + ) + .unwrap(); + let preview = library.prepare(); + let adjustments = &preview.photos[0].preview.adjustments; + assert_eq!(adjustments["orientationSteps"], json!(1)); + assert_eq!( + adjustments["crop"], + json!({"x":3.0,"y":1.0,"width":1.0,"height":1.0}) + ); + let expected = (1_000_000.0 / 4440.0 - 1_000_000.0 / 5550.0) / 150.0 * 100.0; + assert!((adjustments["temperature"].as_f64().unwrap() - expected).abs() < 1e-6); + assert!( + !preview.photos[0] + .preview + .unsupported + .contains(&"whiteBalance".into()) + ); + } + + #[test] + fn unreadable_existing_edits_and_missing_photos_are_never_overwritten() { + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "broken-sidecar", SETTINGS); + let (_, sidecar) = parse_virtual_path(&raw.to_string_lossy()); + fs::write(&sidecar, b"{ broken json").unwrap(); + let missing = library.photo(2, "missing", SETTINGS); + fs::remove_file(&missing).unwrap(); + let before = folder_state(library.dir.path()); + let preview = library.prepare(); + assert!(preview.photos[0].preview.error.is_some()); + assert!(!preview.photos[1].preview.found); + assert!( + library + .apply(&preview.fingerprint, &[1], true, true, true) + .is_err() + ); + assert!( + library + .apply(&preview.fingerprint, &[2], true, true, true) + .is_err() + ); + assert_eq!(folder_state(library.dir.path()), before); + } + + #[cfg(unix)] + #[test] + fn sidecar_write_failures_are_reported_without_damaging_originals() { + use std::os::unix::fs::PermissionsExt; + let _mode = CardMode::off(); + let library = Library::new(); + let raw = library.photo(1, "unwritable", SETTINGS); + let preview = library.prepare(); + let original = fs::read(&raw).unwrap(); + let permissions = fs::metadata(&library.photos).unwrap().permissions(); + fs::set_permissions(&library.photos, fs::Permissions::from_mode(0o555)).unwrap(); + let result = library.apply(&preview.fingerprint, &[1], false, true, false); + fs::set_permissions(&library.photos, permissions).unwrap(); + let result = result.unwrap(); + assert_eq!(result.imported, 0); + assert_eq!(result.errors.len(), 1); + assert!(!parse_virtual_path(&raw.to_string_lossy()).1.exists()); + assert_eq!(fs::read(&raw).unwrap(), original); + } +} diff --git a/src-tauri/src/lightroom/mod.rs b/src-tauri/src/lightroom/mod.rs index ae06f4ead..90b0e902b 100644 --- a/src-tauri/src/lightroom/mod.rs +++ b/src-tauri/src/lightroom/mod.rs @@ -1,4 +1,5 @@ pub mod catalog; pub mod collections; +pub mod develop; #[cfg(test)] mod test_catalog; diff --git a/src-tauri/src/lrtemplate.rs b/src-tauri/src/lrtemplate.rs index d14f26d59..14ed57c19 100644 --- a/src-tauri/src/lrtemplate.rs +++ b/src-tauri/src/lrtemplate.rs @@ -424,6 +424,68 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { return Err("Malformed .lrtemplate: missing `value.settings` table".to_string()); }; + settings_to_xmp(settings, root.get("title"), false) +} + +/// Catalog develop rows contain a settings table directly, rather than the +/// preset wrapper. They use the same bounded parser and XMP encoder. +pub fn catalog_develop_to_xmp( + source: &str, + as_shot: Option<(f64, f64)>, +) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err("Catalog develop row is too large".into()); + } + let mut settings = + Parser::new(source.strip_prefix('\u{feff}').unwrap_or(source)).parse_chunk()?; + if let Some((temperature, tint)) = as_shot { + if !temperature.is_finite() || temperature <= 0.0 || !tint.is_finite() { + return Err("Invalid catalog as-shot white balance".into()); + } + settings + .fields + .retain(|(key, _)| key != "AsShotTemperature" && key != "AsShotTint"); + settings + .fields + .push(("AsShotTemperature".into(), LuaValue::Num(temperature))); + settings + .fields + .push(("AsShotTint".into(), LuaValue::Num(tint))); + } + if settings.get("HasCrop").is_none() + && ["CropLeft", "CropRight", "CropTop", "CropBottom"] + .iter() + .all(|key| matches!(settings.get(key), Some(LuaValue::Num(_)))) + { + settings + .fields + .push(("HasCrop".into(), LuaValue::Bool(true))); + } + settings_to_xmp(&settings, None, true) +} + +/// Only an explicitly as-shot history row supplies a white-balance baseline. +/// No estimates are made from other photos in the folder. +pub fn catalog_as_shot_white_balance(source: &str) -> Result, String> { + if source.len() > MAX_INPUT_BYTES { + return Err("Catalog develop row is too large".into()); + } + let settings = Parser::new(source.strip_prefix('\u{feff}').unwrap_or(source)).parse_chunk()?; + if matches!(settings.get("WhiteBalance"), Some(LuaValue::Str(value)) if value.eq_ignore_ascii_case("As Shot")) + && let (Some(LuaValue::Num(temperature)), Some(LuaValue::Num(tint))) = + (settings.get("Temperature"), settings.get("Tint")) + && *temperature > 0.0 + { + return Ok(Some((*temperature, *tint))); + } + Ok(None) +} + +fn settings_to_xmp( + settings: &LuaTable, + title: Option<&LuaValue>, + report_scalars: bool, +) -> Result { let mut attributes = String::new(); let mut curves = String::new(); let mut unsupported = Vec::new(); @@ -471,6 +533,28 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { matches!(table.get("Amount"), Some(LuaValue::Num(n)) if *n <= 0.0); if !disabled { report("profileLook"); + // Reuse the mapper's profile-look luma composition while + // reporting unsupported profile color and LUT settings. + if report_scalars + && let Some(LuaValue::Table(parameters)) = table.get("Parameters") + && let Some(LuaValue::Table(curve)) = parameters.get("ToneCurvePV2012") + { + let points = curve_points("ToneCurvePV2012", curve)?; + if !points.is_empty() { + let amount = match table.get("Amount") { + Some(LuaValue::Num(value)) => *value, + _ => 1.0, + }; + curves.push_str(&format!( + "", + format_number(amount) + )); + for (x, y) in points { + curves.push_str(&format!("{x}, {y}")); + } + curves.push_str(""); + } + } } } else if MASK_KEYS.contains(&key.as_str()) { report("masks"); @@ -482,6 +566,9 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { continue; } }; + if report_scalars && !crate::preset_converter::is_mapped_xmp_scalar(key) { + report(key); + } attributes.push_str(&format!(" crs:{}=\"{}\"", key, escape_xml(&scalar))); } @@ -489,7 +576,7 @@ pub fn lrtemplate_to_xmp(source: &str) -> Result { "", attributes ); - if let Some(LuaValue::Str(title)) = root.get("title") { + if let Some(LuaValue::Str(title)) = title { let title = localized_title(title).trim(); if !title.is_empty() { xmp.push_str(&format!( @@ -510,6 +597,104 @@ mod tests { use crate::preset_converter::convert_xmp_to_preset; use serde_json::json; + #[test] + fn catalog_rows_use_the_shared_mapper_and_report_nested_settings() { + let converted = catalog_develop_to_xmp( + r#"s = { Exposure2012 = 0.5, Sharpness = 40, ColorNoiseReduction = 25, + ToneCurvePV2012 = { 0, 0, 255, 255 }, + Look = { Parameters = { Exposure2012 = 9 } }, + GradientBasedCorrections = { { LocalExposure = 1 } }, FutureScalar = 7 }"#, + None, + ) + .unwrap(); + let preset = convert_xmp_to_preset(&converted.xmp).unwrap(); + assert_eq!(preset.adjustments["exposure"], json!(0.5)); + let reference = convert_xmp_to_preset( + r#""#, + ) + .unwrap(); + assert_eq!( + preset.adjustments["sharpness"], + reference.adjustments["sharpness"] + ); + assert_eq!( + preset.adjustments["colorNoiseReduction"], + reference.adjustments["colorNoiseReduction"] + ); + assert_eq!( + preset.adjustments["curves"]["luma"], + json!([{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }]) + ); + assert_eq!( + converted.unsupported, + ["profileLook", "masks", "FutureScalar"] + ); + assert!(catalog_develop_to_xmp("s = { Exposure2012 = os.execute('cmd') }", None).is_err()); + } + + #[test] + fn catalog_nested_look_curves_reuse_the_existing_composition_mapper() { + let converted = catalog_develop_to_xmp( + "s = { Exposure2012 = 0.5, ToneCurvePV2012 = {0, 0, 255, 255}, Look = { Amount = 0.5, Parameters = { Exposure2012 = 9, ToneCurvePV2012 = {0, 0, 64, 48, 192, 208, 255, 255} } } }", + None, + ).unwrap(); + let preset = convert_xmp_to_preset(&converted.xmp).unwrap(); + let expected = convert_xmp_to_preset( + r#"0, 0255, 2550, 064, 48192, 208255, 255"#, + ).unwrap(); + assert_eq!(preset.adjustments, expected.adjustments); + assert_eq!(preset.adjustments["exposure"], json!(0.5)); + assert_eq!(converted.unsupported, ["profileLook"]); + } + + #[test] + fn catalog_white_balance_uses_an_explicit_as_shot_history_row() { + let baseline = catalog_as_shot_white_balance( + "s = { WhiteBalance = 'As Shot', Temperature = 4440, Tint = -5 }", + ) + .unwrap(); + assert_eq!(baseline, Some((4440.0, -5.0))); + assert_eq!( + catalog_as_shot_white_balance( + "s = { WhiteBalance = 'Custom', Temperature = 5550, Tint = 12 }" + ) + .unwrap(), + None + ); + let converted = catalog_develop_to_xmp( + "s = { WhiteBalance = 'Custom', Temperature = 5550, Tint = 12, AsShotTemperature = 1, AsShotTint = 1 }", + baseline, + ) + .unwrap(); + assert_eq!(converted.xmp.matches("crs:AsShotTemperature=").count(), 1); + let preset = + crate::preset_converter::convert_xmp_sidecar_to_preset(&converted.xmp).unwrap(); + let expected_temperature = (1_000_000.0 / 4440.0 - 1_000_000.0 / 5550.0) / 150.0 * 100.0; + assert!( + (preset.adjustments["temperature"].as_f64().unwrap() - expected_temperature).abs() + < 1e-6 + ); + assert!((preset.adjustments["tint"].as_f64().unwrap() - 17.0 / 150.0 * 100.0).abs() < 1e-6); + } + + #[test] + fn catalog_crops_go_through_the_existing_sidecar_geometry_mapper() { + let converted = catalog_develop_to_xmp( + "s = { CropLeft = 0.125, CropRight = 0.25, CropTop = 0, CropBottom = 0.25 }", + None, + ) + .unwrap(); + let xmp = converted.xmp.replace( + ", key: &str) -> Option { .and_then(|s| s.trim_start_matches('+').parse::().ok()) } -fn extract_namespaced_scalar(content: &str, prefix: &str, key: &str) -> Option { +pub(crate) fn extract_namespaced_scalar(content: &str, prefix: &str, key: &str) -> Option { let prefix = regex::escape(prefix); let key = regex::escape(key); let attr_pattern = format!(r#"{}:{}="([^"]*)""#, prefix, key); @@ -1267,6 +1267,107 @@ fn parse_xmp_attributes(xmp_content: &str) -> Result, St Ok(attrs) } +const BASIC_MAPPINGS: &[(&str, &str)] = &[ + ("Exposure2012", "exposure"), + ("Contrast2012", "contrast"), + ("Highlights2012", "highlights"), + ("Shadows2012", "shadows"), + ("Whites2012", "whites"), + ("Blacks2012", "blacks"), + ("Clarity2012", "clarity"), + ("Dehaze", "dehaze"), + ("Vibrance", "vibrance"), + ("Saturation", "saturation"), + ("Texture", "structure"), + ("LuminanceSmoothing", "lumaNoiseReduction"), + ("ColorNoiseReduction", "colorNoiseReduction"), + ("ChromaticAberrationRedCyan", "chromaticAberrationRedCyan"), + ( + "ChromaticAberrationBlueYellow", + "chromaticAberrationBlueYellow", + ), + ("PostCropVignetteAmount", "vignetteAmount"), + ("PostCropVignetteMidpoint", "vignetteMidpoint"), + ("PostCropVignetteFeather", "vignetteFeather"), + ("PostCropVignetteRoundness", "vignetteRoundness"), + ("GrainAmount", "grainAmount"), + ("GrainSize", "grainSize"), + ("GrainFrequency", "grainRoughness"), + ("ColorGradeBlending", "blending"), +]; + +pub(crate) fn is_mapped_xmp_scalar(key: &str) -> bool { + if BASIC_MAPPINGS.iter().any(|(name, _)| *name == key) { + return true; + } + for prefix in [ + "HueAdjustment", + "SaturationAdjustment", + "LuminanceAdjustment", + "GrayMixer", + ] { + if let Some(color) = key.strip_prefix(prefix) + && [ + "Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta", + ] + .contains(&color) + { + return true; + } + } + matches!( + key, + "Sharpness" + | "SharpenEdgeMasking" + | "Exposure" + | "Brightness" + | "Contrast" + | "Recovery" + | "FillLight" + | "Shadows" + | "Clarity" + | "Temperature" + | "Tint" + | "AsShotTemperature" + | "AsShotTint" + | "WhiteBalance" + | "IncrementalTemperature" + | "IncrementalTint" + | "ConvertToGrayscale" + | "HasCrop" + | "CropLeft" + | "CropRight" + | "CropTop" + | "CropBottom" + | "CropAngle" + | "CropConstrainAspectRatio" + | "LensProfileEnable" + | "LensProfileDistortionScale" + | "LensProfileVignettingScale" + | "AutoLateralCA" + | "SplitToningShadowHue" + | "SplitToningShadowSaturation" + | "SplitToningHighlightHue" + | "SplitToningHighlightSaturation" + | "SplitToningBalance" + | "ColorGradeMidtoneHue" + | "ColorGradeMidtoneSat" + | "ColorGradeShadowLum" + | "ColorGradeMidtoneLum" + | "ColorGradeHighlightLum" + | "ColorGradeGlobalHue" + | "ColorGradeGlobalSat" + | "ColorGradeGlobalLum" + | "Version" + | "ProcessVersion" + | "HasSettings" + | "AlreadyApplied" + | "CameraProfile" + | "ToneCurveName" + | "ToneCurveName2012" + ) +} + fn convert_xmp_to_preset_with_crop( xmp_content: &str, include_crop_transform: bool, @@ -1282,36 +1383,7 @@ fn convert_xmp_to_preset_with_crop( let mut color_grading_map = Map::new(); let mut curves_map = Map::new(); - let mappings = vec![ - ("Exposure2012", "exposure"), - ("Contrast2012", "contrast"), - ("Highlights2012", "highlights"), - ("Shadows2012", "shadows"), - ("Whites2012", "whites"), - ("Blacks2012", "blacks"), - ("Clarity2012", "clarity"), - ("Dehaze", "dehaze"), - ("Vibrance", "vibrance"), - ("Saturation", "saturation"), - ("Texture", "structure"), - ("LuminanceSmoothing", "lumaNoiseReduction"), - ("ColorNoiseReduction", "colorNoiseReduction"), - ("ChromaticAberrationRedCyan", "chromaticAberrationRedCyan"), - ( - "ChromaticAberrationBlueYellow", - "chromaticAberrationBlueYellow", - ), - ("PostCropVignetteAmount", "vignetteAmount"), - ("PostCropVignetteMidpoint", "vignetteMidpoint"), - ("PostCropVignetteFeather", "vignetteFeather"), - ("PostCropVignetteRoundness", "vignetteRoundness"), - ("GrainAmount", "grainAmount"), - ("GrainSize", "grainSize"), - ("GrainFrequency", "grainRoughness"), - ("ColorGradeBlending", "blending"), - ]; - - for (xmp_key, rr_key) in mappings { + for &(xmp_key, rr_key) in BASIC_MAPPINGS { if let Some(raw_val) = attrs.get(xmp_key) && let Some(num) = parse_num(raw_val.trim_start_matches('+')) && let Some(json_val) = num_to_json(num) diff --git a/src/components/adjustments/AdjustmentSubSection.tsx b/src/components/adjustments/AdjustmentSubSection.tsx index 5f2cf80bd..f00678bc2 100644 --- a/src/components/adjustments/AdjustmentSubSection.tsx +++ b/src/components/adjustments/AdjustmentSubSection.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; import { useShallow } from 'zustand/react/shallow'; import Text from '../ui/Text'; +import VisibilityToggle from '../ui/VisibilityToggle'; import { TextVariants } from '../../types/typography'; import { useSettingsStore } from '../../store/useSettingsStore'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -12,11 +13,21 @@ interface AdjustmentSubSectionProps { actions?: ReactNode; children: ReactNode; id: string; + isContentVisible?: boolean; + onToggleVisibility?: () => void; order: number; title: string; } -export default function AdjustmentSubSection({ actions, children, id, order, title }: AdjustmentSubSectionProps) { +export default function AdjustmentSubSection({ + actions, + children, + id, + isContentVisible = true, + onToggleVisibility, + order, + title, +}: AdjustmentSubSectionProps) { const { appSettings, handleSettingsChange } = useSettingsStore( useShallow((state) => ({ appSettings: state.appSettings, @@ -48,10 +59,22 @@ export default function AdjustmentSubSection({ actions, children, id, order, tit return (
-
- +
+ {title} + {onToggleVisibility && ( +
+ +
+ )} {actions &&
e.stopPropagation()}>{actions}
}
-
+
{children}
diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 8a604073a..b0caf055f 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -4,7 +4,13 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import Slider from '../ui/Slider'; import ColorWheel from '../ui/ColorWheel'; -import { ColorAdjustment, ColorCalibration, HueSatLum, INITIAL_ADJUSTMENTS } from '../../utils/adjustments'; +import { + ColorAdjustment, + ColorCalibration, + HueSatLum, + INITIAL_ADJUSTMENTS, + SectionVisibility, +} from '../../utils/adjustments'; import { Adjustments, ColorGrading, getAdjustmentToolOrder, getHiddenAdjustmentTools } from '../../utils/adjustments'; import { AppSettings } from '../ui/AppProperties'; import Text from '../ui/Text'; @@ -23,6 +29,8 @@ interface ColorPanelProps { appSettings: AppSettings | null; isForMask?: boolean; isWbPickerActive?: boolean; + onToggleVisibility?: (key: string) => void; + sectionVisibility?: SectionVisibility; toggleWbPicker?: () => void; onDragStateChange?: (isDragging: boolean) => void; } @@ -399,6 +407,8 @@ export default function ColorPanel({ isWbPickerActive = false, toggleWbPicker, onDragStateChange, + onToggleVisibility, + sectionVisibility, }: ColorPanelProps) { const { t } = useTranslation(); const [activeColor, setActiveColor] = useState('reds'); @@ -560,6 +570,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorGrading') && ( onToggleVisibility('colorGrading'))} order={toolOrder.indexOf('colorGrading')} title={t('adjustments.color.colorGrading')} > @@ -575,6 +587,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorMixer') && ( onToggleVisibility('colorMixer'))} order={toolOrder.indexOf('colorMixer')} title={t('adjustments.color.colorMixer')} > diff --git a/src/components/modals/AppModals.tsx b/src/components/modals/AppModals.tsx index 705c5828a..88275ae49 100644 --- a/src/components/modals/AppModals.tsx +++ b/src/components/modals/AppModals.tsx @@ -321,6 +321,7 @@ export default function AppModals(props: AppModalsProps) { /> setUI({ lightroomImportCatalog: null })} refreshAllFolderTrees={props.refreshAllFolderTrees} /> diff --git a/src/components/modals/LightroomDevelopImport.test.tsx b/src/components/modals/LightroomDevelopImport.test.tsx new file mode 100644 index 000000000..ae6bd6197 --- /dev/null +++ b/src/components/modals/LightroomDevelopImport.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { invoke, mockCommand } from '../../test/tauriMock'; +import { Invokes } from '../ui/AppProperties'; +import LightroomDevelopImport, { type DevelopPhoto, type DevelopPreview } from './LightroomDevelopImport'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root | undefined; +let container: HTMLDivElement; +afterEach(async () => { + await act(async () => root?.unmount()); + root = undefined; + container?.remove(); +}); +const photo = (id: number, extra: Partial = {}): DevelopPhoto => ({ + id, + path: `/photos/${id}.ARW`, + copyName: '', + virtualCopy: false, + found: true, + existingSidecar: false, + existingEdits: false, + rating: 3, + adjustments: { exposure: 0.5 }, + unsupported: ['profileLook'], + error: null, + ...extra, +}); +const preview: DevelopPreview = { + catalogName: 'Catalog', + fingerprint: 'snapshot-a', + photos: [ + photo(1), + photo(2, { existingSidecar: true, existingEdits: true }), + photo(3, { found: false }), + photo(4, { error: 'Unreadable saved edits' }), + ], +}; +const args = { path: '/Catalog.lrcat', mappings: {} }; +function calls(cmd: string) { + return invoke.mock.calls.filter(([name]) => name === cmd).map(([, options]) => options); +} +function button(key: string) { + const item = [...container.querySelectorAll('button')].find( + (node) => node.textContent === `modals.lightroomDevelop.${key}`, + ); + if (!item) throw new Error(`Missing ${key}`); + return item; +} +function option(key: string) { + const item = [...container.querySelectorAll('label')] + .find((node) => node.textContent === `modals.lightroomDevelop.${key}`) + ?.querySelector('input'); + if (!item) throw new Error(`Missing ${key}`); + return item; +} +async function mount(isCardMode = false) { + mockCommand(Invokes.PreviewLightroomDevelop, () => preview); + mockCommand(Invokes.ImportLightroomDevelop, () => ({ imported: 1, preserved: 0, virtualCopies: 0, errors: [] })); + const onImported = vi.fn(async () => {}), + onClose = vi.fn(), + onBusyChange = vi.fn(); + const props = { catalogPath: args.path, mappings: args.mappings, isCardMode, onImported, onClose, onBusyChange }; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root?.render()); + return props; +} + +describe('Lightroom catalog photo edits', () => { + it('previews all rows, selects only new readable photos, and imports only after an explicit click', async () => { + const props = await mount(); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + expect(container.textContent).toContain('exposure'); + expect(container.textContent).toContain('modals.lightroomDevelop.unsupported'); + expect(container.textContent).toContain('Unreadable saved edits'); + expect(container.querySelectorAll('li input:checked')).toHaveLength(1); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([ + { + ...args, + fingerprint: 'snapshot-a', + selections: [1], + replaceEdits: false, + importRatings: true, + replaceRatings: false, + }, + ]); + expect(props.onImported).toHaveBeenCalledOnce(); + expect(props.onClose).toHaveBeenCalledOnce(); + expect(props.onBusyChange.mock.calls).toEqual([[true], [false]]); + }); + it('keeps edit and rating replacement independent, and excludes missing or malformed rows from select-all', async () => { + await mount(); + await act(async () => button('selectAvailable').click()); + await act(async () => option('replaceRatings').click()); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ + selections: [1, 2], + replaceEdits: false, + importRatings: true, + replaceRatings: true, + }); + }); + it('disables replacement ratings when rating import is off', async () => { + await mount(); + await act(async () => option('replaceRatings').click()); + await act(async () => option('importRatings').click()); + expect(option('replaceRatings').disabled).toBe(true); + await act(async () => option('replaceEdits').click()); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ + replaceEdits: true, + importRatings: false, + replaceRatings: false, + }); + }); + it('refreshes after a partial write failure and leaves the report open', async () => { + const props = await mount(); + mockCommand(Invokes.ImportLightroomDevelop, () => ({ + imported: 1, + preserved: 0, + virtualCopies: 1, + errors: ['/photos/2.ARW: Permission denied'], + })); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ + ...preview, + fingerprint: 'snapshot-b', + photos: [photo(1, { existingSidecar: true }), photo(2)], + })); + await act(async () => button('apply').click()); + expect(props.onImported).toHaveBeenCalledOnce(); + expect(props.onClose).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Permission denied'); + expect(calls(Invokes.PreviewLightroomDevelop)).toHaveLength(2); + expect(container.querySelectorAll('li input:checked')).toHaveLength(1); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[1]).toMatchObject({ fingerprint: 'snapshot-b', selections: [2] }); + }); + it('shows stale-preview errors and fetches a fresh fingerprint before retrying', async () => { + const props = await mount(); + mockCommand(Invokes.ImportLightroomDevelop, () => { + throw new Error('Preview changed; refresh.'); + }); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ ...preview, fingerprint: 'snapshot-c' })); + await act(async () => button('apply').click()); + expect(container.textContent).toContain('Preview changed; refresh.'); + expect(props.onClose).not.toHaveBeenCalled(); + expect(props.onImported).not.toHaveBeenCalled(); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[1]).toMatchObject({ fingerprint: 'snapshot-c' }); + }); + it('prevents writes in Card mode', async () => { + await mount(true); + expect(button('apply').disabled).toBe(true); + expect(option('replaceEdits').disabled).toBe(true); + expect(container.textContent).toContain('modals.lightroomDevelop.cardMode'); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + }); + it('uses relinked paths in the refreshed preview and apply request', async () => { + const props = await mount(); + const mappings = { 'C:/Pictures': '/photos' }; + await act(async () => root?.render()); + expect(calls(Invokes.PreviewLightroomDevelop)[1]).toEqual({ path: args.path, mappings }); + await act(async () => button('apply').click()); + expect(calls(Invokes.ImportLightroomDevelop)[0]).toMatchObject({ mappings }); + }); +}); diff --git a/src/components/modals/LightroomDevelopImport.tsx b/src/components/modals/LightroomDevelopImport.tsx new file mode 100644 index 000000000..40c7adebf --- /dev/null +++ b/src/components/modals/LightroomDevelopImport.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Invokes } from '../ui/AppProperties'; +import Switch from '../ui/Switch'; +import Text from '../ui/Text'; + +export interface DevelopPhoto { + id: number; + path: string; + copyName: string; + virtualCopy: boolean; + found: boolean; + existingSidecar: boolean; + existingEdits: boolean; + rating: number | null; + adjustments: Record; + unsupported: string[]; + error: string | null; +} +export interface DevelopPreview { + catalogName: string; + fingerprint: string; + photos: DevelopPhoto[]; +} +interface ImportResult { + imported: number; + preserved: number; + virtualCopies: number; + errors: string[]; +} +interface Props { + catalogPath: string; + mappings: Record; + isCardMode: boolean; + onBusyChange(busy: boolean): void; + onImported(): Promise; + onClose(): void; +} +const PAGE_SIZE = 50; +const canImport = (photo: DevelopPhoto) => photo.found && !photo.error; +const readable = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' '); + +// Keep the preview readable without exposing a JSON document as an import flow. +function adjustmentDescription(value: unknown): string { + if (typeof value === 'number') return Number(value.toFixed(3)).toLocaleString(); + if (typeof value === 'string' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(adjustmentDescription).join('; '); + if (value && typeof value === 'object') { + return Object.entries(value) + .map(([key, entry]) => `${readable(key)}: ${adjustmentDescription(entry)}`) + .join(', '); + } + return '—'; +} + +export default function LightroomDevelopImport({ + catalogPath, + mappings, + isCardMode, + onBusyChange, + onImported, + onClose, +}: Props) { + const { t } = useTranslation(); + const [preview, setPreview] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(0); + const [refresh, setRefresh] = useState(0); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [applyError, setApplyError] = useState(null); + const [result, setResult] = useState(null); + const [replaceEdits, setReplaceEdits] = useState(false); + const [importRatings, setImportRatings] = useState(true); + const [replaceRatings, setReplaceRatings] = useState(false); + + useEffect(() => { + setResult(null); + setApplyError(null); + }, [catalogPath, mappings]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setPreview(null); + setSelected(new Set()); + setPage(0); + invoke(Invokes.PreviewLightroomDevelop, { path: catalogPath, mappings }) + .then((next) => { + if (cancelled) return; + setPreview(next); + setSelected( + new Set(next.photos.filter((photo) => canImport(photo) && !photo.existingSidecar).map((photo) => photo.id)), + ); + setError(null); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [catalogPath, mappings, refresh]); + + const handleImport = async () => { + if (!preview || busy || loading || isCardMode || selected.size === 0) return; + setBusy(true); + onBusyChange(true); + setApplyError(null); + try { + const next = await invoke(Invokes.ImportLightroomDevelop, { + path: catalogPath, + mappings, + fingerprint: preview.fingerprint, + selections: [...selected], + replaceEdits, + importRatings, + replaceRatings: importRatings && replaceRatings, + }); + setResult(next); + await onImported(); + if (next.errors.length === 0) { + toast.success(t('modals.lightroomDevelop.result', { ...next })); + onClose(); + } + } catch (err) { + setApplyError(String(err)); + } finally { + setRefresh((value) => value + 1); + setBusy(false); + onBusyChange(false); + } + }; + const disabled = busy || loading || isCardMode; + const pageCount = Math.max(1, Math.ceil((preview?.photos.length ?? 0) / PAGE_SIZE)); + const unsupportedLabel = (key: string) => t(`modals.lightroomDevelop.fields.${key}`, { defaultValue: readable(key) }); + + return ( +
+ {t('modals.lightroomDevelop.hint')} + {t('modals.lightroomDevelop.preserved')} + {isCardMode && {t('modals.lightroomDevelop.cardMode')}} + {error && {error}} + {applyError && ( + + {applyError} + + )} + {result && ( +
+ {t('modals.lightroomDevelop.result', { ...result })} + {result.errors.length > 0 && ( +
    + {result.errors.map((item, index) => ( +
  • {item}
  • + ))} +
+ )} +
+ )} + + {loading && {t('modals.lightroomDevelop.reading')}} + {preview && ( + <> +
+ + + +
+
+ + + + {t('modals.lightroomDevelop.selected', { selected: selected.size, total: preview.photos.length })} + +
+ {preview.photos.length === 0 && {t('modals.lightroomDevelop.empty')}} +
    + {preview.photos.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE).map((photo) => ( +
  • + + + {photo.existingSidecar + ? t('modals.lightroomDevelop.existing') + : t('modals.lightroomDevelop.newPhoto')} + {photo.virtualCopy && ` · ${t('modals.lightroomDevelop.virtualCopy')}`} + {' · '} + {photo.rating === null + ? t('modals.lightroomDevelop.noRating') + : t('modals.lightroomDevelop.rating', { rating: photo.rating })} + + {(!photo.found || photo.error) && ( + {photo.error ?? t('modals.lightroomDevelop.missing')} + )} +
    + {t('modals.lightroomDevelop.details')} + {Object.keys(photo.adjustments).length === 0 ? ( + {t('modals.lightroomDevelop.noAdjustments')} + ) : ( +
    + {Object.entries(photo.adjustments).map(([key, value]) => ( +
    +
    {readable(key)}
    +
    {adjustmentDescription(value)}
    +
    + ))} +
    + )} +
    + {photo.unsupported.length > 0 && ( + + {t('modals.lightroomDevelop.unsupported', { + items: photo.unsupported.map(unsupportedLabel).join(', '), + })} + + )} +
  • + ))} +
+ {pageCount > 1 && ( +
+ + {t('modals.lightroomDevelop.page', { page: page + 1, total: pageCount })} + +
+ )} + + + )} +
+ ); +} diff --git a/src/components/modals/LightroomImportModal.test.tsx b/src/components/modals/LightroomImportModal.test.tsx index fe3f62c96..1f623a907 100644 --- a/src/components/modals/LightroomImportModal.test.tsx +++ b/src/components/modals/LightroomImportModal.test.tsx @@ -82,7 +82,12 @@ async function mount() { root = createRoot(container); await act(async () => root?.render( - , + {}} + />, ), ); return { handleSettingsChange, onClose, refreshAllFolderTrees, importedTree }; @@ -140,6 +145,31 @@ describe('LightroomImportModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('shares relinked roots with the photo-edit preview without writing during preview', async () => { + const { handleSettingsChange } = await mount(); + mockCommand(Invokes.PreviewLightroomDevelop, () => ({ + catalogName: 'Catalog', + fingerprint: 'preview', + photos: [], + })); + const tab = Array.from(document.body.querySelectorAll('button')).find( + (item) => item.textContent === 'modals.lightroomDevelop.title', + )!; + await act(async () => tab.click()); + expect(calls(Invokes.PreviewLightroomDevelop)[0]).toEqual({ + path: CATALOG, + mappings: { '/Volumes/Old/': '/mnt/old' }, + }); + pickFolder.mockResolvedValue('/mnt/pictures'); + await act(async () => button('relink').click()); + expect(calls(Invokes.PreviewLightroomDevelop)[1]).toEqual({ + path: CATALOG, + mappings: { '/Volumes/Old/': '/mnt/old', 'C:/Users/Benny/Pictures/': '/mnt/pictures' }, + }); + expect(calls(Invokes.ImportLightroomDevelop)).toEqual([]); + expect(handleSettingsChange).not.toHaveBeenCalled(); + }); + it('cancels without importing or saving anything', async () => { const { handleSettingsChange, onClose } = await mount(); diff --git a/src/components/modals/LightroomImportModal.tsx b/src/components/modals/LightroomImportModal.tsx index dcffc3a3d..946de9635 100644 --- a/src/components/modals/LightroomImportModal.tsx +++ b/src/components/modals/LightroomImportModal.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { CheckCircle2, CircleAlert, FolderSearch } from 'lucide-react'; import Switch from '../ui/Switch'; +import LightroomDevelopImport from './LightroomDevelopImport'; import Text from '../ui/Text'; import { TextVariants } from '../../types/typography'; import { AlbumItem, Invokes } from '../ui/AppProperties'; @@ -38,12 +39,14 @@ interface LightroomImportModalProps { catalogPath: string | null; onClose(): void; refreshAllFolderTrees(): Promise; + refreshImageList(): Promise; } export default function LightroomImportModal({ catalogPath, onClose, refreshAllFolderTrees, + refreshImageList, }: LightroomImportModalProps) { const { t } = useTranslation(); const [isMounted, setIsMounted] = useState(false); @@ -54,12 +57,14 @@ export default function LightroomImportModal({ const [isLoading, setIsLoading] = useState(false); const [isImporting, setIsImporting] = useState(false); const [addRootFolders, setAddRootFolders] = useState(false); + const [tab, setTab] = useState<'collections' | 'develop'>('collections'); const rootPaths = useLibraryStore((state) => state.rootPaths); const isCardMode = useLibraryStore((state) => state.cardBrowseRoot !== null); useEffect(() => { if (catalogPath) { setIsMounted(true); + setTab('collections'); setPreview(null); setError(null); setAddRootFolders(false); @@ -113,6 +118,25 @@ export default function LightroomImportModal({ .filter((root) => root.found && !rootPaths.includes(root.localPath)) .map((root) => root.localPath) ?? []; + const persistImportPaths = async () => { + if (!mappings) return; + const newRoots = addRootFolders && !isCardMode ? rootsToAdd : []; + const { rootPaths: currentRoots, expandedFolders, setLibrary } = useLibraryStore.getState(); + const updatedRoots = [...currentRoots, ...newRoots.filter((root) => !currentRoots.includes(root))]; + const { appSettings, handleSettingsChange } = useSettingsStore.getState(); + if (appSettings) { + await handleSettingsChange({ + ...appSettings, + lightroomPathMappings: { ...appSettings.lightroomPathMappings, ...mappings }, + ...(newRoots.length > 0 ? { rootFolders: updatedRoots } : {}), + }); + } + if (newRoots.length > 0) { + setLibrary({ rootPaths: updatedRoots, expandedFolders: new Set([...expandedFolders, ...newRoots]) }); + await refreshAllFolderTrees(); + } + }; + const handleImport = async () => { if (!catalogPath || !mappings || !preview) return; setIsImporting(true); @@ -124,21 +148,7 @@ export default function LightroomImportModal({ const albumTree = await invoke(Invokes.GetAlbums); useLibraryStore.getState().setLibrary({ albumTree }); - const newRoots = addRootFolders && !isCardMode ? rootsToAdd : []; - const { rootPaths: currentRoots, expandedFolders, setLibrary } = useLibraryStore.getState(); - const updatedRoots = [...currentRoots, ...newRoots.filter((root) => !currentRoots.includes(root))]; - const { appSettings, handleSettingsChange } = useSettingsStore.getState(); - if (appSettings) { - await handleSettingsChange({ - ...appSettings, - lightroomPathMappings: { ...appSettings.lightroomPathMappings, ...mappings }, - ...(newRoots.length > 0 ? { rootFolders: updatedRoots } : {}), - }); - } - if (newRoots.length > 0) { - setLibrary({ rootPaths: updatedRoots, expandedFolders: new Set([...expandedFolders, ...newRoots]) }); - await refreshAllFolderTrees(); - } + await persistImportPaths(); toast.success(t('contextMenus.toasts.importedLightroomCollections', { name: result.catalogName })); onClose(); } catch (err) { @@ -210,22 +220,43 @@ export default function LightroomImportModal({ {catalogPath} +
+ + +
{error && {error}} {!preview && !error && {t('modals.lightroomImport.reading')}} {preview && ( <> -
- {stats.map((stat) => ( -
- {stat.value.toLocaleString()} - {stat.label} -
- ))} -
- - {preview.replacesPreviousImport && ( + {tab === 'collections' && ( +
+ {stats.map((stat) => ( +
+ {stat.value.toLocaleString()} + {stat.label} +
+ ))} +
+ )} + {tab === 'collections' && preview.replacesPreviousImport && ( {t('modals.lightroomImport.replacesPrevious')} )} @@ -282,7 +313,7 @@ export default function LightroomImportModal({
)} - {preview.smartCollections.length > 0 && ( + {tab === 'collections' && preview.smartCollections.length > 0 && (
{t('modals.lightroomImport.smartCollections', { total: preview.smartCollections.length })} @@ -295,13 +326,13 @@ export default function LightroomImportModal({
)} - {preview.skippedOtherCount > 0 && ( + {tab === 'collections' && preview.skippedOtherCount > 0 && ( {t('modals.lightroomImport.skippedOther', { total: preview.skippedOtherCount })} )} - {preview.missingImageCount > 0 && ( + {tab === 'collections' && preview.missingImageCount > 0 && (
{t('modals.lightroomImport.missingFiles', { total: preview.missingImageCount })} @@ -332,10 +363,13 @@ export default function LightroomImportModal({
)} - {!hasContent && {t('modals.lightroomImport.nothingToImport')}} + {tab === 'collections' && !hasContent && ( + {t('modals.lightroomImport.nothingToImport')} + )} {rootsToAdd.length > 0 && !isCardMode && ( )} + {tab === 'develop' && catalogPath && mappings && ( + { + await persistImportPaths(); + await refreshImageList(); + }} + /> + )}
@@ -353,13 +400,15 @@ export default function LightroomImportModal({ > {t('modals.confirm.cancel')} - + {tab === 'collections' && ( + + )}
diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index c065eea07..ad5fd92a2 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -18,6 +18,7 @@ import { INITIAL_ADJUSTMENTS, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation } from '../../ui/AppProperties'; @@ -109,7 +110,7 @@ export default function Controls() { ...prev, sectionVisibility: { ...currentVisibility, - [sectionName]: !currentVisibility[sectionName], + [sectionName]: !(currentVisibility[sectionName] ?? true), }, }; }); @@ -169,10 +170,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -184,10 +185,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -321,6 +322,8 @@ export default function Controls() { isWbPickerActive={isWbPickerActive} toggleWbPicker={toggleWbPicker} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} /> diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index f36c94bfa..37d1031bd 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -80,6 +80,7 @@ import { MaskContainer, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation, Panel } from '../../ui/AppProperties'; @@ -2031,7 +2032,7 @@ function SettingsPanel({ const cur = container.adjustments; const vis = cur.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility; updateContainer(container.id, { - adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !vis[sectionName] } }, + adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !(vis[sectionName] ?? true) } }, }); }; @@ -2059,10 +2060,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2076,10 +2077,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2292,6 +2293,8 @@ function SettingsPanel({ isForMask={true} appSettings={appSettings} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} /> ); diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 3158253c4..944a7f21c 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -125,6 +125,8 @@ export enum Invokes { SaveAlbums = 'save_albums', AddToAlbum = 'add_to_album', GetAlbumImages = 'get_album_images', + PreviewLightroomDevelop = 'preview_lightroom_develop', + ImportLightroomDevelop = 'import_lightroom_develop', PreviewLightroomCollections = 'preview_lightroom_collections', ImportLightroomCollections = 'import_lightroom_collections', TetherListCameras = 'tether_list_cameras', diff --git a/src/components/ui/CollapsibleSection.tsx b/src/components/ui/CollapsibleSection.tsx index 3296621e3..9e55ffa49 100644 --- a/src/components/ui/CollapsibleSection.tsx +++ b/src/components/ui/CollapsibleSection.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from 'react'; -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; -import { useTranslation } from 'react-i18next'; import Text from './Text'; +import VisibilityToggle from './VisibilityToggle'; import { TextVariants, TextWeights } from '../../types/typography'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -27,7 +27,6 @@ export default function CollapsibleSection({ onToggleVisibility = () => {}, title, }: CollapsibleSectionProps) { - const { t } = useTranslation(); const { contentRef, wrapperRef } = useCollapsibleHeight(isOpen); const [isHovering, setIsHovering] = useState(false); const hoverTimeoutRef = useRef(null); @@ -49,11 +48,6 @@ export default function CollapsibleSection({ setIsHovering(false); }; - const handleVisibilityClick = (e: any) => { - e.stopPropagation(); - onToggleVisibility(); - }; - return (
{canToggleVisibility && (
- + isVisible={isContentVisible} + onToggle={onToggleVisibility} + />
)}
diff --git a/src/components/ui/VisibilityToggle.tsx b/src/components/ui/VisibilityToggle.tsx new file mode 100644 index 000000000..47a8c53e8 --- /dev/null +++ b/src/components/ui/VisibilityToggle.tsx @@ -0,0 +1,31 @@ +import { Eye, EyeOff } from 'lucide-react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +interface VisibilityToggleProps { + className?: string; + isVisible: boolean; + onToggle(): void; +} + +export default function VisibilityToggle({ className, isVisible, onToggle }: VisibilityToggleProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f29c40ccc..6ef7b700c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -159,7 +159,7 @@ "confirmDeleteGroupNested": "Confirm Delete Group & All Nested Albums", "deleteAlbum": "Delete Album", "deleteGroup": "Delete Group", - "importLightroom": "Import Lightroom Collections…", + "importLightroom": "Import Lightroom Catalog…", "lightroomCatalog": "Lightroom Catalog", "moveHere": "Move Here", "moveTo": "Move To...", @@ -1530,7 +1530,7 @@ "rootImages": "Images: {{images}}, missing: {{missing}}", "skippedOther": "Books, slideshows, prints and web galleries, not imported: {{total}}", "smartCollections": "Smart collections, not imported: {{total}}", - "title": "Import Lightroom Collections" + "title": "Import Lightroom Catalog" }, "negativeConversion": { "blueWeight": "Blue (Yellow)", @@ -1641,6 +1641,46 @@ "yAxis": "Y Axis", "zoomInTooltip": "Zoom In", "zoomOutTooltip": "Zoom Out" + }, + "lightroomDevelop": { + "title": "Photo edits", + "hint": "Review each photo before importing. Supported Lightroom settings are converted to RapidRoom controls; the appearance can differ. Originals and the catalog stay unchanged.", + "reading": "Reading photo edits…", + "selectAvailable": "Select available photos", + "clearSelection": "Clear selection", + "replaceEdits": "Replace saved RapidRoom edits on selected photos", + "importRatings": "Import catalog star ratings", + "replaceRatings": "Replace saved RapidRoom ratings on selected photos", + "preserved": "Saved RapidRoom edits and ratings are preserved unless you choose to replace them.", + "cardMode": "Card mode is read-only. Copy the photos to a local folder before importing edits.", + "selected": "Selected: {{selected}} of {{total}} photos", + "apply": "Import selected photo edits", + "details": "Changes to import", + "unsupported": "Not transferred: {{items}}", + "existing": "Saved RapidRoom metadata", + "newPhoto": "New photo", + "virtualCopy": "Virtual copy", + "rating": "Catalog rating: {{rating}} stars", + "noRating": "No catalog rating", + "missing": "Photo not found", + "previous": "Previous", + "next": "Next", + "page": "Page {{page}} of {{total}}", + "empty": "This catalog has no photo edits to import.", + "result": "Imported: {{imported}}. Preserved: {{preserved}}. New virtual copies: {{virtualCopies}}.", + "noAdjustments": "No supported adjustments.", + "fields": { + "whiteBalance": "white balance without an as-shot reference", + "profileLook": "profile color and lookup tables", + "cameraProfile": "camera profile", + "masks": "local masks", + "aiMasks": "AI masks", + "lensProfile": "Adobe lens profile", + "pointColor": "point color", + "orientationAndCrop": "unsupported orientation and crop", + "virtualCopyName": "virtual-copy name (shown here only)" + }, + "refresh": "Refresh preview" } }, "settings": { diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index e02aa5b75..15c737a85 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -385,6 +385,8 @@ export interface SectionVisibility { basic: boolean; curves: boolean; color: boolean; + colorGrading: boolean; + colorMixer: boolean; details: boolean; effects: boolean; } @@ -490,6 +492,8 @@ export const INITIAL_MASK_ADJUSTMENTS: MaskAdjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -587,6 +591,8 @@ export const INITIAL_ADJUSTMENTS: Adjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -1020,5 +1026,15 @@ export const ADJUSTMENT_SECTION_TOOLS: Record (ADJUSTMENT_SECTION_TOOLS[section] ?? []).map((tool) => tool.id); +export const showSectionAndTools = (visibility: SectionVisibility, section: string): SectionVisibility => ({ + ...visibility, + ...Object.fromEntries( + getAdjustmentSectionToolIds(section) + .filter((id) => id in visibility) + .map((id) => [id, true]), + ), + [section]: true, +}); + export const getAdjustmentToolOrder = (section: string, toolOrder?: Record): string[] => reconcileOrder(getAdjustmentSectionToolIds(section), toolOrder?.[section]);