LRC-LIB-IMPORT: copy to any folder, organise by day / month / flat, rename on import, metadata preset

library.import gains destination (copies go there instead of the library's Originals/; no
library needed), organize (date | month | flat), rename (file-name template with the rename
tokens, numbered from renameStart; copies are catalogued under their new names) and
metadataPreset. The Add Photos dialog: Copy → Copy to (Choose… / Library), Folders, Rename with
a live example, Metadata preset; batches keep the numbering going.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 21:08:33 -04:00
co-authored by Claude Opus 5.5
parent 8a3489da6b
commit 86c4048ad6
6 changed files with 286 additions and 20 deletions
+24 -2
View File
@@ -553,7 +553,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Photos…",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
always,
|s, p| {
let paths = strs(p, "paths");
@@ -577,7 +577,29 @@ pub fn specs() -> Vec<CommandSpec> {
{
return Err(bad("library.import", "album must be a regular album"));
}
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
let organize = match str_param(p, "organize") {
Some(o) => {
crate::import::Organize::parse(o).ok_or_else(|| bad("library.import", format!("unknown organize `{o}` (date|month|flat)")))?
}
None => Default::default(),
};
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &metadata_preset
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
{
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
}
let opts = crate::import::ImportOptions {
mode,
preset,
keywords: strs(p, "keywords"),
destination: str_param(p, "destination").map(str::to_string),
organize,
rename: str_param(p, "rename").map(str::to_string),
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
metadata_preset,
..Default::default()
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
+74 -10
View File
@@ -52,6 +52,39 @@ pub struct ImportOptions {
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
/// same content as one already known is still listed.
pub local: bool,
/// Copy: where the copies go (default: the library's `Originals/`).
pub destination: Option<String>,
/// Copy: the folders inside the destination.
pub organize: Organize,
/// Copy: a file-name template for the copies ([`crate::rename::expand`] tokens: `{name}`,
/// `{seq:N}`, `{date:%Y%m%d}`, `{camera}`…), numbered from `rename_start`.
pub rename: Option<String>,
pub rename_start: usize,
/// A metadata preset (by name) applied to every imported photo.
pub metadata_preset: Option<String>,
}
/// How copies are filed in the destination.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Organize {
/// `YYYY/YYYY-MM-DD/` by capture date.
#[default]
ByDay,
/// `YYYY/YYYY-MM/`.
ByMonth,
/// All in the destination itself.
Flat,
}
impl Organize {
pub fn parse(s: &str) -> Option<Organize> {
match s {
"date" | "day" | "byDay" => Some(Organize::ByDay),
"month" | "byMonth" => Some(Organize::ByMonth),
"flat" | "none" | "intoOneFolder" => Some(Organize::Flat),
_ => None,
}
}
}
/// A file found by [`scan`], for the import review.
@@ -257,13 +290,20 @@ fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
paths.iter().map(|p| probe(p)).collect()
}
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
/// Copy `src` into `root` (filed per `organize` by `date`) as `name` (default: its own name),
/// made unique with -1, -2…; returns the new path.
fn copy_into(root: &Path, src: &str, date: &str, organize: Organize, name: Option<&str>) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = lib.join("Originals").join(year).join(day);
let dir = match organize {
Organize::ByDay => root.join(year).join(day),
Organize::ByMonth => root.join(year).join(day.get(..7).unwrap_or("undated")),
Organize::Flat => root.to_path_buf(),
};
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let name = name
.map(str::to_string)
.unwrap_or_else(|| Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()));
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
None => (name.clone(), String::new()),
@@ -362,9 +402,17 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
// added file in its own storage already, so "copy" there means "add".
let mode = if mode == ImportMode::Copy && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
if mode == ImportMode::Copy && lib_dir.is_none() {
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
// where copies go: the chosen folder, else the library's Originals/
let copy_root = opts
.destination
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
if mode == ImportMode::Copy && copy_root.is_none() {
return Err(crate::EngineError::Other("copying needs a destination folder or an open library".into()));
}
let mut seq = opts.rename_start.max(1);
let files = expand(paths, lib_dir.as_deref());
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
@@ -413,9 +461,21 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
let stored = match (mode, &lib_dir) {
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
let stored = match (mode, &copy_root) {
(ImportMode::Copy, Some(root))
if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) =>
{
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
// the template sees the photo as it will be catalogued
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
q.captured = info.captured.clone();
q.meta = info.meta.clone();
let n = crate::rename::expand(t, &q, seq);
seq += 1;
n
});
match copy_into(root, &path, info.captured.as_deref().unwrap_or(&now), opts.organize, name.as_deref()) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
@@ -429,7 +489,8 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
// a copy is catalogued under its new name
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
@@ -453,6 +514,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
Err(e) => log::warn!("import {path}: XMP: {e}"),
}
}
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
}
for k in &opts.keywords {
let k = lightcraft_catalog::keywords::clean(k);
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
+45
View File
@@ -211,3 +211,48 @@ fn recently_added_covers_recent_imports_newest_first() {
assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first");
let _ = std::fs::remove_dir_all(&dir);
}
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
/// import order), and a metadata preset on every photo.
#[test]
fn copy_with_destination_organize_rename_and_metadata_preset() {
let src = temp_dir("orgsrc");
let dest = temp_dir("orgdest");
write_png(&src.join("a.png"), 1);
write_png(&src.join("b.png"), 2);
let mut s = Session::new().with_fs();
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap();
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err());
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err());
let r = s
.execute(
"library.import",
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat",
"rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 2, "{r}");
let mut names: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
names.sort();
assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names");
assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination");
assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam"));
// by month, no library needed when a destination is given
let dest2 = temp_dir("orgdest2");
write_png(&src.join("c.png"), 3);
let r = s
.execute(
"library.import",
&json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}),
)
.unwrap();
assert_eq!(ids(&r, "imported"), 1, "{r}");
let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap();
let lightcraft_catalog::Source::File { path } = &p.source else { panic!() };
let rel = Path::new(path).strip_prefix(&dest2).unwrap();
assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}");
assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7);
for d in [&src, &dest, &dest2] {
let _ = std::fs::remove_dir_all(d);
}
}
+45
View File
@@ -943,6 +943,51 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across
/// the import's batches.
#[test]
fn import_copy_renames_into_destination() {
let base = std::env::temp_dir().join(format!("lc-ui-import-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
for i in 0..10u8 {
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[i * 20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join(format!("IMG_{i:02}.png")), png).unwrap();
}
let dest_s = dest.to_string_lossy().to_string();
let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [src.to_string_lossy()]}}), t);
assert_eq!(r["result"]["candidates"], 10, "{r}");
h.settle(SETTLE);
for id in ["button:importCopy", "button:importDest"] {
let r = h.request("ui.clickWidget", json!({"id": id}), t);
assert_eq!(r["ok"], true, "{id}: {r}");
}
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
assert!(opts.copy);
assert_eq!(opts.destination, dest.to_string_lossy(), "Choose… sets the folder");
opts.organize = "flat".into();
opts.rename = "Trip-{seq:2}".into();
} else {
panic!("no import dialog");
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.import.is_none(), "finished");
let mut names: Vec<String> = std::fs::read_dir(&dest).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
names.sort();
let want: Vec<String> = (1..=10).map(|i| format!("Trip-{i:02}.png")).collect();
assert_eq!(names, want, "numbered across batches of {}", crate::import::BATCH);
let _ = std::fs::remove_dir_all(&base);
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]
+94 -4
View File
@@ -15,7 +15,7 @@ use crate::theme::Tokens;
use crate::widgets::register;
/// Files per batch (one batch per frame, so the progress window updates).
const BATCH: usize = 8;
pub(crate) const BATCH: usize = 8;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -32,6 +32,14 @@ pub struct ImportDialog {
pub preset: String,
/// Comma-separated keywords.
pub keywords: String,
/// Copy: destination folder ("" = the library's Originals/).
pub destination: String,
/// Copy: `date` (YYYY/YYYY-MM-DD), `month` or `flat`.
pub organize: String,
/// Copy: file-name template for the copies ("" = keep the names).
pub rename: String,
/// Metadata preset name ("" = none).
pub metadata_preset: String,
}
impl ImportDialog {
@@ -103,6 +111,21 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
if !d.preset.is_empty() {
params["preset"] = json!(d.preset);
}
if !d.metadata_preset.is_empty() {
params["metadataPreset"] = json!(d.metadata_preset);
}
if d.copy {
if !d.destination.trim().is_empty() {
params["destination"] = json!(d.destination.trim());
}
if !d.organize.is_empty() {
params["organize"] = json!(d.organize);
}
if !d.rename.trim().is_empty() {
params["rename"] = json!(d.rename.trim());
params["renameStart"] = json!(1);
}
}
let total = queue.len();
app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() });
app.renderer.forget_imports();
@@ -116,6 +139,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let batch: Vec<String> = task.queue.drain(..n).collect();
let mut p = task.params.clone();
p["paths"] = json!(batch);
// renamed copies keep counting across batches
if p.get("rename").is_some() {
p["renameStart"] = json!(1 + task.imported);
}
let r = app.session.execute("library.import", &p);
let task = app.import.as_mut().expect("import task");
task.done += n;
@@ -216,12 +243,60 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() {
d.copy = false;
}
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk);
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy into library", d.copy)).inner;
if r.on_hover_text("Copy into the library's Originals/YYYY/YYYY-MM-DD/ folders").clicked() {
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy)).inner;
if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() {
d.copy = true;
}
});
if d.copy {
field(ui, "Copy to", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
{
d.destination = f;
}
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() {
d.destination.clear();
}
});
field(ui, "Folders", |ui| {
let opts = [("date", "By day (YYYY/YYYY-MM-DD)"), ("month", "By month (YYYY/YYYY-MM)"), ("flat", "Into one folder")];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
for (k, label) in opts {
if ui.selectable_label(key == k, label).clicked() {
d.organize = k.to_string();
}
}
});
});
field(ui, "Rename", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY));
register(ui.ctx(), "field:importRename", r.rect);
});
if !d.rename.trim().is_empty()
&& let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c)
{
let mut q = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(0),
lightcraft_catalog::Source::Demo { scene: 0 },
&c.name,
&c.format,
0,
0,
"",
);
q.captured = c.captured.clone();
let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1);
ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim));
}
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
@@ -270,6 +345,21 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() {
d.metadata_preset.clear();
}
for m in &app.session.metadata_presets {
if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() {
d.metadata_preset = m.name.clone();
}
}
});
});
}
field(ui, "Keywords", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY));
register(ui.ctx(), "field:importKeywords", r.rect);
+4 -4
View File
@@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) |
| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) |
| Z. Keyboard shortcuts (desktop) | 67 | 9 | 4 | 1 | 49/52 (94%) | 16/23 (70%) |
| Lightroom Classic extras | 14 | 16 | 49 | 9 | — | 13/21 (62%) |
| **Total** | 328 | 48 | 124 | 37 | 193/198 (97%) | 115/144 (80%) |
| Lightroom Classic extras | 15 | 15 | 49 | 9 | — | 14/21 (67%) |
| **Total** | 329 | 47 | 124 | 37 | 193/198 (97%) | 116/144 (81%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.4%** of 500 in-scope rows — P0 98.7% of 198 · P1 88.5% of 144 · P2 18.4% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 88.9% of 144 · P2 18.4% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -685,7 +685,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LRC-LIB-IMPORT | Full import dialog | P1 | 🟡 | `cmd:library.import` (`mode: copy`) | copy/add + duplicate skip; no file renaming, apply-during-import, destination organising, import presets |
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey` | grid, square grid, loupe, compare, survey; no people view; grid cell styles: filenames only |