From 5f9d07aff190201f3de50e8e119041d95357d1bc Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 29 Sep 2026 00:26:56 +0200 Subject: [PATCH] feat(fuse,core,cli,gui): move between locations without transferring content A move request with two absolute paths is resolved against every location (My files, on-demand and mirror synced folders) and made as a server-side move_node. The local side is levelled afterwards: mounts forget and re-list, a mirror folder renames its copy and baseline along or drops the copy once Drive holds it. Anything only this disk holds is refused up front: queued ops, open writes, unsynced or ignored mirror files and symlinks. The GUI Move dialog gains a top-level list of locations. --- crates/pdfs-cli/src/main.rs | 10 +- crates/pdfs-core/src/control.rs | 5 +- crates/pdfs-core/src/db/sync.rs | 82 ++++ crates/pdfs-core/src/db/tests.rs | 88 ++++ crates/pdfs-fuse/src/background.rs | 2 +- crates/pdfs-fuse/src/control.rs | 18 + crates/pdfs-fuse/src/lib.rs | 1 + crates/pdfs-fuse/src/relocate.rs | 526 ++++++++++++++++++++ crates/pdfs-fuse/src/sync.rs | 158 ++++++ crates/pdfs-gui/src/app/pages/browser.rs | 283 +++++++++-- docs/ARCHITECTURE.md | 12 + docs/CHANGELOG.md | 10 + docs/CLI.md | 2 +- docs/USER_GUIDE.md | 17 + po/de.po | 590 +++++++++++----------- po/en_GB.po | 590 +++++++++++----------- po/es.po | 590 +++++++++++----------- po/fr.po | 590 +++++++++++----------- po/it.po | 590 +++++++++++----------- po/nl.po | 590 +++++++++++----------- po/pdfs.pot | 592 ++++++++++++----------- po/pl.po | 590 +++++++++++----------- po/pt_BR.po | 590 +++++++++++----------- 23 files changed, 3853 insertions(+), 2673 deletions(-) create mode 100644 crates/pdfs-fuse/src/relocate.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index d2c367e..faa189d 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -291,10 +291,16 @@ enum Command { new_name: String, }, /// Move a file or folder into another folder via the running daemon. + /// + /// The two paths may be in different locations: My files and any synced + /// folder, on-demand or mirrored. The move happens on Proton Drive, so + /// nothing is downloaded or uploaded again, unlike `mv` between two + /// locations. Move { - /// File/folder path, inside the mountpoint or relative to it. + /// File/folder path: inside a location, or relative to the mountpoint. path: PathBuf, - /// Destination folder path, inside the mountpoint or relative to it. + /// Destination folder path: inside a location, or relative to the + /// mountpoint. new_parent: PathBuf, }, /// Trash a file or folder via the running daemon. diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index bf9ccf4..b3f453e 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -339,7 +339,10 @@ pub enum Request { /// single path component (no separators). Replies with [`Response::Ok`]. Rename { path: String, new_name: String }, /// Move a file or folder into a new parent folder. Both `path` and - /// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`]. + /// `new_parent` are mountpoint-relative, or both absolute local paths; two + /// absolute paths may lie in different locations (My files, an on-demand + /// or a mirror synced folder), and the move is still made on Proton Drive + /// without transferring content. Replies with [`Response::Ok`]. Move { path: String, new_parent: String }, /// Trash a file or folder. `path` is mountpoint-relative. Replies with /// [`Response::Ok`]. diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index 6decd57..9fe2e16 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -260,6 +260,88 @@ impl Db { Ok(()) } + /// Drop the baseline rows of `rel_path` and everything under it. + /// + /// Used when a subtree leaves a mirror folder through a move on Drive: its + /// local copy is about to be removed, and rows left behind would read as + /// "deleted locally" and trash the moved nodes at their new place. + pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "DELETE FROM sync_entry + WHERE folder_id = ?1 + AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')", + params![folder_id, rel_path], + )?; + Ok(()) + } + + /// Move the baseline rows of `from_rel` and everything under it to `to_rel` + /// in folder `to_folder`, in one transaction. + /// + /// Used when a move on Drive took a mirror subtree along with its local + /// copy: the rows still describe both sides correctly, only under a new + /// path, so the next pass on either folder has nothing to do. The two + /// folders may be the same one. + pub fn sync_entries_move( + &self, + from_folder: i64, + from_rel: &str, + to_folder: i64, + to_rel: &str, + ) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + { + let mut select = tx.prepare( + "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, + local_mtime_ns + FROM sync_entry + WHERE folder_id = ?1 + AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')", + )?; + let rows = select + .query_map(params![from_folder, from_rel], |r| { + Ok(StoredSyncEntry { + rel_path: r.get(0)?, + remote_uid: r.get(1)?, + local_mtime: r.get(2)?, + local_size: r.get(3)?, + remote_rev: r.get(4)?, + remote_hash: r.get(5)?, + local_mtime_ns: r.get(6)?, + }) + })? + .collect::>>()?; + let mut delete = + tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?; + for row in &rows { + delete.execute(params![from_folder, row.rel_path])?; + } + let mut insert = tx.prepare( + "INSERT OR REPLACE INTO sync_entry + (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, + remote_hash, local_mtime_ns) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + )?; + for row in rows { + let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]); + insert.execute(params![ + to_folder, + rel, + row.remote_uid, + row.local_mtime, + row.local_size, + row.remote_rev, + row.remote_hash, + row.local_mtime_ns, + ])?; + } + } + tx.commit()?; + Ok(()) + } + /// Drop the entire baseline for a folder. Used when flipping ondemand→mirror: /// the local tree was evicted, so the old baseline is stale and would make the /// next reconcile mistake "locally deleted" for "must re-download". Clearing it diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 0e8e04b..fa7b454 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -2920,6 +2920,94 @@ fn clearing_a_stale_pending_mode_preserves_the_newer_intent() { assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None); } +fn baseline_row(rel: &str) -> StoredSyncEntry { + StoredSyncEntry { + rel_path: rel.into(), + remote_uid: Some(format!("vol~{rel}")), + local_mtime: 1_700_000_000, + local_mtime_ns: Some(1_700_000_000_000_000_001), + local_size: 7, + remote_rev: Some("1700000000".into()), + remote_hash: Some("7".into()), + } +} + +#[test] +fn moving_a_baseline_subtree_takes_its_rows_and_leaves_name_prefixed_siblings() { + let db = Db::open_in_memory().unwrap(); + let from = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap(); + let to = db.sync_folder_add("/home/me/B", "vol~b", "share").unwrap(); + for rel in [ + "docs", + "docs/x.txt", + "docs/sub/y.txt", + "docsextra.txt", + "other", + ] { + db.sync_entry_upsert(from, &baseline_row(rel)).unwrap(); + } + db.sync_entry_upsert(to, &baseline_row("inbox")).unwrap(); + + db.sync_entries_move(from, "docs", to, "inbox/docs") + .unwrap(); + + let left = db.sync_entries(from).unwrap(); + let mut left: Vec<_> = left.keys().cloned().collect(); + left.sort(); + assert_eq!(left, ["docsextra.txt", "other"]); + + let moved = db.sync_entries(to).unwrap(); + let mut keys: Vec<_> = moved.keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + [ + "inbox", + "inbox/docs", + "inbox/docs/sub/y.txt", + "inbox/docs/x.txt" + ] + ); + // Only the path changes: the row still describes the same node on both sides. + assert_eq!( + moved["inbox/docs/x.txt"], + StoredSyncEntry { + rel_path: "inbox/docs/x.txt".into(), + ..baseline_row("docs/x.txt") + } + ); +} + +#[test] +fn moving_a_baseline_subtree_within_one_folder_renames_it() { + let db = Db::open_in_memory().unwrap(); + let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap(); + for rel in ["old", "old/f.txt", "new"] { + db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap(); + } + + db.sync_entries_move(folder, "old", folder, "new/old") + .unwrap(); + + let mut keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect(); + keys.sort(); + assert_eq!(keys, ["new", "new/old", "new/old/f.txt"]); +} + +#[test] +fn removing_a_baseline_subtree_keeps_name_prefixed_siblings() { + let db = Db::open_in_memory().unwrap(); + let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap(); + for rel in ["docs", "docs/x.txt", "docsextra.txt"] { + db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap(); + } + + db.sync_entries_remove_subtree(folder, "docs").unwrap(); + + let keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect(); + assert_eq!(keys, ["docsextra.txt"]); +} + #[test] fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index d89a9c8..3d31953 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -9,7 +9,7 @@ use crate::shutdown::Shutdown; /// /// Returns whether this mount actually held the node, so the caller can evict /// its content blob once if *any* mount did. -fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool { +pub(crate) fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool { // Capture the inode before `forget` clears the uid mapping. let child = st.by_uid.get(uid).copied(); let Some((parent, name)) = st.forget(uid) else { diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 966cf67..f7d94ef 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -974,6 +974,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + // Two absolute paths may name two different locations, which only + // `move_between` can resolve; a relative pair keeps the old meaning. + Ok(CtlRequest::Move { path, new_parent }) + if Path::new(&path).is_absolute() && Path::new(&new_parent).is_absolute() => + { + match core.move_between(Path::new(&path), Path::new(&new_parent)) { + Ok(name) => { + core.log_activity(ActivityKind::Move, &name, format!("to {new_parent}"), true); + CtlResponse::Ok { + message: format!("moved {name}"), + } + } + Err(e) => { + core.log_activity(ActivityKind::Move, &path, &e, false); + CtlResponse::error(e) + } + } + } Ok(CtlRequest::Move { path, new_parent }) => { match ( rel_to_mount(mountpoint, &path), diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index c966366..1279141 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -96,6 +96,7 @@ mod profile; mod queue; mod reads; mod redate; +mod relocate; mod revisions; mod sharing; mod shutdown; diff --git a/crates/pdfs-fuse/src/relocate.rs b/crates/pdfs-fuse/src/relocate.rs new file mode 100644 index 0000000..5bf7a70 --- /dev/null +++ b/crates/pdfs-fuse/src/relocate.rs @@ -0,0 +1,526 @@ +//! Moving a file or folder between two locations without moving its content. +//! +//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE +//! sessions, and a mirror folder is a plain local directory. The kernel refuses +//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so +//! `mv` falls back to copy and delete: every file is downloaded, uploaded again, +//! and the original is trashed. +//! +//! On Proton Drive the same move is one metadata change, because My files and +//! this computer's device folders all live on the main volume. So a move +//! request that names two absolute paths is resolved here, against every +//! location this daemon knows, and done as a server-side move. The local side +//! is then brought level: a mount forgets the node and re-lists, and a mirror +//! folder either renames its copy along or drops it once Drive has it. + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder}; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::NodeKind; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use tracing::{info, warn}; + +use super::background::forget_and_notify; +use super::sync::mirror_subtree_unsynced; +use super::{Core, is_local_uid, parse_uid}; + +/// How long a move waits for a mirror folder's sync pass to finish before it +/// reports the folder busy. A pass can run for hours on a first sync, so the +/// move does not wait it out. +const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5); + +/// Where one side of a move lives. +enum Place { + /// My files or an on-demand folder: a live inode space. `core` is rooted at + /// that mount and `rel` is relative to its mountpoint. + Mounted { core: Box, rel: PathBuf }, + /// A mirror folder: a local directory kept in step by the sync engine. + /// `rel` is `/`-separated, relative to the folder, as the baseline keys it. + Mirror { + folder: StoredSyncFolder, + rel: String, + }, +} + +impl Place { + fn mirror_id(&self) -> Option { + match self { + Place::Mirror { folder, .. } => Some(folder.id), + Place::Mounted { .. } => None, + } + } + + fn is_root(&self) -> bool { + match self { + Place::Mounted { rel, .. } => rel.as_os_str().is_empty(), + Place::Mirror { rel, .. } => rel.is_empty(), + } + } +} + +/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest +/// folder wins, although folders are not expected to nest. +fn mirror_covering( + folders: Vec, + abs: &Path, +) -> Option<(StoredSyncFolder, String)> { + let folder = folders + .into_iter() + .filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path)) + .max_by_key(|f| Path::new(&f.local_path).components().count())?; + let rel = abs.strip_prefix(&folder.local_path).ok()?; + let parts = rel + .components() + .map(|c| match c { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect::>>()?; + Some((folder, parts.join("/"))) +} + +/// `name` inside the folder at `parent`, in baseline form. +fn join_rel(parent: &str, name: &str) -> String { + if parent.is_empty() { + name.to_string() + } else { + format!("{parent}/{name}") + } +} + +/// Whether `uid` is `root` or lies under it, as far as the nodes table knows. +fn under(core: &Core, root: &NodeUid, uid: &str) -> bool { + let root = root.to_string(); + uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_))) +} + +impl Core { + /// Move the file or folder at `src` into the folder at `dest_parent`. Both + /// are absolute local paths and may be in different locations: My files, + /// an on-demand folder, or a mirror folder. Returns the moved name. + /// + /// Within one inode space this is [`Core::move_to`]. Across locations it + /// is a server-side move followed by local bookkeeping; see the module + /// documentation. Anything the move could lose is refused up front. + pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult { + let source = self.locate(src)?; + let dest = self.locate(dest_parent)?; + if let ( + Place::Mounted { core, rel }, + Place::Mounted { + core: dest_core, + rel: dest_rel, + }, + ) = (&source, &dest) + && Arc::ptr_eq(&core.state, &dest_core.state) + { + return core.move_to(rel, dest_rel); + } + if source.is_root() { + return Err(CoreError::invalid(format!( + "{} is the root of a synced location and cannot be moved", + src.display() + ))); + } + if dest_parent.starts_with(src) { + return Err(CoreError::invalid("cannot move a folder into itself")); + } + let name = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))? + .to_string(); + + // Hold every mirror folder involved, lowest id first, so no sync pass + // sees the move half done and two moves cannot deadlock. + let mut ids: Vec = [source.mirror_id(), dest.mirror_id()] + .into_iter() + .flatten() + .collect(); + ids.sort_unstable(); + ids.dedup(); + let locks: Vec>> = ids.iter().map(|&id| self.sync_lock(id)).collect(); + let mut guards = Vec::with_capacity(locks.len()); + for lock in &locks { + guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| { + CoreError::conflict("a synced folder is busy syncing; try again when it is done") + })?); + } + + let mut baselines: HashMap> = HashMap::new(); + for &id in &ids { + let baseline = self + .db + .sync_entries(id) + .map_err(|e| CoreError::internal(format!("db: {e}")))?; + baselines.insert(id, baseline); + } + + let src_uid = self.source_uid(&source, &baselines)?; + let dest_uid = self.dest_uid(&dest, &baselines, &name)?; + if src_uid.volume_id != dest_uid.volume_id { + return Err(CoreError::invalid( + "the two locations are on different Proton Drive volumes; copy it instead", + )); + } + if under(self, &src_uid, &dest_uid.to_string()) { + return Err(CoreError::invalid("cannot move a folder into itself")); + } + + // Mirror to mirror renames the local copy along, so it needs nothing + // proven about it. Another filesystem makes that impossible, and the + // pair then drops the source copy like any other move out of a mirror. + let mut renamed = None; + if let ( + Place::Mirror { folder, rel }, + Place::Mirror { + folder: dest_folder, + rel: dest_rel, + }, + ) = (&source, &dest) + { + let from = src.to_path_buf(); + let to = dest_parent.join(&name); + match std::fs::rename(&from, &to) { + Ok(()) => renamed = Some((from, to)), + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => { + info!(from = %folder.local_path, to = %dest_folder.local_path, + "move: mirror folders are on different filesystems"); + } + Err(e) => { + return Err(CoreError::internal(format!( + "could not move {} to {}: {e}", + rel, + join_rel(dest_rel, &name) + ))); + } + } + } + if renamed.is_none() + && let Place::Mirror { folder, rel } = &source + && let Some(reason) = + mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id]) + { + return Err(CoreError::conflict(format!( + "{name} is not fully synced yet ({reason}); wait for the sync to finish" + ))); + } + + if let Err(e) = self.rt.block_on(self.client.move_node(&src_uid, &dest_uid)) { + if let Some((from, to)) = &renamed + && let Err(back) = std::fs::rename(to, from) + { + warn!(from = %to.display(), to = %from.display(), error = %back, + "move: could not put the local copy back after the move failed"); + } + return Err(CoreError::from_api(&e, "move")); + } + + self.settle_source(&source, &src_uid, renamed.is_some(), &dest, &name, src); + self.settle_dest(&dest, &dest_uid, &name); + drop(guards); + if renamed.is_none() + && let Place::Mirror { folder, .. } = &dest + { + // Nothing local exists yet; the folder's next pass downloads it. + self.sync_now(Some(folder.id)); + } + Ok(name) + } + + /// Which location `abs` is in. A mount is looked for first: a mirror folder + /// cannot live inside a FUSE mount, and a mount is what the kernel sees. + fn locate(&self, abs: &Path) -> CoreResult { + if let Some((core, rel)) = self.rooted_at(abs) { + if !rel + .components() + .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) + { + return Err(CoreError::invalid(format!( + "{} escapes its location", + abs.display() + ))); + } + return Ok(Place::Mounted { + core: Box::new(core), + rel, + }); + } + let folders = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e}")))?; + mirror_covering(folders, abs) + .map(|(folder, rel)| Place::Mirror { folder, rel }) + .ok_or_else(|| { + CoreError::invalid(format!( + "{} is not in a Proton Drive location", + abs.display() + )) + }) + } + + /// The node being moved, once everything that must hold before it leaves + /// its location has been checked. + fn source_uid( + &self, + source: &Place, + baselines: &HashMap>, + ) -> CoreResult { + match source { + Place::Mounted { core, rel } => { + let (ino, uid) = core.resolve(rel)?; + if is_local_uid(&uid) { + return Err(CoreError::conflict( + "it is not uploaded yet; wait for the upload to finish", + )); + } + let old_parent = core.source_parent_uid(ino, rel)?; + for authority in [&uid, &old_parent] { + core.require_uid_writable(authority) + .map_err(|e| core.errno_error(e, "move access"))?; + } + if self.has_pending_work_under(&uid)? { + return Err(CoreError::conflict( + "it has changes that are not uploaded yet; wait for the upload to finish", + )); + } + Ok(uid) + } + Place::Mirror { folder, rel } => baselines[&folder.id] + .get(rel) + .and_then(|entry| entry.remote_uid.as_deref()) + .and_then(parse_uid) + .ok_or_else(|| { + CoreError::conflict( + "it is not on Proton Drive yet; wait for the sync to finish", + ) + }), + } + } + + /// The folder the node moves into. Refuses one that already holds `name` + /// on this disk; Drive refuses a clash on its side by itself. + fn dest_uid( + &self, + dest: &Place, + baselines: &HashMap>, + name: &str, + ) -> CoreResult { + let uid = match dest { + Place::Mounted { core, rel } => { + let (ino, uid) = core.resolve(rel)?; + let is_folder = core + .state() + .entries + .get(&ino) + .is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder)); + if !is_folder { + return Err(CoreError::invalid("the destination is not a folder")); + } + core.require_uid_writable(&uid) + .map_err(|e| core.errno_error(e, "move access"))?; + uid + } + Place::Mirror { folder, rel } => { + let local = Path::new(&folder.local_path).join(rel); + if !local.is_dir() { + return Err(CoreError::invalid("the destination is not a folder")); + } + if std::fs::symlink_metadata(local.join(name)).is_ok() { + return Err(CoreError::conflict(format!( + "{name} already exists in the destination" + ))); + } + let uid = if rel.is_empty() { + Some(folder.remote_uid.as_str()) + } else { + baselines[&folder.id] + .get(rel) + .and_then(|entry| entry.remote_uid.as_deref()) + }; + uid.and_then(parse_uid).ok_or_else(|| { + CoreError::conflict( + "the destination is not on Proton Drive yet; wait for the sync to finish", + ) + })? + } + }; + if is_local_uid(&uid) { + return Err(CoreError::conflict( + "the destination is not uploaded yet; wait for the upload to finish", + )); + } + Ok(uid) + } + + /// Whether the queue owes Drive anything for `uid` or a node under it, or a + /// file under it is open for writing. Moving such a node away would strand + /// the pending change against a path that no longer leads to it. + fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult { + let ops = self + .db + .pending_ops() + .map_err(|e| CoreError::internal(format!("db: {e}")))?; + if ops.iter().any(|op| { + under(self, uid, &op.uid) + || op + .parent_uid + .as_deref() + .is_some_and(|p| under(self, uid, p)) + }) { + return Ok(true); + } + // Collected first so no State lock is held across the nodes-table reads. + let mut writing = Vec::new(); + self.for_each_state(|st| { + writing.extend(st.active_writes.values().map(|w| w.uid.to_string())); + }); + Ok(writing.iter().any(|w| under(self, uid, w))) + } + + /// Bring the source location level with a move Drive has made. + fn settle_source( + &self, + source: &Place, + uid: &NodeUid, + renamed: bool, + dest: &Place, + name: &str, + src: &Path, + ) { + match source { + Place::Mounted { core, rel } => { + // Forget it everywhere, so no mount keeps serving it under the + // old path. The content cache is keyed by uid and stays valid. + self.for_each_mount(|st, notify| { + forget_and_notify(st, notify, uid); + }); + core.invalidate_parent_listing(rel); + } + Place::Mirror { folder, rel } if renamed => { + let Place::Mirror { + folder: dest_folder, + rel: dest_rel, + } = dest + else { + unreachable!("a local rename needs a mirror destination"); + }; + if let Err(e) = self.db.sync_entries_move( + folder.id, + rel, + dest_folder.id, + &join_rel(dest_rel, name), + ) { + // The next passes see a copy with no baseline on one side + // and a baseline with no copy on the other. That uploads + // the files again and trashes the moved node, but loses + // nothing. + warn!(error = ?e, "move: could not carry the sync baseline along"); + } + } + Place::Mirror { folder, rel } => { + // Drive has all of it (checked before the move), so the local + // copy is only a stale duplicate now. + if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) { + warn!(error = ?e, "move: could not drop the sync baseline"); + } + let removed = if src.is_dir() { + std::fs::remove_dir_all(src) + } else { + std::fs::remove_file(src) + }; + if let Err(e) = removed { + warn!(path = %src.display(), error = %e, + "move: could not remove the local copy after the move"); + } + } + } + } + + /// Bring the destination location level with a move Drive has made. + fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) { + if let Place::Mounted { .. } = dest { + self.invalidate_children_of(uid); + // A lookup that missed before the move may be cached as absent. + self.for_each_mount(|st, notify| { + if let Some(&ino) = st.by_uid.get(uid) { + notify.inval_entry(ino, name.to_string()); + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder { + StoredSyncFolder { + id, + local_path: local_path.to_string(), + remote_uid: format!("vol~root{id}"), + remote_share_id: String::new(), + mode: mode.to_string(), + pending_mode: None, + state: "idle".to_string(), + last_sync: 0, + } + } + + #[test] + fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() { + let folders = vec![folder(1, "/home/u/Docs", "mirror")]; + let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap(); + assert_eq!(found.id, 1); + assert_eq!(rel, "a/b.txt"); + } + + #[test] + fn mirror_covering_gives_an_empty_path_for_the_folder_itself() { + let folders = vec![folder(1, "/home/u/Docs", "mirror")]; + let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap(); + assert_eq!(rel, ""); + } + + #[test] + fn mirror_covering_prefers_the_deepest_folder() { + let folders = vec![ + folder(1, "/home/u/Docs", "mirror"), + folder(2, "/home/u/Docs/Work", "mirror"), + ]; + let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap(); + assert_eq!(found.id, 2); + assert_eq!(rel, "x"); + } + + #[test] + fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() { + let folders = || { + vec![ + folder(1, "/home/u/Docs", "mirror"), + folder(2, "/home/u/Media", "ondemand"), + ] + }; + assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none()); + assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none()); + } + + #[test] + fn mirror_covering_refuses_a_path_that_climbs_out() { + let folders = vec![folder(1, "/home/u/Docs", "mirror")]; + assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none()); + } + + #[test] + fn join_rel_names_a_child_of_the_folder_root_without_a_slash() { + assert_eq!(join_rel("", "a"), "a"); + assert_eq!(join_rel("x/y", "a"), "x/y/a"); + } +} diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index bff1c02..ba5260c 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -1602,6 +1602,76 @@ fn walk_local_tree( Ok(()) } +/// Why the mirror copy at `rel` holds something Proton Drive does not, or +/// `None` when everything under it is on Drive exactly as it is on disk. +/// +/// A move out of a mirror folder removes the local copy once Drive has moved +/// the node, so anything only this disk has would be lost with it: a file not +/// uploaded yet, one edited since, a deletion not carried up yet, or anything +/// the engine never uploads. That is why ignored names count here, and why the +/// walk is its own rather than [`walk_local_tree`], which skips symlinks. +pub(crate) fn mirror_subtree_unsynced( + root: &Path, + rel: &str, + baseline: &HashMap, +) -> Option { + let mut seen = HashSet::new(); + if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) { + return Some(reason); + } + let prefix = format!("{rel}/"); + baseline + .keys() + .filter(|r| *r == rel || r.starts_with(&prefix)) + .find(|r| !seen.contains(r.as_str())) + .map(|r| format!("{r} was deleted here but not on Proton Drive yet")) +} + +fn unsynced_local( + root: &Path, + rel: &str, + baseline: &HashMap, + seen: &mut HashSet, +) -> Option { + let path = root.join(rel_to_path(rel)); + let meta = match std::fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(e) => return Some(format!("cannot read {rel}: {e}")), + }; + let Some(base) = baseline.get(rel) else { + return Some(format!("{rel} is not on Proton Drive yet")); + }; + seen.insert(rel.to_string()); + if meta.is_file() { + return (!LocalSig::from(&meta).same_content(&LocalSig::from(base))) + .then(|| format!("{rel} has changes that are not on Proton Drive yet")); + } + if !meta.is_dir() { + return Some(format!("{rel} is not a regular file or folder")); + } + let entries = match std::fs::read_dir(&path) { + Ok(entries) => entries, + Err(e) => return Some(format!("cannot read {rel}: {e}")), + }; + for entry in entries { + let name = match entry { + Ok(entry) => entry.file_name(), + Err(e) => return Some(format!("cannot read {rel}: {e}")), + }; + let Some(name) = name.to_str() else { + return Some(format!("{rel} holds a name that is not valid UTF-8")); + }; + // A download the engine left half done; it is the engine's to clean up. + if name.contains(".pdfs-tmp-") { + continue; + } + if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) { + return Some(reason); + } + } + None +} + /// Publish a conflict copy without overwriting an earlier conflict bearing the /// same timestamp. The original is removed only after the new copy is durable. fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result { @@ -2346,6 +2416,94 @@ mod tests { ); } + /// A baseline row recording `path` as it is on disk right now. + fn synced_row(root: &Path, rel: &str) -> (String, StoredSyncEntry) { + let meta = std::fs::symlink_metadata(root.join(rel_to_path(rel))).unwrap(); + let sig = LocalSig::from(&meta); + let entry = StoredSyncEntry { + rel_path: rel.to_string(), + remote_uid: Some(format!("vol~{rel}")), + local_mtime: sig.mtime, + local_mtime_ns: sig.mtime_ns, + local_size: sig.size, + remote_rev: None, + remote_hash: None, + }; + (rel.to_string(), entry) + } + + fn synced_subtree(name: &str) -> (PathBuf, HashMap) { + let root = sync_test_dir(name); + std::fs::create_dir_all(root.join("dir/sub")).unwrap(); + std::fs::write(root.join("dir/a.txt"), b"a").unwrap(); + std::fs::write(root.join("dir/sub/b.txt"), b"b").unwrap(); + let baseline = ["dir", "dir/sub", "dir/a.txt", "dir/sub/b.txt"] + .into_iter() + .map(|rel| synced_row(&root, rel)) + .collect(); + (root, baseline) + } + + #[test] + fn a_fully_synced_mirror_subtree_may_leave() { + let (root, baseline) = synced_subtree("move-synced"); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(reason, None); + } + + #[test] + fn a_mirror_subtree_with_a_file_not_uploaded_may_not_leave() { + let (root, baseline) = synced_subtree("move-new-file"); + std::fs::write(root.join("dir/sub/new.txt"), b"n").unwrap(); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/sub/new.txt")); + } + + #[test] + fn a_mirror_subtree_with_an_edit_not_uploaded_may_not_leave() { + let (root, baseline) = synced_subtree("move-edit"); + std::fs::write(root.join("dir/a.txt"), b"longer now").unwrap(); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/a.txt")); + } + + #[test] + fn a_mirror_subtree_with_a_deletion_not_carried_up_may_not_leave() { + let (root, baseline) = synced_subtree("move-deleted"); + std::fs::remove_file(root.join("dir/sub/b.txt")).unwrap(); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/sub/b.txt")); + } + + #[test] + fn a_mirror_subtree_holding_a_symlink_may_not_leave() { + let (root, baseline) = synced_subtree("move-symlink"); + std::os::unix::fs::symlink("a.txt", root.join("dir/link")).unwrap(); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/link")); + } + + #[test] + fn a_name_prefixed_sibling_does_not_hold_a_mirror_subtree_back() { + let (root, mut baseline) = synced_subtree("move-sibling"); + // Deleted here but not on Drive, yet outside the subtree being moved. + baseline.insert( + "dirt".to_string(), + StoredSyncEntry { + rel_path: "dirt".to_string(), + ..baseline["dir/a.txt"].clone() + }, + ); + let reason = mirror_subtree_unsynced(&root, "dir", &baseline); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(reason, None); + } + /// The same rule one level up: if the sync root itself cannot be read, the /// pass sees nothing, and "nothing" must never mean "delete everything". #[test] diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 5c93895..8b570f3 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1920,6 +1920,10 @@ pub(crate) fn prompt_rename(ui: &Rc, entry: &DirEntry) { /// Pick a destination folder by browsing Drive's folders, and move `entries` /// there through the daemon. +/// +/// Besides My files the picker offers every synced folder. A move into one is +/// still made on Proton Drive, so nothing is downloaded or uploaded again; it +/// is sent with absolute paths, which is how the daemon tells locations apart. pub(crate) fn prompt_move(ui: &Rc, entries: Vec) { if entries.is_empty() { return; @@ -1967,6 +1971,7 @@ pub(crate) fn prompt_move(ui: &Rc, entries: Vec) { dialog.set_default_response(Some("move")); dialog.set_close_response("cancel"); + let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let picker = Rc::new(MovePicker { ui: ui.clone(), dialog: dialog.clone(), @@ -1974,28 +1979,93 @@ pub(crate) fn prompt_move(ui: &Rc, entries: Vec) { list, up: up.clone(), sources: sources.clone(), - folder: RefCell::new(String::new()), + folder: RefCell::new(MoveTarget::Locations), generation: Cell::new(0), }); let picker_up = picker.clone(); up.connect_clicked(move |_| { - let folder = picker_up.folder.borrow().clone(); - let parent = folder.rfind('/').map(|i| &folder[..i]).unwrap_or_default(); - picker_up.open(parent.to_string()); + let parent = picker_up.folder.borrow().parent(); + picker_up.open(parent); }); // Start where the browser is: moving is most often one level up or down. - picker.open(ui.browser.path.borrow().clone()); + picker.open(MoveTarget::Drive(ui.browser.path.borrow().clone())); let ui = ui.clone(); let picker_done = picker.clone(); dialog.connect_response(None, move |_, resp| { - if resp == "move" { - run_bulk_move(&ui, sources.clone(), picker_done.folder.borrow().clone()); + if resp != "move" { + return; + } + match picker_done.folder.borrow().clone() { + MoveTarget::Locations => {} + MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder), + MoveTarget::Synced { path, .. } => { + let absolute = sources + .iter() + .map(|rel| mountpoint.join(rel).to_string_lossy().into_owned()) + .collect(); + run_bulk_move(&ui, absolute, path); + } } }); dialog.present(ui_window(&picker.ui).as_ref()); } +/// Where the Move dialog is looking, and the destination if the user confirms. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum MoveTarget { + /// The list of places to move to: My files and each synced folder. + Locations, + /// A folder in My files, mountpoint-relative; empty for My files itself. + Drive(String), + /// A folder inside a synced folder, as an absolute local path. `root` is + /// the synced folder. A mirror folder is a plain directory, read from disk. + Synced { + root: String, + path: String, + mirror: bool, + }, +} + +impl MoveTarget { + /// One level up. The top of a location leads back to the list of them. + pub(crate) fn parent(&self) -> MoveTarget { + match self { + MoveTarget::Locations => MoveTarget::Locations, + MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Locations, + MoveTarget::Drive(folder) => MoveTarget::Drive( + folder + .rfind('/') + .map(|i| folder[..i].to_string()) + .unwrap_or_default(), + ), + MoveTarget::Synced { root, path, .. } if path == root => MoveTarget::Locations, + MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced { + root: root.clone(), + path: Path::new(path) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| root.clone()), + mirror: *mirror, + }, + } + } + + /// `name` inside this folder, in the same form. + fn child(&self, name: &str) -> MoveTarget { + match self { + MoveTarget::Locations => MoveTarget::Locations, + MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Drive(name.to_string()), + MoveTarget::Drive(folder) => MoveTarget::Drive(format!("{folder}/{name}")), + MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced { + root: root.clone(), + path: Path::new(path).join(name).to_string_lossy().into_owned(), + mirror: *mirror, + }, + } + } +} + /// The Move dialog's folder browser. struct MovePicker { ui: Rc, @@ -2006,71 +2076,81 @@ struct MovePicker { /// What is being moved, mountpoint-relative. sources: Vec, /// The folder on show, and the destination if the user confirms now. - folder: RefCell, + folder: RefCell, /// Drops a listing that arrives after the user went elsewhere. generation: Cell, } impl MovePicker { - fn open(self: &Rc, folder: String) { + fn open(self: &Rc, folder: MoveTarget) { let generation = self.generation.get().wrapping_add(1); self.generation.set(generation); - self.location.set_label(if folder.is_empty() { - "Proton Drive" - } else { - &folder - }); - self.location.set_tooltip_text(Some(&folder)); - self.up.set_sensitive(!folder.is_empty()); - self.dialog - .set_response_enabled("move", move_target_allowed(&self.sources, &folder)); + let (label, tooltip) = match &folder { + MoveTarget::Locations => (gettext("Locations"), String::new()), + MoveTarget::Drive(rel) if rel.is_empty() => (gettext("My files"), String::new()), + MoveTarget::Drive(rel) => (rel.clone(), rel.clone()), + MoveTarget::Synced { path, .. } => (path.clone(), path.clone()), + }; + self.location.set_label(&label); + self.location.set_tooltip_text(Some(&tooltip)); + self.up + .set_sensitive(!matches!(folder, MoveTarget::Locations)); + let allowed = match &folder { + MoveTarget::Locations => false, + MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel), + // What is moved is in My files, so it cannot be in a synced folder. + MoveTarget::Synced { .. } => true, + }; + self.dialog.set_response_enabled("move", allowed); *self.folder.borrow_mut() = folder.clone(); while let Some(row) = self.list.first_child() { self.list.remove(&row); } self.list.append(&picker_note(&gettext("Loading…"))); - let rx = spawn_request( - self.ui.dirs.control_socket(), - Request::ListDir { - path: folder.clone(), - }, - ); + let picker = self.clone(); glib::spawn_future_local(async move { - let result = rx.recv().await; + let listed = match &folder { + MoveTarget::Locations => picker.locations().await, + MoveTarget::Drive(rel) => picker.drive_folders(&folder, rel.clone()).await, + MoveTarget::Synced { + path, mirror: true, .. + } => local_folders(&folder, path.clone()).await, + MoveTarget::Synced { path, .. } => { + picker.drive_folders(&folder, path.clone()).await + } + }; if picker.generation.get() != generation { return; } while let Some(row) = picker.list.first_child() { picker.list.remove(&row); } - let Ok(Ok(Response::Entries { entries })) = result else { + let Some(rows) = listed else { picker .list .append(&picker_note(&gettext("Couldn't read this folder."))); return; }; - let mut folders: Vec = entries.into_iter().filter(|e| e.is_dir).collect(); - folders.sort_by_key(|e| e.name.to_lowercase()); let mut shown = 0; - for entry in folders { - let path = if folder.is_empty() { - entry.name.clone() - } else { - format!("{folder}/{}", entry.name) - }; + for (title, subtitle, target) in rows { // A folder cannot go inside itself, so do not offer to open it. - if picker.sources.contains(&path) { + if let MoveTarget::Drive(path) = &target + && picker.sources.contains(path) + { continue; } let row = adw::ActionRow::builder() - .title(glib::markup_escape_text(&entry.name).as_str()) + .title(glib::markup_escape_text(&title).as_str()) .activatable(true) .build(); + if let Some(subtitle) = subtitle { + row.set_subtitle(glib::markup_escape_text(&subtitle).as_str()); + } row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic")); let open = picker.clone(); - row.connect_activated(move |_| open.open(path.clone())); + row.connect_activated(move |_| open.open(target.clone())); picker.list.append(&row); shown += 1; } @@ -2081,6 +2161,92 @@ impl MovePicker { } }); } + + /// My files and every synced folder, as rows of the top level. + async fn locations(&self) -> Option> { + let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))]; + let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders); + let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else { + // My files alone is still somewhere to move to. + return Some(rows); + }; + items.sort_by_key(|f| f.local_path.to_lowercase()); + for folder in items { + let name = Path::new(&folder.local_path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| folder.local_path.clone()); + rows.push(( + name, + Some(folder.local_path.clone()), + MoveTarget::Synced { + root: folder.local_path.clone(), + path: folder.local_path, + mirror: folder.mode == "mirror", + }, + )); + } + Some(rows) + } + + /// The folders in a mounted folder: `folder` is mountpoint-relative for My + /// files and absolute for an on-demand synced folder. + async fn drive_folders(&self, here: &MoveTarget, folder: String) -> Option> { + let rx = spawn_request( + self.ui.dirs.control_socket(), + Request::ListDir { + path: folder.clone(), + }, + ); + let Ok(Ok(Response::Entries { entries })) = rx.recv().await else { + return None; + }; + let mut names: Vec = entries + .into_iter() + .filter(|e| e.is_dir) + .map(|e| e.name) + .collect(); + names.sort_by_key(|name| name.to_lowercase()); + Some( + names + .into_iter() + .map(|name| { + let target = here.child(&name); + (name, None, target) + }) + .collect(), + ) + } +} + +/// One row of the picker: its title, an optional subtitle, and where it leads. +type PickerRow = (String, Option, MoveTarget); + +/// The folders in a mirror folder's directory `path`, read from disk off the +/// main thread. A mirror folder has no mount for `ListDir` to read. +async fn local_folders(here: &MoveTarget, path: String) -> Option> { + let names = gio::spawn_blocking(move || { + let mut names: Vec = std::fs::read_dir(&path) + .ok()? + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| !name.contains(".pdfs-tmp-")) + .collect(); + names.sort_by_key(|name| name.to_lowercase()); + Some(names) + }) + .await + .ok()??; + Some( + names + .into_iter() + .map(|name| { + let target = here.child(&name); + (name, None, target) + }) + .collect(), + ) } fn picker_note(text: &str) -> gtk4::ListBoxRow { @@ -2786,8 +2952,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { #[cfg(test)] mod tests { use super::{ - hit_location, listing_summary, move_target_allowed, show_thumbnail_build_progress, - sort_entries, + MoveTarget, hit_location, listing_summary, move_target_allowed, + show_thumbnail_build_progress, sort_entries, }; use pdfs_core::config::{FileSort, FilesView}; use pdfs_core::control::{DirEntry, ThumbnailBuildStatus}; @@ -2880,4 +3046,43 @@ mod tests { status.running = false; assert!(!show_thumbnail_build_progress(&status)); } + + #[test] + fn move_picker_climbs_from_a_location_top_to_the_list_of_locations() { + assert_eq!( + MoveTarget::Drive(String::new()).parent(), + MoveTarget::Locations + ); + let root = MoveTarget::Synced { + root: "/home/u/Docs".into(), + path: "/home/u/Docs".into(), + mirror: true, + }; + assert_eq!(root.parent(), MoveTarget::Locations); + } + + #[test] + fn move_picker_climbs_one_folder_at_a_time_inside_a_location() { + assert_eq!( + MoveTarget::Drive("a/b".into()).parent(), + MoveTarget::Drive("a".into()) + ); + assert_eq!( + MoveTarget::Drive("a".into()).parent(), + MoveTarget::Drive(String::new()) + ); + let inner = MoveTarget::Synced { + root: "/home/u/Docs".into(), + path: "/home/u/Docs/x/y".into(), + mirror: false, + }; + assert_eq!( + inner.parent(), + MoveTarget::Synced { + root: "/home/u/Docs".into(), + path: "/home/u/Docs/x".into(), + mirror: false, + } + ); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3157f16..a369176 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -539,6 +539,18 @@ A restore is expanded before it is sent (`expand_restore`, `pdfs-fuse/src/lib.rs The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error. +### Moves Between Locations +**Files**: [`relocate.rs`](../crates/pdfs-fuse/src/relocate.rs), [`control.rs`](../crates/pdfs-fuse/src/control.rs), [`browser.rs`](../crates/pdfs-gui/src/app/pages/browser.rs) + +The primary mount and every on-demand folder are separate FUSE sessions, and a mirror folder has no session at all. The kernel answers `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so `mv` copies and deletes. On Proton Drive the same move is one `move_node`, because My files and this device's folders are on the main volume. + +`Request::Move` with two absolute paths is therefore resolved by `Core::move_between` against every location: a live mount through `rooted_at`, otherwise the mirror folder with the longest `local_path` prefix, whose uids come from the `sync_entry` baseline. Two paths in one inode space still go to `move_to`. Relative paths keep their old meaning. + +- **Nothing that exists only on this disk may be lost.** A mounted source is refused while it or anything under it has a queued op or a file open for writing. A mirror source is refused unless every file under it matches its baseline and every baseline row still has its file (`mirror_subtree_unsynced`); ignored names and symlinks count as unsynced, because the local copy is removed after the move. +- **Mirror to mirror renames along.** The local copy is renamed first and its baseline rows are moved with `sync_entries_move`, so neither folder's next pass has anything to do. If `move_node` fails, the rename is undone. `EXDEV` between two filesystems falls back to the check above, then drops the source copy. +- **Sync passes are held off.** Every mirror folder involved is locked through `sync_lock`, by ascending id, with a five-second bound. A folder that is still busy is reported rather than waited on. +- **The local side is levelled afterwards.** A mounted source forgets the node in every inode space and notifies the kernel. A mounted destination has its listing cleared. A mirror destination that received nothing locally gets a reconcile, which downloads the node. + ### Open-for-Write Deferral for Mirror Sync **File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3660bde..70eb52d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +### Added +- **Moves between locations without transferring content.** `pdfs move` and the app's + **Move to…** now take a file or folder from My files into a synced folder, or between two + synced folders, on-demand or mirrored. The move happens on Proton Drive, so nothing is + downloaded or uploaded again. `mv` between two locations still copies, because the kernel + refuses a rename between two mounts. The app's Move dialog lists every location at its top + level. A move is refused, with the reason, when the source still has changes that are not on + Proton Drive, when the two locations are on different volumes, or when a synced folder stays + busy with a sync pass. + ## [2.6.2] — 2026-09-28 Schema: **35** (unchanged). diff --git a/docs/CLI.md b/docs/CLI.md index bee2685..a93fa98 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -53,7 +53,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount | `pdfs search QUERY [--in FOLDER] [--limit N]` | Search names in the local index. | | `pdfs mkdir PARENT NAME` | Create a folder. | | `pdfs rename PATH NEW_NAME` | Rename a file or folder. | -| `pdfs move PATH NEW_PARENT` | Move into another folder. | +| `pdfs move PATH NEW_PARENT` | Move into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. | | `pdfs rm PATH` | Move to the Trash. | | `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. | | `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 60832c7..83dce5a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -95,6 +95,23 @@ of the folder, so the files stay visible but take no disk space until opened. Sw **Synced / Online only** switch on the folder's card, or `pdfs sync mode ondemand` and back with `pdfs sync mode mirror`. `pdfs locations` lists every local place the service occupies. +### Moving between synced folders and My files + +`~/ProtonDrive` and each online-only folder are separate mounts, and a mirrored folder is a plain +folder on disk. `mv`, and dragging in a file manager, cannot rename across them, so they copy +and then delete: every file is downloaded, uploaded again, and the original goes to the Trash. + +Use `pdfs move ` or **Move to…** in the app instead. The move happens on Proton +Drive, so it takes seconds whatever the size. For example: + +```sh +pdfs move ~/Documents/Scans ~/ProtonDrive/Archive +``` + +A move out of a synced folder waits until its files are on Proton Drive. A file that is still +uploading, has changes that are not uploaded, or is excluded by `.pdfsignore` stops the move, +and the message names it. + Removing a synced folder (**Stop Syncing** or `pdfs sync rm `) keeps the local files and the copy on Proton Drive. Add `--delete-remote` (or tick **Also delete from Proton Drive**) to delete the copy on Proton Drive as well. diff --git a/po/de.po b/po/de.po index b8c0dfc..10a2a66 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-09-28 22:51+0200\n" +"POT-Creation-Date: 2026-09-29 00:18+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,102 +57,104 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:734 +#: crates/pdfs-gui/src/app/main.rs:743 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 -#: crates/pdfs-gui/src/app/pages/locations.rs:1469 +#: crates/pdfs-gui/src/app/pages/browser.rs:2090 +#: crates/pdfs-gui/src/app/pages/browser.rs:2167 +#: crates/pdfs-gui/src/app/pages/locations.rs:1471 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:737 crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:746 crates/pdfs-gui/src/app/main.rs:1390 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:742 +#: crates/pdfs-gui/src/app/main.rs:751 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:747 +#: crates/pdfs-gui/src/app/main.rs:756 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:750 +#: crates/pdfs-gui/src/app/main.rs:759 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:751 +#: crates/pdfs-gui/src/app/main.rs:760 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:753 +#: crates/pdfs-gui/src/app/main.rs:762 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1070 +#: crates/pdfs-gui/src/app/main.rs:1079 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1089 +#: crates/pdfs-gui/src/app/main.rs:1098 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1090 +#: crates/pdfs-gui/src/app/main.rs:1099 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1091 +#: crates/pdfs-gui/src/app/main.rs:1100 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1092 +#: crates/pdfs-gui/src/app/main.rs:1101 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1093 +#: crates/pdfs-gui/src/app/main.rs:1102 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1134 crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1143 crates/pdfs-gui/src/app/main.rs:1372 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1135 +#: crates/pdfs-gui/src/app/main.rs:1144 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1137 +#: crates/pdfs-gui/src/app/main.rs:1146 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1142 +#: crates/pdfs-gui/src/app/main.rs:1151 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1198 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -164,23 +166,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1211 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1215 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1228 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -205,7 +207,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +227,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +224,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -223,7 +225,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +226,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +224,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -223,7 +225,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,102 +60,104 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:734 +#: crates/pdfs-gui/src/app/main.rs:743 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 -#: crates/pdfs-gui/src/app/pages/locations.rs:1469 +#: crates/pdfs-gui/src/app/pages/browser.rs:2090 +#: crates/pdfs-gui/src/app/pages/browser.rs:2167 +#: crates/pdfs-gui/src/app/pages/locations.rs:1471 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:737 crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:746 crates/pdfs-gui/src/app/main.rs:1390 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:742 +#: crates/pdfs-gui/src/app/main.rs:751 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:747 +#: crates/pdfs-gui/src/app/main.rs:756 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:750 +#: crates/pdfs-gui/src/app/main.rs:759 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:751 +#: crates/pdfs-gui/src/app/main.rs:760 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:753 +#: crates/pdfs-gui/src/app/main.rs:762 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1070 +#: crates/pdfs-gui/src/app/main.rs:1079 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1089 +#: crates/pdfs-gui/src/app/main.rs:1098 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1090 +#: crates/pdfs-gui/src/app/main.rs:1099 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1091 +#: crates/pdfs-gui/src/app/main.rs:1100 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1092 +#: crates/pdfs-gui/src/app/main.rs:1101 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1093 +#: crates/pdfs-gui/src/app/main.rs:1102 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1134 crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1143 crates/pdfs-gui/src/app/main.rs:1372 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1135 +#: crates/pdfs-gui/src/app/main.rs:1144 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1137 +#: crates/pdfs-gui/src/app/main.rs:1146 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1142 +#: crates/pdfs-gui/src/app/main.rs:1151 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1198 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -163,23 +165,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1211 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1215 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1228 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -192,21 +194,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +228,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +224,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the