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) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-11 20:49:19 +11:00
co-authored by Claude Opus 5.5
parent 0ba118de92
commit 8924ea9afd
25 changed files with 416 additions and 24 deletions
+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()
File diff suppressed because one or more lines are too long