mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #947
# 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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)": "{} 枚の写真を移動しました"
|
||||
}
|
||||
|
||||
@@ -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 サイドカーをこのフォルダへ移動します。上書きはしません"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)": "Перемещено фото: {}"
|
||||
}
|
||||
|
||||
@@ -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-файлы в эту папку; ничего не перезаписывает"
|
||||
}
|
||||
|
||||
@@ -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)": "Переміщено фото: {}"
|
||||
}
|
||||
|
||||
@@ -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-файли до цієї теки; нічого не перезаписує"
|
||||
}
|
||||
|
||||
@@ -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)": "已移动 {} 张照片"
|
||||
}
|
||||
|
||||
@@ -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 附属文件移到此文件夹;绝不覆盖"
|
||||
}
|
||||
|
||||
@@ -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)": "已移動 {} 張照片"
|
||||
}
|
||||
|
||||
@@ -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 附屬檔案移至此資料夾;絕不覆寫"
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user