# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Claude
2026-10-11 12:02:51 +00:00
25 changed files with 416 additions and 24 deletions
+139
View File
@@ -197,6 +197,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",
@@ -251,6 +269,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
@@ -108,7 +108,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>,
}
@@ -597,11 +598,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
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)",
"New Folder in “{name}”": "Neuer Ordner in „{name}“",
"Moved {} photo(s)": "{} Foto(s) verschoben"
}
+7 -1
View File
@@ -2074,5 +2074,11 @@
"No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken",
"New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen",
"New Library": "Neue Bibliothek",
"Library name": "Name der Bibliothek"
"Library name": "Name der Bibliothek",
"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
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)",
"New Folder in “{name}”": "Nueva carpeta en «{name}»",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "No hay bibliotecas recientes",
"New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla",
"New Library": "Nueva biblioteca",
"Library name": "Nombre de la biblioteca"
"Library name": "Nombre de la biblioteca",
"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
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)",
"New Folder in “{name}”": "Nouveau dossier dans « {name} »",
"Moved {} photo(s)": "{} photo(s) déplacée(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "Aucune bibliothèque récente",
"New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement",
"New Library": "Nouvelle bibliothèque",
"Library name": "Nom de la bibliothèque"
"Library name": "Nom de la bibliothèque",
"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
@@ -231,5 +231,7 @@
"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}合成をキャンセルしました",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)",
"New Folder in “{name}”": "「{name}」に新規フォルダ",
"Moved {} photo(s)": "{} 枚の写真を移動しました"
}
+7 -1
View File
@@ -1918,5 +1918,11 @@
"No Recent Libraries": "最近使ったライブラリはありません",
"New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択",
"New Library": "新しいライブラリ",
"Library name": "ライブラリ名"
"Library name": "ライブラリ名",
"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
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)",
"New Folder in “{name}”": "Nova pasta em “{name}”",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "Nenhuma biblioteca recente",
"New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la",
"New Library": "Nova biblioteca",
"Library name": "Nome da biblioteca"
"Library name": "Nome da biblioteca",
"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
@@ -231,5 +231,7 @@
"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}: слияние отменено",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)",
"New Folder in “{name}”": "Новая папка в «{name}»",
"Moved {} photo(s)": "Перемещено фото: {}"
}
+7 -1
View File
@@ -1630,5 +1630,11 @@
"No Recent Libraries": "Нет недавних библиотек",
"New Library: Choose Where to Put It": "Новая библиотека: выберите расположение",
"New Library": "Новая библиотека",
"Library name": "Название библиотеки"
"Library name": "Название библиотеки",
"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
@@ -231,5 +231,7 @@
"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} скасовано",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)",
"New Folder in “{name}”": "Нова тека в «{name}»",
"Moved {} photo(s)": "Переміщено фото: {}"
}
+7 -1
View File
@@ -2110,5 +2110,11 @@
"No Recent Libraries": "Немає нещодавніх бібліотек",
"New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування",
"New Library": "Нова бібліотека",
"Library name": "Назва бібліотеки"
"Library name": "Назва бібліотеки",
"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
@@ -231,5 +231,7 @@
"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} 合并",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)",
"New Folder in “{name}”": "在“{name}”中新建文件夹",
"Moved {} photo(s)": "已移动 {} 张照片"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "没有最近的图库",
"New Library: Choose Where to Put It": "新建图库:选择存放位置",
"New Library": "新建图库",
"Library name": "图库名称"
"Library name": "图库名称",
"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
@@ -231,5 +231,7 @@
"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}合併",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)",
"New Folder in “{name}”": "在「{name}」中新增資料夾",
"Moved {} photo(s)": "已移動 {} 張照片"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "沒有最近的圖庫",
"New Library: Choose Where to Put It": "新增圖庫:選擇存放位置",
"New Library": "新增圖庫",
"Library name": "圖庫名稱"
"Library name": "圖庫名稱",
"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
@@ -984,6 +984,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
@@ -1369,6 +1369,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()
File diff suppressed because one or more lines are too long