Library: create folders on disk and move photos between folders

folder.create {parent, name} makes a folder on disk (one undo step; undo
removes it again while it is empty). photo.moveToFolder {folder, ids?}
moves the photos' files with their XMP sidecars into another folder and
relinks them as one undo step (undo moves them back); virtual copies
follow their master. It never overwrites: a name already taken in the
destination refuses the whole move with an actionable error. Missing or
offline originals and Local photos are refused. Across volumes a file is
copied, compared byte for byte and only then removed. The move holds a
row in the activity stack.

UI: New Folder on Disk… and Move Selected Photos Here on a Folders row,
Move to Folder… in the photo context menu; strings in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Andrew FrahnandClaude Opus 5.5 committed 2026-10-11 20:49:19 +11:00
1 parent 0ba118de92
commit 8924ea9afd
25 files changed
+416 -24

No files matched your search

+139
View File
@@ -195,6 +195,24 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"path": to, "relinked": n}))
}
),
cmd!(
"folder.create",
"New Folder on Disk",
[],
None,
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
always,
create_folder
),
cmd!(
"photo.moveToFolder",
"Move to Folder",
[],
None,
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
always,
move_to_folder
),
cmd!(
"library.browse",
"Browse Folder",
@@ -249,6 +267,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
}
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
match (from.is_empty(), to.is_empty()) {
(false, false) => rename_folder_on_disk(from, to),
(false, true) => remove_empty_folder(from),
(true, false) => {
if Path::new(to).exists() {
return Err(format!("{to} already exists"));
}
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
}
(true, true) => Ok(()),
}
}
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
let p = Path::new(path);
if !p.is_dir() {
return Err(format!("{path} is not a folder"));
}
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
if entries.next().is_some() {
return Err(format!("{path} is not empty"));
}
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
}
/// A folder path parameter without trailing separators (`None` when missing or blank).
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
}
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "folder.create";
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
if !Path::new(parent).is_dir() {
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
}
let path = Path::new(parent).join(name).to_string_lossy().to_string();
if Path::new(&path).exists() {
return Err(bad(C, format!("{path} already exists: choose another name")));
}
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
let _ = remove_empty_folder(&path);
return Err(e);
}
Ok(json!({"path": path}))
}
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
/// already taken in `folder` refuses the whole move.
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.moveToFolder";
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
let dir = Path::new(folder);
if !dir.is_dir() {
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
}
let ids = s.targets(p);
if ids.is_empty() {
return Err(bad(C, "no photos to move: select some or give ids"));
}
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut clashes: Vec<String> = Vec::new();
for id in ids {
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
if ph.local {
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
}
let lightcraft_catalog::Source::File { path } = &ph.source else {
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
};
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
continue; // a virtual copy of a photo already moving
}
let src = Path::new(path);
if !src.is_file() {
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
}
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
if src.parent() == Some(dir) {
continue; // already there
}
let to = dir.join(file);
let to_s = to.to_string_lossy().to_string();
if to.exists() || !taken.insert(to_s.to_lowercase()) {
clashes.push(file.to_string_lossy().to_string());
continue;
}
let name = ph.file_name.clone();
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
}
if !clashes.is_empty() {
let s_ = if clashes.len() == 1 { "" } else { "s" };
return Err(bad(
C,
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
));
}
let n = plans.len();
if n == 0 {
return Ok(json!({"moved": 0, "folder": folder}));
}
let plural = |k: usize| if k == 1 { "" } else { "s" };
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
Ok(json!({"moved": n, "folder": folder}))
}
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
// a library folder chosen at or below it follows too, as a source or as a filter
+4 -3
View File
@@ -100,7 +100,8 @@ pub struct UndoEntry {
pub label: String,
pub op: Op,
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An
/// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder).
pub folder: Option<FolderMove>,
}
@@ -586,11 +587,11 @@ impl Session {
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
let fs = rename::RealFs;
if let Some(f) = folder {
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
}
let undo_folder = || {
if let Some(f) = folder {
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
let _ = cmd::browse::folder_on_disk(&f.to, &f.from);
}
};
let moves = self.file_moves(op);
+28 -2
View File
@@ -370,7 +370,8 @@ impl MoveFs for RealFs {
let _ = std::fs::remove_file(b);
}),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
Err(_) => std::fs::rename(a, b),
Err(_) => std::fs::rename(a, b)
.or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }),
}
}
@@ -392,6 +393,24 @@ impl MoveFs for RealFs {
}
}
/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
fs.copy_no_replace(a, b)?;
let same = match (std::fs::read(a), std::fs::read(b)) {
(Ok(x), Ok(y)) => x == y,
_ => false,
};
if !same {
let _ = std::fs::remove_file(b);
return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display())));
}
std::fs::remove_file(a).inspect_err(|_| {
// the original stays: drop the copy so the file isn't in two places
let _ = std::fs::remove_file(b);
})
}
/// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive
/// volume): the rename must go through a temporary name.
fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool {
@@ -652,6 +671,13 @@ impl Session {
/// [`Session::apply_rename`] on the file system `fs`.
pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result<usize> {
self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }))
}
/// Carry out `plans` (renames or moves to another folder): move the files with their sidecars
/// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the
/// photos (and their virtual copies) at the new paths. Undo moves the files back.
pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result<usize> {
let mut ops = Vec::new();
let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect();
for p in self.catalog.photos() {
@@ -682,7 +708,7 @@ impl Session {
return Err(EngineError::Other(self.follow_stuck(&op, e, true)));
}
if n > 0
&& let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op)
&& let Err(err) = self.commit(&label(n), op)
{
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
if let Err(e) = move_all(fs, &back) {
+121
View File
@@ -564,3 +564,124 @@ fn a_label_comes_off_a_folder_whose_photos_are_gone() {
assert_eq!(s.catalog.folder_record("/pics/trip"), None);
assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos");
}
// ---- folder.create and photo.moveToFolder (issue #765)
/// A library photo of the real file `path` (written with `body`); returns its id.
fn add_file(s: &mut Session, path: &str, body: &str) -> lightcraft_catalog::PhotoId {
std::fs::write(path, body).unwrap();
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string();
let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
}
fn file_of(s: &Session, id: lightcraft_catalog::PhotoId) -> String {
match &s.catalog.photo(id).unwrap().source {
Source::File { path } => path.clone(),
_ => panic!("not a file"),
}
}
#[test]
fn a_folder_is_created_on_disk_and_undo_removes_it() {
let dir = Scratch::new("create");
let mut s = Session::new();
let r = s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap();
assert_eq!(r["path"], dir.path("picks"));
assert!(dir.0.join("picks").is_dir());
assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap_err().to_string().contains("already exists"));
assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "../x"})).is_err(), "one name, no slashes");
assert!(s.execute("folder.create", &json!({"parent": dir.path("nope"), "name": "x"})).unwrap_err().to_string().contains("not a folder"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!dir.0.join("picks").exists(), "undo removes it");
s.execute("edit.redo", &json!({})).unwrap();
assert!(dir.0.join("picks").is_dir(), "redo makes it again");
std::fs::write(dir.0.join("picks/note.txt"), "x").unwrap();
assert!(s.execute("edit.undo", &json!({})).is_err(), "a folder with something in it is never removed");
assert!(dir.0.join("picks/note.txt").is_file());
}
#[test]
fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() {
let dir = Scratch::new("move-photos");
std::fs::create_dir_all(dir.0.join("in")).unwrap();
std::fs::create_dir_all(dir.0.join("out")).unwrap();
let mut s = Session::new();
let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");
std::fs::write(dir.path("in/a.xmp"), "<x/>").unwrap();
let b = add_file(&mut s, &dir.path("in/b.jpg"), "B");
// a virtual copy shares its master's file and goes along
let vc = s.catalog.alloc_photo_id();
let p = Photo::new(vc, Source::File { path: dir.path("in/a.jpg") }, "a.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap();
assert_eq!(r["moved"], 2);
assert_eq!(std::fs::read_to_string(dir.path("out/a.jpg")).unwrap(), "A");
assert!(dir.0.join("out/a.xmp").is_file(), "the sidecar goes along");
assert!(!dir.0.join("in/a.jpg").exists() && !dir.0.join("in/a.xmp").exists());
assert_eq!(file_of(&s, a), dir.path("out/a.jpg"));
assert_eq!(file_of(&s, b), dir.path("out/b.jpg"));
assert_eq!(file_of(&s, vc), dir.path("out/a.jpg"), "the virtual copy follows");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(file_of(&s, a), dir.path("in/a.jpg"));
assert_eq!(file_of(&s, vc), dir.path("in/a.jpg"));
assert!(dir.0.join("in/a.jpg").is_file() && dir.0.join("in/a.xmp").is_file() && dir.0.join("in/b.jpg").is_file());
assert!(!dir.0.join("out/a.jpg").exists());
s.execute("edit.redo", &json!({})).unwrap();
assert!(dir.0.join("out/b.jpg").is_file());
assert_eq!(file_of(&s, b), dir.path("out/b.jpg"));
}
#[test]
fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() {
let dir = Scratch::new("move-clash");
std::fs::create_dir_all(dir.0.join("in")).unwrap();
std::fs::create_dir_all(dir.0.join("out")).unwrap();
let mut s = Session::new();
let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");
let b = add_file(&mut s, &dir.path("in/b.jpg"), "B");
std::fs::write(dir.path("out/b.jpg"), "OTHER").unwrap();
let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap_err().to_string();
assert!(e.contains("b.jpg already in") && e.contains("nothing was moved"), "{e}");
assert_eq!(std::fs::read_to_string(dir.path("out/b.jpg")).unwrap(), "OTHER");
assert!(dir.0.join("in/a.jpg").is_file() && !dir.0.join("out/a.jpg").exists(), "all or nothing");
assert_eq!(file_of(&s, a), dir.path("in/a.jpg"));
}
#[test]
fn moving_to_a_missing_folder_or_a_missing_original_is_refused() {
let dir = Scratch::new("move-missing");
std::fs::create_dir_all(dir.0.join("in")).unwrap();
let mut s = Session::new();
let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");
let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("nope")})).unwrap_err().to_string();
assert!(e.contains("not a folder"), "{e}");
std::fs::create_dir_all(dir.0.join("out")).unwrap();
std::fs::remove_file(dir.path("in/a.jpg")).unwrap();
let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("out")})).unwrap_err().to_string();
assert!(e.contains("missing or offline"), "{e}");
assert_eq!(file_of(&s, a), dir.path("in/a.jpg"));
}
#[cfg(unix)]
#[test]
fn a_destination_without_write_permission_reports_an_error_and_keeps_everything() {
use std::os::unix::fs::PermissionsExt;
let dir = Scratch::new("move-perm");
std::fs::create_dir_all(dir.0.join("in")).unwrap();
std::fs::create_dir_all(dir.0.join("ro")).unwrap();
let mut s = Session::new();
let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");
std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o555)).unwrap();
// running as root ignores permissions: nothing to check there
let root = std::fs::write(dir.0.join("ro/probe"), "x").is_ok();
if !root {
assert!(s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("ro")})).is_err());
assert!(dir.0.join("in/a.jpg").is_file());
assert_eq!(file_of(&s, a), dir.path("in/a.jpg"));
assert!(s.execute("folder.create", &json!({"parent": dir.path("ro"), "name": "x"})).is_err());
}
std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o755)).unwrap();
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "+{n} weitere",
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.",
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen"
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
"New Folder in “{name}”": "Neuer Ordner in „{name}“",
"Moved {} photo(s)": "{} Foto(s) verschoben"
}
+7 -1
View File
@@ -2039,5 +2039,11 @@
"Downloading model": "Modell wird heruntergeladen",
"Finding faces": "Gesichter werden gesucht",
"Synchronizing folder": "Ordner wird synchronisiert",
"Looking for changes": "Änderungen werden gesucht"
"Looking for changes": "Änderungen werden gesucht",
"New Folder on Disk": "Neuer Ordner auf dem Datenträger",
"New Folder on Disk…": "Neuer Ordner auf dem Datenträger…",
"Move to Folder": "In Ordner verschieben",
"Move to Folder…": "In Ordner verschieben…",
"Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "+{n} más",
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.",
"{what} merge cancelled": "Combinación {what} cancelada"
"{what} merge cancelled": "Combinación {what} cancelada",
"New Folder in “{name}”": "Nueva carpeta en «{name}»",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1884,5 +1884,11 @@
"Downloading model": "Descargando modelo",
"Finding faces": "Buscando caras",
"Synchronizing folder": "Sincronizando carpeta",
"Looking for changes": "Buscando cambios"
"Looking for changes": "Buscando cambios",
"New Folder on Disk": "Nueva carpeta en el disco",
"New Folder on Disk…": "Nueva carpeta en el disco…",
"Move to Folder": "Mover a carpeta",
"Move to Folder…": "Mover a carpeta…",
"Move Selected Photos Here": "Mover aquí las fotos seleccionadas",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "+{n} autres",
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.",
"{what} merge cancelled": "Fusion {what} annulée"
"{what} merge cancelled": "Fusion {what} annulée",
"New Folder in “{name}”": "Nouveau dossier dans « {name} »",
"Moved {} photo(s)": "{} photo(s) déplacée(s)"
}
+7 -1
View File
@@ -1884,5 +1884,11 @@
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
"Resize panels": "Resize panels",
"New Folder on Disk": "Nouveau dossier sur le disque",
"New Folder on Disk…": "Nouveau dossier sur le disque…",
"Move to Folder": "Déplacer vers un dossier",
"Move to Folder…": "Déplacer vers un dossier…",
"Move Selected Photos Here": "Déplacer ici les photos sélectionnées",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "ほか{n}件",
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。",
"{what} merge cancelled": "{what}合成をキャンセルしました"
"{what} merge cancelled": "{what}合成をキャンセルしました",
"New Folder in “{name}”": "「{name}」に新規フォルダ",
"Moved {} photo(s)": "{} 枚の写真を移動しました"
}
+7 -1
View File
@@ -1886,5 +1886,11 @@
"Downloading model": "モデルをダウンロード中",
"Finding faces": "顔を検索中",
"Synchronizing folder": "フォルダを同期中",
"Looking for changes": "変更を確認中"
"Looking for changes": "変更を確認中",
"New Folder on Disk": "ディスクに新規フォルダ",
"New Folder on Disk…": "ディスクに新規フォルダ…",
"Move to Folder": "フォルダへ移動",
"Move to Folder…": "フォルダへ移動…",
"Move Selected Photos Here": "選択した写真をここへ移動",
"Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "+{n} mais",
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.",
"{what} merge cancelled": "{what} mesclagem cancelada"
"{what} merge cancelled": "{what} mesclagem cancelada",
"New Folder in “{name}”": "Nova pasta em “{name}”",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1884,5 +1884,11 @@
"Downloading model": "Baixando modelo",
"Finding faces": "Procurando rostos",
"Synchronizing folder": "Sincronizando pasta",
"Looking for changes": "Procurando alterações"
"Looking for changes": "Procurando alterações",
"New Folder on Disk": "Nova pasta no disco",
"New Folder on Disk…": "Nova pasta no disco…",
"Move to Folder": "Mover para pasta",
"Move to Folder…": "Mover para pasta…",
"Move Selected Photos Here": "Mover as fotos selecionadas para cá",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "ещё {n}",
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.",
"{what} merge cancelled": "{what}: слияние отменено"
"{what} merge cancelled": "{what}: слияние отменено",
"New Folder in “{name}”": "Новая папка в «{name}»",
"Moved {} photo(s)": "Перемещено фото: {}"
}
+7 -1
View File
@@ -1598,5 +1598,11 @@
"Finding faces": "Поиск лиц",
"Quit Anyway": "Всё равно выйти",
"Synchronizing folder": "Синхронизация папки",
"Looking for changes": "Поиск изменений"
"Looking for changes": "Поиск изменений",
"New Folder on Disk": "Новая папка на диске",
"New Folder on Disk…": "Новая папка на диске…",
"Move to Folder": "Переместить в папку",
"Move to Folder…": "Переместить в папку…",
"Move Selected Photos Here": "Переместить выбранные фото сюда",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "+{n} ще",
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.",
"{what} merge cancelled": "Об’єднання {what} скасовано"
"{what} merge cancelled": "Об’єднання {what} скасовано",
"New Folder in “{name}”": "Нова тека в «{name}»",
"Moved {} photo(s)": "Переміщено фото: {}"
}
+7 -1
View File
@@ -2078,5 +2078,11 @@
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
"Resize panels": "Resize panels",
"New Folder on Disk": "Нова тека на диску",
"New Folder on Disk…": "Нова тека на диску…",
"Move to Folder": "Перемістити до теки",
"Move to Folder…": "Перемістити до теки…",
"Move Selected Photos Here": "Перемістити вибрані фото сюди",
"Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "还有 {n} 项",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。",
"{what} merge cancelled": "已取消 {what} 合并"
"{what} merge cancelled": "已取消 {what} 合并",
"New Folder in “{name}”": "在“{name}”中新建文件夹",
"Moved {} photo(s)": "已移动 {} 张照片"
}
+7 -1
View File
@@ -1884,5 +1884,11 @@
"Downloading model": "正在下载模型",
"Finding faces": "正在查找人脸",
"Synchronizing folder": "正在同步文件夹",
"Looking for changes": "正在查找更改"
"Looking for changes": "正在查找更改",
"New Folder on Disk": "在磁盘上新建文件夹",
"New Folder on Disk…": "在磁盘上新建文件夹…",
"Move to Folder": "移动到文件夹",
"Move to Folder…": "移动到文件夹…",
"Move Selected Photos Here": "将所选照片移到此处",
"Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖"
}
+3 -1
View File
@@ -228,5 +228,7 @@
"+{n} more": "還有 {n} 項",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
"{what} merge cancelled": "已取消{what}合併"
"{what} merge cancelled": "已取消{what}合併",
"New Folder in “{name}”": "在「{name}」中新增資料夾",
"Moved {} photo(s)": "已移動 {} 張照片"
}
+7 -1
View File
@@ -1884,5 +1884,11 @@
"Downloading model": "正在下載模型",
"Finding faces": "正在尋找臉孔",
"Synchronizing folder": "正在同步資料夾",
"Looking for changes": "正在尋找變更"
"Looking for changes": "正在尋找變更",
"New Folder on Disk": "在磁碟上新增資料夾",
"New Folder on Disk…": "在磁碟上新增資料夾…",
"Move to Folder": "移至資料夾",
"Move to Folder…": "移至資料夾…",
"Move Selected Photos Here": "將所選照片移至此處",
"Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫"
}
+9
View File
@@ -935,6 +935,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
let _ = app.run("dialog.rename", json!({}));
}
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move to Folder…")).clicked() {
let into = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(into) = into {
match app.run("photo.moveToFolder", json!({"folder": into})) {
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])),
Err(e) => app.toast(ui.ctx(), e),
}
}
}
if ui.button(crate::i18n::tr("Reload from Disk")).clicked() {
let _ = app.run("photo.reload", json!({}));
}
+24
View File
@@ -1320,6 +1320,30 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
}
ui.close();
}
if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: crate::i18n::tr_format!("New Folder in “{name}”", name = label),
hint: "Folder name (created on disk)".into(),
value: String::new(),
command: "folder.create".into(),
params: json!({"parent": path}),
key: "name".into(),
});
ui.close();
}
let selected = app.session.selection.ids.len();
if selected > 0
&& ui
.button(crate::i18n::tr("Move Selected Photos Here"))
.on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites"))
.clicked()
{
match app.run("photo.moveToFolder", json!({"folder": path})) {
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])),
Err(e) => app.toast(ui.ctx(), e),
}
ui.close();
}
if app.services.reveal.is_some()
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
&& let Some(f) = app.services.reveal.as_mut()
+1 -1
View File
@@ -682,7 +682,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove DiLine truncated
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, tLine truncated
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |