diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 89613b906..c820f93e1 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -40,8 +40,10 @@ pub struct Filter { /// `dateTo` as a prefix upper bound (`2026-04` includes all of April). pub date_from: Option, pub date_to: Option, - /// Import date prefix (Recently Added). + /// Import date prefix. pub imported: Option, + /// Imported at or after this time (ISO; Recently Added). + pub imported_from: Option, /// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below. pub folder: Option, pub subfolders: bool, @@ -216,6 +218,11 @@ impl Filter { { return false; } + if let Some(d) = &self.imported_from + && p.imported.as_str() < d.as_str() + { + return false; + } if let Some(k) = &self.keyword && !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k)) { diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 949a71c3e..a455d3a54 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -213,11 +213,29 @@ pub struct Watermark { pub color: [u8; 3], /// Soft dark drop shadow for legibility on bright areas. pub shadow: bool, + /// A graphic (PNG with transparency, JPEG, …) drawn instead of the text (empty = text). + pub image: String, + /// The graphic's width as a fraction of the photo's width. + pub image_width: f32, + /// The colour space the photo is in (set by the encoder; the graphic is converted into it). + #[serde(skip)] + pub target: Option, } impl Default for Watermark { fn default() -> Self { - Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true } + Self { + text: String::new(), + size: 0.035, + opacity: 0.7, + anchor: Anchor::BottomRight, + inset: 0.025, + color: [255; 3], + shadow: true, + image: String::new(), + image_width: 0.2, + target: None, + } } } @@ -261,6 +279,10 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { /// every covered pixel (shadow pass first). fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point}; + if !wm.image.trim().is_empty() { + logo_coverage(width, height, wm, blend_px); + return; + } let text = wm.text.trim(); if text.is_empty() || width == 0 || height == 0 { return; @@ -315,6 +337,74 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: } } +/// A watermark graphic, decoded once per file (and again when the file changes). +fn watermark_logo(path: &str) -> Option> { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + static CACHE: Mutex)>>> = Mutex::new(None); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let map = c.get_or_insert_with(HashMap::new); + if let Some((m, img)) = map.get(path) + && *m == modified + { + return Some(img.clone()); + } + let bytes = std::fs::read(path).ok()?; + let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + map.insert(path.to_string(), (modified, img.clone())); + Some(img) +} + +/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to +/// `image_width` of the photo, placed like the text, blended with its alpha × opacity. +fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { + let Some(logo) = watermark_logo(wm.image.trim()) else { + log::warn!("watermark: can't read {}", wm.image); + return; + }; + if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { + return; + } + let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width); + let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height); + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { + let p = logo.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); + let inset = wm.inset.clamp(0.0, 0.4) * width.min(height) as f32; + let (w, h, lwf, lhf) = (width as f32, height as f32, lw as f32, lh as f32); + use Anchor::*; + let ox = match wm.anchor { + TopLeft | Left | BottomLeft => inset, + Top | Center | Bottom => (w - lwf) / 2.0, + TopRight | Right | BottomRight => w - inset - lwf, + } + .max(0.0) as usize; + let oy = match wm.anchor { + TopLeft | Top | TopRight => inset, + Left | Center | Right => (h - lhf) / 2.0, + BottomLeft | Bottom | BottomRight => h - inset - lhf, + } + .max(0.0) as usize; + let space = wm.target.unwrap_or(OutputSpace::Srgb); + let op = wm.opacity.clamp(0.0, 1.0); + for y in 0..lh { + for x in 0..lw { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let (px, py) = (ox + x, oy + y); + if a <= 1e-4 || px >= width || py >= height { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend_px(px, py, a * op, srgb8_in(space, c)); + } + } +} + /// Which metadata is embedded in exported files. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -430,7 +520,7 @@ impl ExportOptions { Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(), _ => None, } - .filter(|w: &Watermark| !w.text.trim().is_empty()), + .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), } @@ -648,7 +738,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada output_sharpen(&mut img, o.sharpen, o.sharpen_amount); let space = o.effective_space(); if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() }; + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; draw_watermark(&mut img, &wm); } let profile = icc::write_named(named_space(space)); @@ -732,7 +822,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) let mut img = img.clone(); output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), ..wm.clone() }; + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; draw_watermark_deep(&mut img, &wm); } let profile = match img.samples { @@ -1108,6 +1198,30 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + #[test] + fn graphic_watermark_is_placed_scaled_and_blended() { + let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo_path = dir.join("logo.png"); + // 40×20: left half opaque red, right half fully transparent + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 255, 0, 0] }); + let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap(); + std::fs::write(&logo_path, png).unwrap(); + let mut img = Rgba8::new(400, 300); + img.data.iter_mut().for_each(|p| *p = [0, 0, 0, 255]); + let wm = Watermark { image: logo_path.to_string_lossy().into(), image_width: 0.25, opacity: 1.0, inset: 0.0, ..Default::default() }; + draw_watermark(&mut img, &wm); + // 100 px wide × 50 px high in the bottom-right corner + assert!(img.get(310, 280)[0] > 200, "opaque half: {:?}", img.get(310, 280)); + assert_eq!(img.get(390, 280), [0, 0, 0, 255], "transparent half leaves the photo"); + assert_eq!(img.get(310, 240), [0, 0, 0, 255], "above the logo"); + assert_eq!(img.get(10, 10), [0, 0, 0, 255]); + // an image-only watermark survives the params (no text needed) + let o = ExportOptions::from_json(&json!({"watermark": {"image": logo_path.to_string_lossy(), "imageWidth": 0.1}})); + assert_eq!(o.watermark.as_ref().map(|w| w.image_width), Some(0.1)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn naming_and_params() { let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 855899a89..ec0514776 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -385,6 +385,14 @@ impl Session { self.visible.reverse(); } } + if self.source == LibrarySource::RecentlyAdded { + // newest import first, whatever the sort (the grid groups by import day) + let cat = &self.catalog; + self.visible.sort_by(|a, b| { + let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); + key(b).cmp(&key(a)).then(a.cmp(b)) + }); + } if self.source == LibrarySource::Missing { let missing: std::collections::HashSet = cmd::missing::missing(self).into_iter().map(|m| m.0).collect(); self.visible.retain(|id| missing.contains(id)); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 454c053d2..46e8cbebd 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -185,3 +185,29 @@ fn missing_files_are_found_and_relinked() { assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn recently_added_covers_recent_imports_newest_first() { + let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, seed: u8| { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }) + .unwrap(); + std::fs::write(dir.join(name), b).unwrap(); + dir.join(name).to_string_lossy().to_string() + }; + let mut s = Session::new().with_fs(); + // three imports: 60 days ago, 10 days ago, today + for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] { + let path = png(name, seed); + let w = when.to_string(); + s.clock = Box::new(move || w.clone()); + s.execute("library.import", &serde_json::json!({"paths": [path]})).unwrap(); + } + s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap(); + let names: Vec = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect(); + assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index ab4133af9..ad607d2b4 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -29,14 +29,21 @@ pub struct Browse { pub subfolders: bool, } +/// How far back Recently Added reaches from the latest import. +pub const RECENT_DAYS: i64 = 30; + impl LibrarySource { pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter { let mut f = f.clone(); match self { LibrarySource::All => {} + // everything imported in the 30 days up to the latest import (by import, newest first) LibrarySource::RecentlyAdded => { - let latest = cat.photos().map(|p| p.imported.clone()).max().unwrap_or_default(); - f.imported = Some(latest.get(..10).unwrap_or("").to_string()); + let latest = cat.photos().filter(|p| p.in_library()).map(|p| p.imported.clone()).max().unwrap_or_default(); + let from = lightcraft_catalog::stacks::iso_seconds(&latest) + .map(|s| lightcraft_catalog::dates::civil(s - RECENT_DAYS * 86_400)) + .unwrap_or_else(|| latest.get(..10).unwrap_or("").to_string()); + f.imported_from = Some(from); } LibrarySource::Album(a) => f.album = Some(*a), LibrarySource::RecentlyDeleted => f.deleted = true, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 770b69944..a434084b3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -377,9 +377,37 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); } if let Some(wm) = &mut opts.watermark { - field(ui, "Text", |ui| { - ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY)) - }); + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::new("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::new("wm-graphic"), graphic)); + if !graphic { + wm.image.clear(); + } + } + if graphic { + field(ui, "Graphic", |ui| { + let can_pick = app.services.pick_files.is_some(); + let w = ui.available_width() - if can_pick { 76.0 } else { 0.0 }; + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(w)); + if can_pick + && crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + field(ui, "Text", |ui| { + ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY)) + }); + } choices( ui, "Position", @@ -388,14 +416,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &mut wm.anchor, ); let mut size = wm.size as f64 * 100.0; - if num(ui, &WM_SIZE, &mut size) { + if !graphic && num(ui, &WM_SIZE, &mut size) { wm.size = (size / 100.0) as f32; } let mut op = wm.opacity as f64 * 100.0; if num(ui, &WM_OPACITY, &mut op) { wm.opacity = (op / 100.0) as f32; } - ui.checkbox(&mut wm.shadow, "Shadow"); + if !graphic { + ui.checkbox(&mut wm.shadow, "Shadow"); + } } } ui.add_space(4.0); @@ -758,6 +788,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex *ppi = p as u16; } } +const WM_IMAGE_WIDTH: ControlSpec = spec("export.watermarkImageWidth", "Width (% of photo)", 2.0, 100.0, 20.0, 1.0); const WM_SIZE: ControlSpec = spec("export.watermarkSize", "Size (% of short edge)", 1.0, 15.0, 3.5, 0.5); const WM_OPACITY: ControlSpec = spec("export.watermarkOpacity", "Opacity (%)", 5.0, 100.0, 70.0, 1.0); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 26d5f2bfa..8e009d8ef 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -77,7 +77,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted { Vec::new() } else { - app.session.catalog.date_runs(&ids, app.session.sort.key, by) + let key = if app.session.source == lightcraft_engine::LibrarySource::RecentlyAdded { + lightcraft_catalog::SortKey::ImportDate + } else { + app.session.sort.key + }; + app.session.catalog.date_runs(&ids, key, by) }; let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); let lay = layout(&aspects, &spans, avail_w, target, square); diff --git a/docs/parity.md b/docs/parity.md index 1069194ed..c91041195 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,7 +22,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) | -| B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) | +| B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 4 | 2 | 0 | 0 | 2/2 (100%) | 1/2 (50%) | @@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 13 | 1 | 4 | 0 | 7/7 (100%) | 6/7 (86%) | +| S. Export (EXP) | 14 | 0 | 4 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 65 | 10 | 5 | 1 | 49/52 (94%) | 14/23 (61%) | | Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) | -| **Total** | 317 | 56 | 127 | 37 | 193/198 (97%) | 105/144 (73%) | +| **Total** | 319 | 54 | 127 | 37 | 193/198 (97%) | 107/144 (74%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 84.4% of 144 · P2 17.7% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 85.1% of 144 · P2 17.7% of 158. ## Top gaps @@ -118,7 +118,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | -| LR-LIB-RECENT-ADDED | Recently added | P1 | 🟡 | `cmd:library.source` (`recentlyAdded`) | not grouped by import session | +| LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with counts; a row filters by that date | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | @@ -416,7 +416,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-HDR | HDR output | P2 | ⬜ | | | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `crates/engine/src/export.rs` (`Watermark`) | text only; no graphic watermark | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text or `image` + `imageWidth`), `crates/engine/src/export.rs` (`Watermark`) | text (size, colour, shadow) or a graphic with transparency (width as % of the photo, converted to the output colour space); position, inset, opacity | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |