mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-12 17:27:03 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad648ab5cc | ||
|
|
2b95708260 |
No files matched your search
@@ -160,6 +160,7 @@ jobs:
|
|||||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
po/build.sh deb/usr/share/locale
|
po/build.sh deb/usr/share/locale
|
||||||
|
packaging/completions.sh target/release/pdfs deb/usr/share
|
||||||
|
|
||||||
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||||
|
|||||||
Generated
+26
-4
@@ -754,6 +754,18 @@ dependencies = [
|
|||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"clap_lex",
|
||||||
|
"is_executable",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -2551,6 +2563,15 @@ version = "2.12.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_executable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -3520,10 +3541,11 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"pdfs-fuse",
|
"pdfs-fuse",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
@@ -3536,7 +3558,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3563,7 +3585,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3585,7 +3607,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.7.0"
|
version = "3.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -56,6 +56,9 @@ rusqlite = { version = "0.41", features = ["bundled"] }
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
# `unstable-dynamic`: Tab calls `pdfs` back, so arguments complete from the
|
||||||
|
# running service. Only `pdfs-cli/src/complete.rs` uses it.
|
||||||
|
clap_complete = { version = "4.6", features = ["unstable-dynamic"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ The documentation is in [`docs/`](docs/README.md) and on the
|
|||||||
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
|
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
|
||||||
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||||
| [FAQ](docs/FAQ.md) | Short answers to common questions |
|
| [FAQ](docs/FAQ.md) | Short answers to common questions |
|
||||||
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
| [Command-line reference](docs/CLI.md) | Every `pdfs` command, Tab completion and scripting with `--json` |
|
||||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ anyhow.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
//! Shell completion for `pdfs`.
|
||||||
|
//!
|
||||||
|
//! Built on clap_complete's dynamic engine: the script a shell loads is a thin
|
||||||
|
//! stub that calls `pdfs` back on every Tab with the words typed so far, and
|
||||||
|
//! `pdfs` answers from its own clap definition. That lets an argument complete
|
||||||
|
//! from the running service as well as from the command tree — `pdfs sync rm
|
||||||
|
//! <Tab>` offers the synced-folder ids with their paths, `pdfs pin Doc<Tab>`
|
||||||
|
//! walks My files.
|
||||||
|
//!
|
||||||
|
//! Every value that comes from the service is best effort. With no service, a
|
||||||
|
//! slow one or an error, a completer offers nothing: a Tab that hangs or prints
|
||||||
|
//! an error into the prompt is worse than one that does nothing.
|
||||||
|
//!
|
||||||
|
//! The engine sits behind clap_complete's `unstable-dynamic` feature, so all of
|
||||||
|
//! this crate's use of it is kept to this module.
|
||||||
|
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use clap_complete::engine::{CompletionCandidate, PathCompleter, ValueCompleter};
|
||||||
|
use clap_complete::env::{Bash, CompleteEnv, Elvish, EnvCompleter, Fish, Powershell, Zsh};
|
||||||
|
use pdfs_core::control::{
|
||||||
|
AlbumInfo, BookmarkInfo, ConflictInfo, DeviceInfo, DirEntry, InvitationInfo, PendingOpInfo,
|
||||||
|
PublicLinkInfo, Request, Response, RevisionInfo, ShareEntry, ShareEntryKind, SyncFolderInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The environment variable that turns a run of `pdfs` into a completion
|
||||||
|
/// request. Not clap's generic `COMPLETE`: the same binary is the systemd
|
||||||
|
/// daemon, and an unrelated `COMPLETE` in its environment must not stop it.
|
||||||
|
pub const VAR: &str = "PDFS_COMPLETE";
|
||||||
|
|
||||||
|
/// How long a completer waits for the service. A Tab is interactive, so a
|
||||||
|
/// listing the service cannot give soon is better skipped. Long enough for the
|
||||||
|
/// listings that ask Proton (computers take over a second).
|
||||||
|
const SERVICE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How long a listing that asks Proton is reused. Every Tab is a new `pdfs`, so
|
||||||
|
/// without it `pdfs devices rm <Tab>`, a letter and another Tab would wait for
|
||||||
|
/// Proton twice. A uid that went away in the meantime only fails the command.
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Answer a completion request and exit, if this run is one. Must run before
|
||||||
|
/// anything writes to stdout.
|
||||||
|
pub fn handle_request() {
|
||||||
|
CompleteEnv::with_factory(crate::Cli::command)
|
||||||
|
.var(VAR)
|
||||||
|
.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shells `pdfs completions` writes a script for.
|
||||||
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||||
|
pub enum ShellArg {
|
||||||
|
Bash,
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
|
Elvish,
|
||||||
|
Powershell,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellArg {
|
||||||
|
fn completer(self) -> &'static dyn EnvCompleter {
|
||||||
|
match self {
|
||||||
|
ShellArg::Bash => &Bash,
|
||||||
|
ShellArg::Zsh => &Zsh,
|
||||||
|
ShellArg::Fish => &Fish,
|
||||||
|
ShellArg::Elvish => &Elvish,
|
||||||
|
ShellArg::Powershell => &Powershell,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the script that registers `pdfs` completion with `shell`.
|
||||||
|
///
|
||||||
|
/// The script calls `pdfs` from `PATH`, not this binary's path, so one written
|
||||||
|
/// at packaging time keeps working once installed.
|
||||||
|
pub fn write_script(shell: ShellArg, out: &mut dyn std::io::Write) -> std::io::Result<()> {
|
||||||
|
shell
|
||||||
|
.completer()
|
||||||
|
.write_registration(VAR, "pdfs", "pdfs", "pdfs", out)?;
|
||||||
|
if let ShellArg::Zsh = shell {
|
||||||
|
// Installed as `_pdfs` in site-functions, the script is autoloaded as
|
||||||
|
// that function's body on the first Tab. The body above only defines
|
||||||
|
// and registers the completer, so this line also runs it, or the first
|
||||||
|
// Tab would complete nothing. Sourced, `funcstack` names no `_pdfs`.
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"if [ \"$funcstack[1]\" = \"_pdfs\" ]; then\n _clap_dynamic_completer_pdfs \"$@\"\nfi"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Talking to the service
|
||||||
|
|
||||||
|
/// One request to the service, or `None` if there is no answer worth showing.
|
||||||
|
fn ask(request: Request) -> Option<Response> {
|
||||||
|
let socket = pdfs_core::config::AppDirs::new().ok()?.control_socket();
|
||||||
|
ask_at(&socket, &request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ask_at(socket: &Path, request: &Request) -> Option<Response> {
|
||||||
|
match pdfs_core::control::send_with_timeout(socket, request, SERVICE_TIMEOUT) {
|
||||||
|
Ok(Response::Error { .. }) | Err(_) => None,
|
||||||
|
Ok(response) => Some(response),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`ask`] for a listing that comes from Proton rather than the local
|
||||||
|
/// database, reusing an answer younger than [`CACHE_TTL`]. `key` names the
|
||||||
|
/// listing's cache file.
|
||||||
|
fn ask_cached(key: &str, request: Request) -> Option<Response> {
|
||||||
|
let file = cache_file(key);
|
||||||
|
if let Some(hit) = file.as_deref().and_then(read_fresh) {
|
||||||
|
return Some(hit);
|
||||||
|
}
|
||||||
|
let response = ask(request)?;
|
||||||
|
if let Some(file) = &file {
|
||||||
|
write_cache(file, &response);
|
||||||
|
}
|
||||||
|
Some(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In `$XDG_RUNTIME_DIR`: private to the user, in memory and gone at logout,
|
||||||
|
/// which suits names from the account. With no runtime dir, nothing is cached.
|
||||||
|
fn cache_file(key: &str) -> Option<PathBuf> {
|
||||||
|
let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
|
||||||
|
Some(
|
||||||
|
runtime
|
||||||
|
.join("proton-drive-linux")
|
||||||
|
.join(format!("completion-{key}.json")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fresh(file: &Path) -> Option<Response> {
|
||||||
|
let age = std::fs::metadata(file)
|
||||||
|
.ok()?
|
||||||
|
.modified()
|
||||||
|
.ok()?
|
||||||
|
.elapsed()
|
||||||
|
.ok()?;
|
||||||
|
if age > CACHE_TTL {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&std::fs::read(file).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best effort: a cache that cannot be written only costs the next Tab a wait.
|
||||||
|
fn write_cache(file: &Path, response: &Response) {
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||||
|
|
||||||
|
let (Some(dir), Ok(json)) = (file.parent(), serde_json::to_vec(response)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(dir);
|
||||||
|
let staging = file.with_extension(format!("{}.tmp", std::process::id()));
|
||||||
|
let written = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&staging)
|
||||||
|
.and_then(|mut f| std::io::Write::write_all(&mut f, &json));
|
||||||
|
if written.is_err() || std::fs::rename(&staging, file).is_err() {
|
||||||
|
let _ = std::fs::remove_file(&staging);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate(value: impl Into<OsString>, help: impl Into<String>) -> CompletionCandidate {
|
||||||
|
let help = help.into();
|
||||||
|
let candidate = CompletionCandidate::new(value);
|
||||||
|
if help.is_empty() {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
candidate.help(Some(help.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Paths in Proton Drive
|
||||||
|
|
||||||
|
/// A path in the mount: an absolute path completes from the local filesystem
|
||||||
|
/// (through the mount), anything else from the service's listing of My files,
|
||||||
|
/// which is what the commands resolve a relative path against.
|
||||||
|
pub fn drive_path(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
complete_drive(current, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`drive_path`] for an argument that must be a folder.
|
||||||
|
pub fn drive_dir(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
complete_drive(current, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs refresh`: a folder, or one of the two named caches.
|
||||||
|
pub fn refresh_target(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
let typed = current.to_string_lossy();
|
||||||
|
let mut candidates: Vec<_> = [
|
||||||
|
("trash", "the trash listing"),
|
||||||
|
("photos", "the photos timeline"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(name, _)| name.starts_with(typed.as_ref()))
|
||||||
|
.map(|(name, help)| candidate(name, help))
|
||||||
|
.collect();
|
||||||
|
candidates.extend(drive_dir(current));
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_drive(current: &OsStr, dirs_only: bool) -> Vec<CompletionCandidate> {
|
||||||
|
let Some(word) = current.to_str() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if word.starts_with('/') || word.starts_with('~') {
|
||||||
|
let local = if dirs_only {
|
||||||
|
PathCompleter::dir()
|
||||||
|
} else {
|
||||||
|
PathCompleter::any()
|
||||||
|
};
|
||||||
|
return local.complete(current);
|
||||||
|
}
|
||||||
|
let (parent, prefix) = split_drive_word(word);
|
||||||
|
let listing = Request::ListDir {
|
||||||
|
path: parent.trim_end_matches('/').to_owned(),
|
||||||
|
};
|
||||||
|
match ask(listing) {
|
||||||
|
Some(Response::Entries { entries }) => {
|
||||||
|
drive_candidates(parent, prefix, &entries, dirs_only)
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a typed drive path into the folder to list (with its trailing `/`)
|
||||||
|
/// and the start of the name being completed in it.
|
||||||
|
fn split_drive_word(word: &str) -> (&str, &str) {
|
||||||
|
match word.rfind('/') {
|
||||||
|
Some(at) => word.split_at(at + 1),
|
||||||
|
None => ("", word),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entries of `parent` whose name starts with `prefix`. A folder ends in
|
||||||
|
/// `/`, which tells every shell not to add a space after it, so the next Tab
|
||||||
|
/// goes on into it.
|
||||||
|
fn drive_candidates(
|
||||||
|
parent: &str,
|
||||||
|
prefix: &str,
|
||||||
|
entries: &[DirEntry],
|
||||||
|
dirs_only: bool,
|
||||||
|
) -> Vec<CompletionCandidate> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.name.starts_with(prefix) && (e.is_dir || !dirs_only))
|
||||||
|
.map(|e| {
|
||||||
|
let slash = if e.is_dir { "/" } else { "" };
|
||||||
|
CompletionCandidate::new(format!("{parent}{}{slash}", e.name))
|
||||||
|
.hide(e.name.starts_with('.') && !prefix.starts_with('.'))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ids and uids from the service's listings
|
||||||
|
|
||||||
|
pub fn sync_folder_id() -> Vec<CompletionCandidate> {
|
||||||
|
match ask(Request::ListSyncFolders) {
|
||||||
|
Some(Response::SyncFolders { items }) => sync_folder_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_folder_candidates(items: &[SyncFolderInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|f| candidate(f.id.to_string(), format!("{} ({})", f.local_path, f.mode)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_id() -> Vec<CompletionCandidate> {
|
||||||
|
match ask(Request::ListPendingOps) {
|
||||||
|
Some(Response::PendingOps { items }) => queue_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_candidates(items: &[PendingOpInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|op| candidate(op.id.to_string(), format!("{} {}", op.kind, op.path)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_uid() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("devices", Request::ListDevices) {
|
||||||
|
Some(Response::Devices { items }) => device_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_candidates(items: &[DeviceInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let this = if d.this_device {
|
||||||
|
" (this computer)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
candidate(d.uid.as_str(), format!("{}{this}", d.name))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trash_uid() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("trash", Request::ListTrash) {
|
||||||
|
Some(Response::Entries { entries }) => trash_candidates(&entries),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trash_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let help = match e.trashed_from.as_deref() {
|
||||||
|
Some(from) if !from.is_empty() => format!("{} (from {from})", e.name),
|
||||||
|
_ => e.name.clone(),
|
||||||
|
};
|
||||||
|
candidate(e.uid.as_str(), help)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_uid() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("albums", Request::PhotoAlbums) {
|
||||||
|
Some(Response::Albums { items, .. }) => album_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_candidates(items: &[AlbumInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|a| candidate(a.uid.as_str(), a.name.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shared_uid() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("shared-with-me", Request::ListSharedWithMe) {
|
||||||
|
Some(Response::Entries { entries }) => shared_candidates(&entries),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let help = if e.shared_by.is_empty() {
|
||||||
|
e.name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} (from {})", e.name, e.shared_by)
|
||||||
|
};
|
||||||
|
candidate(e.uid.as_str(), help)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invitation_id() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("invitations", Request::ListInvitations) {
|
||||||
|
Some(Response::Invitations { items }) => invitation_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invitation_candidates(items: &[InvitationInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|i| {
|
||||||
|
let help = match i.name.as_deref() {
|
||||||
|
Some(name) => format!("{name} from {}", i.inviter_email),
|
||||||
|
None => format!("from {}", i.inviter_email),
|
||||||
|
};
|
||||||
|
candidate(i.id.as_str(), help)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bookmark_token() -> Vec<CompletionCandidate> {
|
||||||
|
match ask_cached("bookmarks", Request::ListBookmarks) {
|
||||||
|
Some(Response::Bookmarks { items }) => bookmark_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bookmark_candidates(items: &[BookmarkInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|b| candidate(b.token.as_str(), b.name.as_deref().unwrap_or(&b.url)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conflict_path() -> Vec<CompletionCandidate> {
|
||||||
|
match ask(Request::ListConflicts) {
|
||||||
|
Some(Response::Conflicts { items }) => conflict_candidates(&items),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict_candidates(items: &[ConflictInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|c| candidate(c.path.as_str(), format!("copy of {}", c.original_path)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ids that belong to the path typed before them
|
||||||
|
|
||||||
|
/// A version of the file named earlier on the line (`pdfs versions restore
|
||||||
|
/// <path> <Tab>`).
|
||||||
|
pub fn version_id(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
let Some(path) = typed_path() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match ask(Request::ListRevisions { path }) {
|
||||||
|
Some(Response::Revisions { items }) => starting_with(current, revision_candidates(&items)),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revision_candidates(items: &[RevisionInfo]) -> Vec<CompletionCandidate> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let current = if r.is_active { ", current" } else { "" };
|
||||||
|
let size = r.claimed_size.unwrap_or(r.size_on_storage).max(0);
|
||||||
|
candidate(
|
||||||
|
r.id.as_str(),
|
||||||
|
format!(
|
||||||
|
"{}, {}{current}",
|
||||||
|
crate::format_epoch_utc(r.created),
|
||||||
|
crate::human_bytes(size as u64)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member or invitation on the share of the path typed earlier on the line.
|
||||||
|
pub fn share_entry_id(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
match share_of_typed_path() {
|
||||||
|
Some((entries, _)) => starting_with(current, share_entry_candidates(&entries)),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_entry_candidates(entries: &[ShareEntry]) -> Vec<CompletionCandidate> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
// The kind is the next argument, so the help names it in the words
|
||||||
|
// that argument takes.
|
||||||
|
let kind = match e.kind {
|
||||||
|
ShareEntryKind::Member => "member",
|
||||||
|
ShareEntryKind::ProtonInvite => "proton",
|
||||||
|
ShareEntryKind::ExternalInvite => "external",
|
||||||
|
};
|
||||||
|
candidate(e.id.as_str(), format!("{} ({}, {kind})", e.email, e.role))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The public link of the path typed earlier on the line.
|
||||||
|
pub fn public_link_id(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||||
|
match share_of_typed_path() {
|
||||||
|
Some((_, Some(link))) => starting_with(current, vec![public_link_candidate(&link)]),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Described without its URL: the fragment is the link's password, and a Tab
|
||||||
|
/// menu is on screen for anyone watching.
|
||||||
|
fn public_link_candidate(link: &PublicLinkInfo) -> CompletionCandidate {
|
||||||
|
let help = match link.expires {
|
||||||
|
Some(expires) => format!(
|
||||||
|
"{} link, expires {}",
|
||||||
|
link.role,
|
||||||
|
crate::format_epoch_utc(expires)
|
||||||
|
),
|
||||||
|
None => format!("{} link", link.role),
|
||||||
|
};
|
||||||
|
candidate(link.id.as_str(), help)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_of_typed_path() -> Option<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
|
match ask(Request::ListShare {
|
||||||
|
path: typed_path()?,
|
||||||
|
})? {
|
||||||
|
Response::Share { entries, link } => Some((entries, link)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`clap_complete::engine::ArgValueCompleter`] candidates are not filtered by
|
||||||
|
/// the engine, unlike [`clap_complete::engine::ArgValueCandidates`].
|
||||||
|
fn starting_with(
|
||||||
|
current: &OsStr,
|
||||||
|
candidates: Vec<CompletionCandidate>,
|
||||||
|
) -> Vec<CompletionCandidate> {
|
||||||
|
let typed = current.to_string_lossy();
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.get_value().to_string_lossy().starts_with(typed.as_ref()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `path` argument of the command being completed, as the service takes
|
||||||
|
/// it. A completer only receives the word under the cursor, so this reads the
|
||||||
|
/// line from the process arguments, where the shell's stub passes it.
|
||||||
|
fn typed_path() -> Option<String> {
|
||||||
|
let args: Vec<OsString> = std::env::args_os().collect();
|
||||||
|
let index = std::env::var("_CLAP_COMPLETE_INDEX")
|
||||||
|
.ok()
|
||||||
|
.and_then(|i| i.parse().ok());
|
||||||
|
path_in(words_before_cursor(&args, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The words of the line before the one being completed. The stub runs
|
||||||
|
/// `pdfs -- <words>`; bash, zsh and elvish say which word the cursor is on,
|
||||||
|
/// fish and PowerShell pass the line cut at the cursor, so it is the last.
|
||||||
|
fn words_before_cursor(args: &[OsString], index: Option<usize>) -> Vec<OsString> {
|
||||||
|
let start = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--")
|
||||||
|
.map_or(args.len(), |at| at + 1);
|
||||||
|
let mut words = args[start..].to_vec();
|
||||||
|
let index = index.unwrap_or(words.len().saturating_sub(1));
|
||||||
|
words.truncate(index);
|
||||||
|
words
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `words` as far as they go and take the innermost command's `path`.
|
||||||
|
fn path_in(words: Vec<OsString>) -> Option<String> {
|
||||||
|
let matches = crate::Cli::command()
|
||||||
|
.ignore_errors(true)
|
||||||
|
.try_get_matches_from(words)
|
||||||
|
.ok()?;
|
||||||
|
let mut leaf = &matches;
|
||||||
|
while let Some((_, sub)) = leaf.subcommand() {
|
||||||
|
leaf = sub;
|
||||||
|
}
|
||||||
|
let path = leaf.try_get_one::<PathBuf>("path").ok().flatten()?;
|
||||||
|
crate::path_arg(path).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn values(candidates: &[CompletionCandidate]) -> Vec<String> {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.get_value().to_string_lossy().into_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help(candidate: &CompletionCandidate) -> String {
|
||||||
|
candidate
|
||||||
|
.get_help()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(name: &str, is_dir: bool) -> DirEntry {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"is_dir": is_dir,
|
||||||
|
"size": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"pinned": false,
|
||||||
|
"uid": format!("vol~{name}"),
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn words(line: &[&str]) -> Vec<OsString> {
|
||||||
|
line.iter().map(OsString::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_definition_is_valid() {
|
||||||
|
crate::Cli::command().debug_assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_script_is_written_for_every_shell() {
|
||||||
|
for shell in [
|
||||||
|
ShellArg::Bash,
|
||||||
|
ShellArg::Zsh,
|
||||||
|
ShellArg::Fish,
|
||||||
|
ShellArg::Elvish,
|
||||||
|
ShellArg::Powershell,
|
||||||
|
] {
|
||||||
|
let mut script = Vec::new();
|
||||||
|
write_script(shell, &mut script).unwrap();
|
||||||
|
let script = String::from_utf8(script).unwrap();
|
||||||
|
assert!(script.contains(VAR), "{script}");
|
||||||
|
assert!(script.contains("pdfs"), "{script}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_path_splits_parent_and_prefix() {
|
||||||
|
assert_eq!(split_drive_word(""), ("", ""));
|
||||||
|
assert_eq!(split_drive_word("Doc"), ("", "Doc"));
|
||||||
|
assert_eq!(split_drive_word("Documents/"), ("Documents/", ""));
|
||||||
|
assert_eq!(split_drive_word("Documents/Rep"), ("Documents/", "Rep"));
|
||||||
|
assert_eq!(split_drive_word("a/b/c"), ("a/b/", "c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_dirs_end_in_a_slash() {
|
||||||
|
let entries = [
|
||||||
|
entry("Reports", true),
|
||||||
|
entry("Receipt.pdf", false),
|
||||||
|
entry("Other", true),
|
||||||
|
];
|
||||||
|
let all = drive_candidates("Documents/", "Re", &entries, false);
|
||||||
|
assert_eq!(
|
||||||
|
values(&all),
|
||||||
|
["Documents/Reports/", "Documents/Receipt.pdf"]
|
||||||
|
);
|
||||||
|
let dirs = drive_candidates("Documents/", "", &entries, true);
|
||||||
|
assert_eq!(values(&dirs), ["Documents/Reports/", "Documents/Other/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hidden_drive_entries_need_a_typed_dot() {
|
||||||
|
let entries = [entry(".git", true)];
|
||||||
|
assert!(drive_candidates("", "", &entries, false)[0].is_hide_set());
|
||||||
|
assert!(!drive_candidates("", ".", &entries, false)[0].is_hide_set());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_folder_ids_complete_with_their_local_path() {
|
||||||
|
let folders: Vec<SyncFolderInfo> = serde_json::from_value(serde_json::json!([{
|
||||||
|
"id": 3,
|
||||||
|
"local_path": "/home/me/Documents",
|
||||||
|
"remote_uid": "vol~docs",
|
||||||
|
"mode": "mirror",
|
||||||
|
"state": "idle",
|
||||||
|
"last_sync": 0,
|
||||||
|
}]))
|
||||||
|
.unwrap();
|
||||||
|
let candidates = sync_folder_candidates(&folders);
|
||||||
|
assert_eq!(values(&candidates), ["3"]);
|
||||||
|
assert_eq!(help(&candidates[0]), "/home/me/Documents (mirror)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_uids_complete_with_their_name() {
|
||||||
|
let mut from_docs = entry("a.pdf", false);
|
||||||
|
from_docs.trashed_from = Some("Documents".into());
|
||||||
|
let candidates = trash_candidates(&[from_docs, entry("b", true)]);
|
||||||
|
assert_eq!(values(&candidates), ["vol~a.pdf", "vol~b"]);
|
||||||
|
assert_eq!(help(&candidates[0]), "a.pdf (from Documents)");
|
||||||
|
assert_eq!(help(&candidates[1]), "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completer_without_a_service_offers_nothing() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-complete-{}", std::process::id()));
|
||||||
|
let socket = dir.join("control.sock");
|
||||||
|
assert!(ask_at(&socket, &Request::ListSyncFolders).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cached_listing_is_reused_until_it_is_stale() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-complete-cache-{}", std::process::id()));
|
||||||
|
let file = dir.join("completion-devices.json");
|
||||||
|
write_cache(&file, &Response::Devices { items: Vec::new() });
|
||||||
|
assert!(matches!(read_fresh(&file), Some(Response::Devices { .. })));
|
||||||
|
let mode = std::os::unix::fs::PermissionsExt::mode(
|
||||||
|
&std::fs::metadata(&file).unwrap().permissions(),
|
||||||
|
);
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
|
||||||
|
let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(1);
|
||||||
|
std::fs::File::options()
|
||||||
|
.write(true)
|
||||||
|
.open(&file)
|
||||||
|
.unwrap()
|
||||||
|
.set_modified(stale)
|
||||||
|
.unwrap();
|
||||||
|
assert!(read_fresh(&file).is_none());
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_before_cursor_stop_at_the_cursor() {
|
||||||
|
let args = words(&[
|
||||||
|
"pdfs", "--", "pdfs", "versions", "restore", "a.txt", "", "x",
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
words_before_cursor(&args, Some(4)),
|
||||||
|
words(&["pdfs", "versions", "restore", "a.txt"])
|
||||||
|
);
|
||||||
|
// fish: the line arrives cut at the cursor, the current word last.
|
||||||
|
let fish = words(&["pdfs", "--", "pdfs", "versions", "restore", "a.txt", "ab"]);
|
||||||
|
assert_eq!(
|
||||||
|
words_before_cursor(&fish, None),
|
||||||
|
words(&["pdfs", "versions", "restore", "a.txt"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completing_words_finds_the_path_before_the_id() {
|
||||||
|
let line = words(&["pdfs", "versions", "restore", "Documents/a.txt"]);
|
||||||
|
assert_eq!(path_in(line).as_deref(), Some("Documents/a.txt"));
|
||||||
|
let line = words(&["pdfs", "--json", "share-role", "Shared/x"]);
|
||||||
|
assert_eq!(path_in(line).as_deref(), Some("Shared/x"));
|
||||||
|
let line = words(&["pdfs", "public-link", "update", "--role", "editor", "a"]);
|
||||||
|
assert_eq!(path_in(line).as_deref(), Some("a"));
|
||||||
|
assert_eq!(path_in(words(&["pdfs", "versions", "restore"])), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-22
@@ -8,7 +8,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand, ValueHint};
|
||||||
|
use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter};
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
@@ -20,6 +21,7 @@ use pdfs_core::control::{
|
|||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
|
mod complete;
|
||||||
mod journal;
|
mod journal;
|
||||||
|
|
||||||
/// `println!` for this binary, shadowing std's, which panics when stdout is
|
/// `println!` for this binary, shadowing std's, which panics when stdout is
|
||||||
@@ -165,6 +167,7 @@ enum Command {
|
|||||||
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
||||||
Mount {
|
Mount {
|
||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
||||||
@@ -172,6 +175,7 @@ enum Command {
|
|||||||
/// don't invoke it by hand.
|
/// don't invoke it by hand.
|
||||||
Daemon {
|
Daemon {
|
||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Keep a file or folder on this device (download + cache it). A folder's
|
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||||
@@ -179,11 +183,13 @@ enum Command {
|
|||||||
/// Needs a running mount.
|
/// Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File or folder path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Stop keeping a file or folder on this device, evicting its cached content.
|
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||||
Unpin {
|
Unpin {
|
||||||
/// File or folder path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// List files and folders pinned to this device.
|
/// List files and folders pinned to this device.
|
||||||
@@ -191,6 +197,7 @@ enum Command {
|
|||||||
/// List a directory via the running daemon (in-app browser backend).
|
/// List a directory via the running daemon (in-app browser backend).
|
||||||
Ls {
|
Ls {
|
||||||
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
|
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// List the photos timeline (newest first) via the running daemon.
|
/// List the photos timeline (newest first) via the running daemon.
|
||||||
@@ -224,7 +231,7 @@ enum Command {
|
|||||||
/// so an interrupted import can simply be run again.
|
/// so an interrupted import can simply be run again.
|
||||||
ImportGooglePhotos {
|
ImportGooglePhotos {
|
||||||
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true, value_hint = ValueHint::FilePath)]
|
||||||
archives: Vec<PathBuf>,
|
archives: Vec<PathBuf>,
|
||||||
/// Report what would be imported without uploading anything.
|
/// Report what would be imported without uploading anything.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -277,6 +284,7 @@ enum Command {
|
|||||||
/// List the photos in one album (newest capture first).
|
/// List the photos in one album (newest capture first).
|
||||||
Album {
|
Album {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Max photos to list.
|
/// Max photos to list.
|
||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
@@ -293,6 +301,7 @@ enum Command {
|
|||||||
/// Rename a photo album.
|
/// Rename a photo album.
|
||||||
RenameAlbum {
|
RenameAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// New name.
|
/// New name.
|
||||||
name: String,
|
name: String,
|
||||||
@@ -300,6 +309,7 @@ enum Command {
|
|||||||
/// Delete a photo album. Its photos stay in the timeline.
|
/// Delete a photo album. Its photos stay in the timeline.
|
||||||
DeleteAlbum {
|
DeleteAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Also delete photos that exist only in this album (saved from a
|
/// Also delete photos that exist only in this album (saved from a
|
||||||
/// shared album); without it the server refuses to delete such an album.
|
/// shared album); without it the server refuses to delete such an album.
|
||||||
@@ -309,6 +319,7 @@ enum Command {
|
|||||||
/// Add photos to an album.
|
/// Add photos to an album.
|
||||||
AddToAlbum {
|
AddToAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Photo node uids (from `pdfs photos`).
|
/// Photo node uids (from `pdfs photos`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
@@ -317,6 +328,7 @@ enum Command {
|
|||||||
/// Take photos out of an album. They stay in the timeline.
|
/// Take photos out of an album. They stay in the timeline.
|
||||||
RemoveFromAlbum {
|
RemoveFromAlbum {
|
||||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::album_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Photo node uids (from `pdfs album <uid>`).
|
/// Photo node uids (from `pdfs album <uid>`).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
@@ -335,13 +347,14 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
/// Only search below this folder (under the mountpoint).
|
/// Only search below this folder (under the mountpoint).
|
||||||
#[arg(long = "in", value_name = "FOLDER")]
|
#[arg(long = "in", value_name = "FOLDER", add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
scope: Option<PathBuf>,
|
scope: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Rename a file or folder via the running daemon.
|
/// Rename a file or folder via the running daemon.
|
||||||
Rename {
|
Rename {
|
||||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||||
/// relative to the mountpoint.
|
/// relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// New name (a single path component).
|
/// New name (a single path component).
|
||||||
new_name: String,
|
new_name: String,
|
||||||
@@ -355,7 +368,7 @@ enum Command {
|
|||||||
Move {
|
Move {
|
||||||
/// Files/folders to move, then the destination folder: each inside a
|
/// Files/folders to move, then the destination folder: each inside a
|
||||||
/// location, or relative to the mountpoint.
|
/// location, or relative to the mountpoint.
|
||||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
paths: Vec<PathBuf>,
|
paths: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Copy files or folders into another folder in My files via the running
|
/// Copy files or folders into another folder in My files via the running
|
||||||
@@ -367,7 +380,7 @@ enum Command {
|
|||||||
Copy {
|
Copy {
|
||||||
/// Files/folders to copy, then the destination folder: inside My files,
|
/// Files/folders to copy, then the destination folder: inside My files,
|
||||||
/// or relative to the mountpoint.
|
/// or relative to the mountpoint.
|
||||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
#[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
paths: Vec<PathBuf>,
|
paths: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Show how much a folder holds on Proton Drive.
|
/// Show how much a folder holds on Proton Drive.
|
||||||
@@ -376,18 +389,21 @@ enum Command {
|
|||||||
/// encrypted bytes on storage and includes items in the trash.
|
/// encrypted bytes on storage and includes items in the trash.
|
||||||
Du {
|
Du {
|
||||||
/// Folder path, inside My files or relative to the mountpoint.
|
/// Folder path, inside My files or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Trash a file or folder via the running daemon.
|
/// Trash a file or folder via the running daemon.
|
||||||
Rm {
|
Rm {
|
||||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||||
/// relative to the mountpoint.
|
/// relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Create a folder via the running daemon.
|
/// Create a folder via the running daemon.
|
||||||
Mkdir {
|
Mkdir {
|
||||||
/// Parent folder path, inside My files or an on-demand synced folder,
|
/// Parent folder path, inside My files or an on-demand synced folder,
|
||||||
/// or relative to the mountpoint.
|
/// or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
parent: PathBuf,
|
parent: PathBuf,
|
||||||
/// New folder name (a single path component).
|
/// New folder name (a single path component).
|
||||||
name: String,
|
name: String,
|
||||||
@@ -397,10 +413,10 @@ enum Command {
|
|||||||
/// background; watch progress with `pdfs transfers`.
|
/// background; watch progress with `pdfs transfers`.
|
||||||
Upload {
|
Upload {
|
||||||
/// Local files and/or folders to upload.
|
/// Local files and/or folders to upload.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, value_hint = ValueHint::AnyPath)]
|
||||||
sources: Vec<PathBuf>,
|
sources: Vec<PathBuf>,
|
||||||
/// Destination folder, inside the mountpoint or relative to it.
|
/// Destination folder, inside the mountpoint or relative to it.
|
||||||
#[arg(short = 't', long = "to", default_value = ".")]
|
#[arg(short = 't', long = "to", default_value = ".", add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
parent: PathBuf,
|
parent: PathBuf,
|
||||||
},
|
},
|
||||||
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
||||||
@@ -415,13 +431,13 @@ enum Command {
|
|||||||
/// Restore trashed items, by uid, to the folders they were trashed from.
|
/// Restore trashed items, by uid, to the folders they were trashed from.
|
||||||
Restore {
|
Restore {
|
||||||
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
|
||||||
uids: Vec<String>,
|
uids: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Permanently delete trashed items by uid. This cannot be undone.
|
/// Permanently delete trashed items by uid. This cannot be undone.
|
||||||
DeleteForever {
|
DeleteForever {
|
||||||
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
/// Node uids (`volume~link`), as printed by `pdfs trash`.
|
||||||
#[arg(required = true)]
|
#[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))]
|
||||||
uids: Vec<String>,
|
uids: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Permanently delete everything in the trash. This cannot be undone.
|
/// Permanently delete everything in the trash. This cannot be undone.
|
||||||
@@ -433,6 +449,7 @@ enum Command {
|
|||||||
Refresh {
|
Refresh {
|
||||||
/// What to refresh: a folder path (inside the mountpoint or relative to
|
/// What to refresh: a folder path (inside the mountpoint or relative to
|
||||||
/// it), `trash`, or `photos`. The mount root if omitted.
|
/// it), `trash`, or `photos`. The mount root if omitted.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::refresh_target))]
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
/// With `photos`: read every photo's metadata again instead of only what
|
/// With `photos`: read every photo's metadata again instead of only what
|
||||||
/// changed. Minutes on a large library; for a gallery that disagrees with
|
/// changed. Minutes on a large library; for a gallery that disagrees with
|
||||||
@@ -459,12 +476,13 @@ enum Command {
|
|||||||
/// Share a file or folder with Proton and/or external email addresses.
|
/// Share a file or folder with Proton and/or external email addresses.
|
||||||
Share {
|
Share {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// One or more invitee emails (Proton or external, auto-detected).
|
/// One or more invitee emails (Proton or external, auto-detected).
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
emails: Vec<String>,
|
emails: Vec<String>,
|
||||||
/// Role to grant: viewer, editor or admin.
|
/// Role to grant: viewer, editor or admin.
|
||||||
#[arg(long, default_value = "viewer")]
|
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
|
||||||
role: String,
|
role: String,
|
||||||
/// Optional message included in the invitation email.
|
/// Optional message included in the invitation email.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -473,17 +491,21 @@ enum Command {
|
|||||||
/// List a node's members, pending invitations and public link.
|
/// List a node's members, pending invitations and public link.
|
||||||
Members {
|
Members {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Change the role of a member or pending invitation on a node's share.
|
/// Change the role of a member or pending invitation on a node's share.
|
||||||
ShareRole {
|
ShareRole {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::share_entry_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: ShareKindArg,
|
kind: ShareKindArg,
|
||||||
/// New role: viewer, editor or admin.
|
/// New role: viewer, editor or admin.
|
||||||
|
#[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)]
|
||||||
role: String,
|
role: String,
|
||||||
},
|
},
|
||||||
/// Stop sharing a node, or remove one member or pending invitation.
|
/// Stop sharing a node, or remove one member or pending invitation.
|
||||||
@@ -492,9 +514,10 @@ enum Command {
|
|||||||
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
||||||
Unshare {
|
Unshare {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||||
#[arg(requires = "kind")]
|
#[arg(requires = "kind", add = ArgValueCompleter::new(complete::share_entry_id))]
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
/// Entry kind: member, proton or external.
|
/// Entry kind: member, proton or external.
|
||||||
kind: Option<ShareKindArg>,
|
kind: Option<ShareKindArg>,
|
||||||
@@ -515,18 +538,22 @@ enum Command {
|
|||||||
SharedWithMe {
|
SharedWithMe {
|
||||||
/// Folder uid to list into, in `volume~link` form (from a previous run).
|
/// Folder uid to list into, in `volume~link` form (from a previous run).
|
||||||
/// Omit for the top level.
|
/// Omit for the top level.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: Option<String>,
|
uid: Option<String>,
|
||||||
},
|
},
|
||||||
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
|
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
|
||||||
SharedGet {
|
SharedGet {
|
||||||
/// Node uid in `volume~link` form.
|
/// Node uid in `volume~link` form.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Where to write it. Omit to leave it in the cache and print the path.
|
/// Where to write it. Omit to leave it in the cache and print the path.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: Option<PathBuf>,
|
dest: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
|
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
|
||||||
Leave {
|
Leave {
|
||||||
/// Node uid in `volume~link` form.
|
/// Node uid in `volume~link` form.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::shared_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
},
|
},
|
||||||
/// Manage invitations addressed to me.
|
/// Manage invitations addressed to me.
|
||||||
@@ -557,6 +584,17 @@ enum Command {
|
|||||||
/// Runs without a daemon on purpose: the state worth diagnosing is usually
|
/// Runs without a daemon on purpose: the state worth diagnosing is usually
|
||||||
/// the state where the daemon will not start.
|
/// the state where the daemon will not start.
|
||||||
Diagnose,
|
Diagnose,
|
||||||
|
/// Print the shell completion script for bash, zsh, fish, elvish or
|
||||||
|
/// PowerShell.
|
||||||
|
///
|
||||||
|
/// Besides commands and options, it completes values from the running
|
||||||
|
/// service: paths in My files, synced-folder and queue ids, computers,
|
||||||
|
/// trash, albums, shares and versions. Load it with, for example,
|
||||||
|
/// `pdfs completions fish | source` or `source <(pdfs completions bash)`.
|
||||||
|
Completions {
|
||||||
|
/// The shell to write the script for.
|
||||||
|
shell: complete::ShellArg,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -604,6 +642,7 @@ enum VersionCmd {
|
|||||||
/// List a file's versions, newest first.
|
/// List a file's versions, newest first.
|
||||||
List {
|
List {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Make an earlier version the file's current content.
|
/// Make an earlier version the file's current content.
|
||||||
@@ -612,25 +651,32 @@ enum VersionCmd {
|
|||||||
/// moment to show up in the mount.
|
/// moment to show up in the mount.
|
||||||
Restore {
|
Restore {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
/// Write an earlier version to a local file, leaving the file untouched.
|
/// Write an earlier version to a local file, leaving the file untouched.
|
||||||
Save {
|
Save {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// Where to write it. Must not already exist.
|
/// Where to write it. Must not already exist.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: PathBuf,
|
dest: PathBuf,
|
||||||
},
|
},
|
||||||
/// Permanently delete an earlier version. The content is unrecoverable, and
|
/// Permanently delete an earlier version. The content is unrecoverable, and
|
||||||
/// the version that is currently active cannot be deleted.
|
/// the version that is currently active cannot be deleted.
|
||||||
Rm {
|
Rm {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Version id, from `pdfs versions list`.
|
/// Version id, from `pdfs versions list`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::version_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -640,13 +686,21 @@ enum DeviceCmd {
|
|||||||
/// List registered devices.
|
/// List registered devices.
|
||||||
List,
|
List,
|
||||||
/// Rename a device by uid.
|
/// Rename a device by uid.
|
||||||
Rename { uid: String, name: String },
|
Rename {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
|
uid: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
/// Delete (deregister) a device by uid.
|
/// Delete (deregister) a device by uid.
|
||||||
Rm { uid: String },
|
Rm {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
|
uid: String,
|
||||||
|
},
|
||||||
/// Adopt an existing device as this machine's, so a hostname change or a
|
/// Adopt an existing device as this machine's, so a hostname change or a
|
||||||
/// reinstall re-attaches to it instead of registering a duplicate.
|
/// reinstall re-attaches to it instead of registering a duplicate.
|
||||||
Adopt {
|
Adopt {
|
||||||
/// Device uid (from `devices list`). Omit with `--clear`.
|
/// Device uid (from `devices list`). Omit with `--clear`.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: Option<String>,
|
uid: Option<String>,
|
||||||
/// Drop the adoption and go back to matching by hostname.
|
/// Drop the adoption and go back to matching by hostname.
|
||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
@@ -657,6 +711,7 @@ enum DeviceCmd {
|
|||||||
/// here. Both computers keep them in step.
|
/// here. Both computers keep them in step.
|
||||||
Join {
|
Join {
|
||||||
/// Device uid (from `devices list`).
|
/// Device uid (from `devices list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Skip the confirmation and accept every proposed local path.
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -666,6 +721,7 @@ enum DeviceCmd {
|
|||||||
/// them here. The other computer stays registered, without those folders.
|
/// them here. The other computer stays registered, without those folders.
|
||||||
Migrate {
|
Migrate {
|
||||||
/// Device uid (from `devices list`).
|
/// Device uid (from `devices list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
uid: String,
|
uid: String,
|
||||||
/// Skip the confirmation and accept every proposed local path.
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -678,6 +734,7 @@ enum ConflictCmd {
|
|||||||
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
||||||
Resolve {
|
Resolve {
|
||||||
/// The conflict copy, absolute or relative to the mountpoint.
|
/// The conflict copy, absolute or relative to the mountpoint.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::conflict_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// `original` trashes the copy; `copy` trashes the original and gives the
|
/// `original` trashes the copy; `copy` trashes the original and gives the
|
||||||
/// copy its name; `both` renames the copy to `--name`.
|
/// copy its name; `both` renames the copy to `--name`.
|
||||||
@@ -694,15 +751,17 @@ enum SyncCmd {
|
|||||||
/// Add a local folder to this machine's device and upload its contents.
|
/// Add a local folder to this machine's device and upload its contents.
|
||||||
Add {
|
Add {
|
||||||
/// Local directory to sync.
|
/// Local directory to sync.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Sync a real local folder with an existing folder in My files.
|
/// Sync a real local folder with an existing folder in My files.
|
||||||
/// The first pass preserves local-only files and keeps differing versions.
|
/// The first pass preserves local-only files and keeps differing versions.
|
||||||
Attach {
|
Attach {
|
||||||
/// Existing local directory; it remains a real local directory.
|
/// Existing local directory; it remains a real local directory.
|
||||||
|
#[arg(value_hint = ValueHint::DirPath)]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Existing folder in My files, relative to the cloud mount or inside it.
|
/// Existing folder in My files, relative to the cloud mount or inside it.
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCompleter::new(complete::drive_dir))]
|
||||||
to: PathBuf,
|
to: PathBuf,
|
||||||
},
|
},
|
||||||
/// List this machine's synced folders.
|
/// List this machine's synced folders.
|
||||||
@@ -710,6 +769,7 @@ enum SyncCmd {
|
|||||||
/// Remove a synced folder by id.
|
/// Remove a synced folder by id.
|
||||||
Rm {
|
Rm {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Also delete the folder's copy in Proton Drive.
|
/// Also delete the folder's copy in Proton Drive.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -718,6 +778,7 @@ enum SyncCmd {
|
|||||||
/// Force a sync pass now (all folders, or one by id).
|
/// Force a sync pass now (all folders, or one by id).
|
||||||
Now {
|
Now {
|
||||||
/// Optional synced-folder id; omit to reconcile every folder.
|
/// Optional synced-folder id; omit to reconcile every folder.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Forget what a synced folder synced before and compare it with Proton
|
/// Forget what a synced folder synced before and compare it with Proton
|
||||||
@@ -725,6 +786,7 @@ enum SyncCmd {
|
|||||||
/// that differs keeps both versions. Nothing is deleted.
|
/// that differs keeps both versions. Nothing is deleted.
|
||||||
Reset {
|
Reset {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
},
|
},
|
||||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||||
@@ -734,13 +796,13 @@ enum SyncCmd {
|
|||||||
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
||||||
duration: Option<u64>,
|
duration: Option<u64>,
|
||||||
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
folder: Option<i64>,
|
folder: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Resume syncing after `pdfs sync pause`.
|
/// Resume syncing after `pdfs sync pause`.
|
||||||
Resume {
|
Resume {
|
||||||
/// Resume only this folder, paused with `sync pause --folder`.
|
/// Resume only this folder, paused with `sync pause --folder`.
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
folder: Option<i64>,
|
folder: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Cap upload and download speed, or show the caps with no options.
|
/// Cap upload and download speed, or show the caps with no options.
|
||||||
@@ -759,8 +821,10 @@ enum SyncCmd {
|
|||||||
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
||||||
Export {
|
Export {
|
||||||
/// Queue id.
|
/// Queue id.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Where to save it: a new file, or an existing folder to save it in.
|
/// Where to save it: a new file, or an existing folder to save it in.
|
||||||
|
#[arg(value_hint = ValueHint::AnyPath)]
|
||||||
dest: PathBuf,
|
dest: PathBuf,
|
||||||
},
|
},
|
||||||
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
||||||
@@ -768,6 +832,7 @@ enum SyncCmd {
|
|||||||
/// content is lost unless saved with `sync export` first.
|
/// content is lost unless saved with `sync export` first.
|
||||||
Discard {
|
Discard {
|
||||||
/// Queue id.
|
/// Queue id.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// Do not ask first.
|
/// Do not ask first.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -776,11 +841,13 @@ enum SyncCmd {
|
|||||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||||
Retry {
|
Retry {
|
||||||
/// Queue id; omit to retry every failed operation.
|
/// Queue id; omit to retry every failed operation.
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::queue_id))]
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
||||||
Mode {
|
Mode {
|
||||||
/// Synced-folder id (from `sync list`).
|
/// Synced-folder id (from `sync list`).
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::sync_folder_id))]
|
||||||
id: i64,
|
id: i64,
|
||||||
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
|
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
|
||||||
#[arg(value_parser = ["mirror", "ondemand"])]
|
#[arg(value_parser = ["mirror", "ondemand"])]
|
||||||
@@ -794,7 +861,7 @@ enum SyncCmd {
|
|||||||
yes: bool,
|
yes: bool,
|
||||||
/// Restore another computer's folders instead, by device uid (from
|
/// Restore another computer's folders instead, by device uid (from
|
||||||
/// `pdfs devices list`).
|
/// `pdfs devices list`).
|
||||||
#[arg(long)]
|
#[arg(long, add = ArgValueCandidates::new(complete::device_uid))]
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -804,9 +871,10 @@ enum PublicLinkCmd {
|
|||||||
/// Create a public link on a node.
|
/// Create a public link on a node.
|
||||||
Create {
|
Create {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Role: viewer or editor.
|
/// Role: viewer or editor.
|
||||||
#[arg(long, default_value = "viewer")]
|
#[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)]
|
||||||
role: String,
|
role: String,
|
||||||
/// Optional custom password protecting the link.
|
/// Optional custom password protecting the link.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -819,11 +887,13 @@ enum PublicLinkCmd {
|
|||||||
/// given is kept.
|
/// given is kept.
|
||||||
Update {
|
Update {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Public-link id, from `pdfs members`.
|
/// Public-link id, from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
|
||||||
id: String,
|
id: String,
|
||||||
/// New role: viewer or editor.
|
/// New role: viewer or editor.
|
||||||
#[arg(long)]
|
#[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)]
|
||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
/// New custom password protecting the link.
|
/// New custom password protecting the link.
|
||||||
#[arg(long, conflicts_with = "no_password")]
|
#[arg(long, conflicts_with = "no_password")]
|
||||||
@@ -841,8 +911,10 @@ enum PublicLinkCmd {
|
|||||||
/// Remove a node's public link.
|
/// Remove a node's public link.
|
||||||
Remove {
|
Remove {
|
||||||
/// File/folder path, inside the mountpoint or relative to it.
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::drive_path))]
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Public-link id, from `pdfs members`.
|
/// Public-link id, from `pdfs members`.
|
||||||
|
#[arg(add = ArgValueCompleter::new(complete::public_link_id))]
|
||||||
id: String,
|
id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -852,9 +924,15 @@ enum InvitationCmd {
|
|||||||
/// List invitations addressed to me.
|
/// List invitations addressed to me.
|
||||||
List,
|
List,
|
||||||
/// Accept an invitation by id.
|
/// Accept an invitation by id.
|
||||||
Accept { id: String },
|
Accept {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
/// Reject an invitation by id.
|
/// Reject an invitation by id.
|
||||||
Reject { id: String },
|
Reject {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::invitation_id))]
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -870,7 +948,10 @@ enum BookmarkCmd {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
/// Remove a saved bookmark by its token.
|
/// Remove a saved bookmark by its token.
|
||||||
Rm { token: String },
|
Rm {
|
||||||
|
#[arg(add = ArgValueCandidates::new(complete::bookmark_token))]
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a daemon failure for a terminal.
|
/// Render a daemon failure for a terminal.
|
||||||
@@ -890,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
// First: a completion request answers on stdout and exits, so nothing may
|
||||||
|
// write there before it.
|
||||||
|
complete::handle_request();
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
||||||
// packet with a longer (but equivalent) length encoding than the
|
// packet with a longer (but equivalent) length encoding than the
|
||||||
@@ -1030,6 +1114,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Activity { limit } => cmd_activity(limit),
|
Command::Activity { limit } => cmd_activity(limit),
|
||||||
Command::Quota => cmd_quota(),
|
Command::Quota => cmd_quota(),
|
||||||
Command::Cache { action } => cmd_cache(action),
|
Command::Cache { action } => cmd_cache(action),
|
||||||
|
Command::Completions { shell } => cmd_completions(shell),
|
||||||
Command::Diagnose => cmd_diagnose(),
|
Command::Diagnose => cmd_diagnose(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3806,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
|||||||
result.and(remove)
|
result.and(remove)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print the completion script for `shell`. Touches no account state, so
|
||||||
|
/// packaging can run it at build time.
|
||||||
|
fn cmd_completions(shell: complete::ShellArg) -> Result<()> {
|
||||||
|
let mut script = Vec::new();
|
||||||
|
complete::write_script(shell, &mut script)?;
|
||||||
|
print!("{}", String::from_utf8_lossy(&script));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Self-check the installation, reporting each finding and exiting non-zero if
|
/// Self-check the installation, reporting each finding and exiting non-zero if
|
||||||
/// any check failed.
|
/// any check failed.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -2405,9 +2405,17 @@ impl Request {
|
|||||||
///
|
///
|
||||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||||
|
send_with_timeout(socket, req, req.read_timeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`send`] with a caller-chosen limit on how long the daemon may take to
|
||||||
|
/// answer, which also bounds the write. For a caller that must never keep a
|
||||||
|
/// person waiting, such as shell completion, where a slow answer is worth less
|
||||||
|
/// than none.
|
||||||
|
pub fn send_with_timeout(socket: &Path, req: &Request, timeout: Duration) -> Result<Response> {
|
||||||
let mut stream = UnixStream::connect(socket)?;
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
stream.set_read_timeout(Some(req.read_timeout()))?;
|
stream.set_read_timeout(Some(timeout))?;
|
||||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
|
||||||
let mut line = serde_json::to_vec(req)?;
|
let mut line = serde_json::to_vec(req)?;
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
stream.write_all(&line)?;
|
stream.write_all(&line)?;
|
||||||
|
|||||||
+39
-6
@@ -11,15 +11,48 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
Schema: **45**. Synced folders now distinguish device backups from My files attachments.
|
## [3.8.0] — 2026-11-12
|
||||||
Existing folders remain device backups; older builds cannot open the upgraded database.
|
|
||||||
|
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
|
||||||
|
Existing folders stay device backups. Older builds cannot open the upgraded database.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing
|
- **Sync a local folder with a folder you already have in My files** (#67, #77).
|
||||||
My files folder. The first sync preserves one-sided files and differing versions (#67).
|
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
|
||||||
Attachments stay in mirror mode, are excluded from device profiles, and cannot delete
|
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
|
||||||
the remote root when syncing is stopped.
|
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
|
||||||
|
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
|
||||||
|
uploaded too. Attached folders are always kept on this computer, never online only. They are
|
||||||
|
listed apart from device backups and are not part of the device profile used to restore another
|
||||||
|
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
|
||||||
|
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
|
||||||
|
files stay. Attaching a folder is done from a terminal for now.
|
||||||
|
|
||||||
|
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
|
||||||
|
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
|
||||||
|
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
|
||||||
|
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
|
||||||
|
file's versions or the share's members and link. Without the service it still completes commands
|
||||||
|
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
|
||||||
|
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
|
||||||
|
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
|
||||||
|
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||||
|
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||||
|
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||||
|
a dead mount before it opens the folder. On a large account a restart that did open My files was
|
||||||
|
killed again, because loading the account took longer than the 2 minutes the service has to
|
||||||
|
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
|
||||||
|
|
||||||
|
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
|
||||||
|
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
|
||||||
|
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
|
||||||
|
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
|
||||||
|
and a very large photo waits and is made on its own.
|
||||||
|
|
||||||
## [3.7.0] — 2026-11-02
|
## [3.7.0] — 2026-11-02
|
||||||
|
|
||||||
|
|||||||
+32
@@ -8,6 +8,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
|||||||
what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
||||||
|
|
||||||
- [Conventions](#conventions)
|
- [Conventions](#conventions)
|
||||||
|
- [Shell completion](#shell-completion)
|
||||||
- [Account and service](#account-and-service)
|
- [Account and service](#account-and-service)
|
||||||
- [Files](#files)
|
- [Files](#files)
|
||||||
- [Available offline](#available-offline)
|
- [Available offline](#available-offline)
|
||||||
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
|||||||
service.
|
service.
|
||||||
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
|
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
|
||||||
|
|
||||||
|
## Shell completion
|
||||||
|
|
||||||
|
Tab completes commands and options, and also the values the service knows. Paths complete
|
||||||
|
from My files, and these complete with a description: synced-folder and queue ids, computers,
|
||||||
|
trash items, albums, items shared with you, invitations, bookmarks, conflict copies, and a file's
|
||||||
|
versions or a share's members and link once the path is typed. Values come from the running
|
||||||
|
service. Without it, only commands, options and local paths complete.
|
||||||
|
|
||||||
|
| Example | Completes |
|
||||||
|
|---|---|
|
||||||
|
| `pdfs pin Doc<Tab>` | `Documents/`; Tab again lists its contents |
|
||||||
|
| `pdfs sync rm <Tab>` | `1 /home/me/Documents (mirror)` … |
|
||||||
|
| `pdfs devices rm <Tab>` | computer uids, with their names |
|
||||||
|
| `pdfs versions restore Documents/a.pdf <Tab>` | version ids, with date and size |
|
||||||
|
|
||||||
|
The packages install completion for bash, zsh and fish, which these shells load by themselves. For
|
||||||
|
a source build, or another shell, load the script that `pdfs completions <shell>` prints:
|
||||||
|
|
||||||
|
| Shell | Add to |
|
||||||
|
|---|---|
|
||||||
|
| bash | `~/.bashrc`: `source <(pdfs completions bash)` |
|
||||||
|
| zsh | `~/.zshrc`, after `compinit`: `source <(pdfs completions zsh)` |
|
||||||
|
| fish | `~/.config/fish/config.fish`: `pdfs completions fish \| source` |
|
||||||
|
| elvish | `~/.config/elvish/rc.elv`: `eval (pdfs completions elvish \| slurp)` |
|
||||||
|
| PowerShell | `$PROFILE`: `pdfs completions powershell \| Out-String \| Invoke-Expression` |
|
||||||
|
|
||||||
|
The script runs `pdfs` from `PATH` on every Tab, so it keeps working after an update. A
|
||||||
|
`pdfs` that is not on `PATH` prints a script for its own location with
|
||||||
|
`PDFS_COMPLETE=<shell> ./pdfs`.
|
||||||
|
|
||||||
## Account and service
|
## Account and service
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -185,6 +216,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
|
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
|
||||||
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
|
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
|
||||||
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
|
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
|
||||||
|
| `pdfs completions SHELL` | Print the [shell completion](#shell-completion) script for bash, zsh, fish, elvish or powershell. |
|
||||||
|
|
||||||
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
|
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
|||||||
the tag to update:
|
the tag to update:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.7.0";
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each release tag is built with Nix before the release is published.
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.7.0
|
pkgver=3.8.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -80,5 +80,8 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/usr/share/locale"
|
po/build.sh "$pkgdir/usr/share/locale"
|
||||||
|
|
||||||
|
# Shell completion for bash, zsh and fish.
|
||||||
|
packaging/completions.sh "$rel/pdfs" "$pkgdir/usr/share"
|
||||||
|
|
||||||
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,14 @@ package() {
|
|||||||
install -dm755 "$pkgdir/usr/share"
|
install -dm755 "$pkgdir/usr/share"
|
||||||
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
||||||
|
|
||||||
|
# Shell completion for bash, zsh and fish, written by the binary itself.
|
||||||
|
local share="$pkgdir/usr/share"
|
||||||
|
install -d "$share/bash-completion/completions" "$share/zsh/site-functions" \
|
||||||
|
"$share/fish/vendor_completions.d"
|
||||||
|
"$srcdir/pdfs" completions bash > "$share/bash-completion/completions/pdfs"
|
||||||
|
"$srcdir/pdfs" completions zsh > "$share/zsh/site-functions/_pdfs"
|
||||||
|
"$srcdir/pdfs" completions fish > "$share/fish/vendor_completions.d/pdfs.fish"
|
||||||
|
|
||||||
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
# pacman brings new releases, so the app never checks for them itself.
|
# pacman brings new releases, so the app never checks for them itself.
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/usr/share/locale"
|
po/build.sh "$pkgdir/usr/share/locale"
|
||||||
|
|
||||||
|
# Shell completion for bash, zsh and fish.
|
||||||
|
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ package() {
|
|||||||
# Translations: compiled from po/ into /usr/share/locale.
|
# Translations: compiled from po/ into /usr/share/locale.
|
||||||
po/build.sh "$pkgdir/usr/share/locale"
|
po/build.sh "$pkgdir/usr/share/locale"
|
||||||
|
|
||||||
|
# Shell completion for bash, zsh and fish.
|
||||||
|
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Write the shell completion scripts of a built `pdfs` into <datadir>, where
|
||||||
|
# bash-completion, zsh and fish look for them. Packages pass their install
|
||||||
|
# tree, e.g. "$pkgdir/usr/share". The scripts call `pdfs` from PATH on every
|
||||||
|
# Tab, so they are the same for every version and need no rebuild per release.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 2 ]; then
|
||||||
|
echo "usage: $0 <pdfs binary> <datadir>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
pdfs="$1"
|
||||||
|
datadir="$2"
|
||||||
|
|
||||||
|
install -d "$datadir/bash-completion/completions" \
|
||||||
|
"$datadir/zsh/site-functions" \
|
||||||
|
"$datadir/fish/vendor_completions.d"
|
||||||
|
"$pdfs" completions bash > "$datadir/bash-completion/completions/pdfs"
|
||||||
|
"$pdfs" completions zsh > "$datadir/zsh/site-functions/_pdfs"
|
||||||
|
"$pdfs" completions fish > "$datadir/fish/vendor_completions.d/pdfs.fish"
|
||||||
|
chmod 644 "$datadir/bash-completion/completions/pdfs" \
|
||||||
|
"$datadir/zsh/site-functions/_pdfs" \
|
||||||
|
"$datadir/fish/vendor_completions.d/pdfs.fish"
|
||||||
@@ -40,6 +40,7 @@ rustPlatform.buildRustPackage {
|
|||||||
../io.narl.proton-drive-linux.desktop
|
../io.narl.proton-drive-linux.desktop
|
||||||
../io.narl.proton-drive-linux-tray.desktop
|
../io.narl.proton-drive-linux-tray.desktop
|
||||||
../io.narl.proton-drive-linux.svg
|
../io.narl.proton-drive-linux.svg
|
||||||
|
../completions.sh
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +108,9 @@ rustPlatform.buildRustPackage {
|
|||||||
patchShebangs po/build.sh
|
patchShebangs po/build.sh
|
||||||
po/build.sh $out/share/locale
|
po/build.sh $out/share/locale
|
||||||
|
|
||||||
|
patchShebangs packaging/completions.sh
|
||||||
|
packaging/completions.sh $out/bin/pdfs $out/share
|
||||||
|
|
||||||
# Nix brings new releases, so the app never checks for them itself.
|
# Nix brings new releases, so the app never checks for them itself.
|
||||||
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
%global _lto_cflags %{nil}
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
%{!?version: %global version 3.7.0}
|
%{!?version: %global version 3.8.0}
|
||||||
%global forge https://github.com/narrrl/proton-drive-linux
|
%global forge https://github.com/narrrl/proton-drive-linux
|
||||||
# Files come from the checkout, or from the unpacked tag archive.
|
# Files come from the checkout, or from the unpacked tag archive.
|
||||||
%global src %{?git_dir}%{!?git_dir:.}
|
%global src %{?git_dir}%{!?git_dir:.}
|
||||||
@@ -114,6 +114,7 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
|
|||||||
install -D -m0644 %{src}/packaging/proton-drive.service \
|
install -D -m0644 %{src}/packaging/proton-drive.service \
|
||||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||||
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||||
|
%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir}
|
||||||
%if 0%{!?git_dir:1}
|
%if 0%{!?git_dir:1}
|
||||||
# dnf brings new releases from COPR, so the app does not offer its own check.
|
# dnf brings new releases from COPR, so the app does not offer its own check.
|
||||||
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
|
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
|
||||||
@@ -149,6 +150,9 @@ fi
|
|||||||
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||||
/usr/lib/systemd/user/proton-drive.service
|
/usr/lib/systemd/user/proton-drive.service
|
||||||
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
||||||
|
%{_datadir}/bash-completion/completions/pdfs
|
||||||
|
%{_datadir}/zsh/site-functions/_pdfs
|
||||||
|
%{_datadir}/fish/vendor_completions.d/pdfs.fish
|
||||||
%if 0%{!?git_dir:1}
|
%if 0%{!?git_dir:1}
|
||||||
%{_datadir}/proton-drive-linux/managed-updates
|
%{_datadir}/proton-drive-linux/managed-updates
|
||||||
%endif
|
%endif
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: proton-drive-linux 3.7.0\n"
|
"Project-Id-Version: proton-drive-linux 3.8.0\n"
|
||||||
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
"Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n"
|
||||||
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
"POT-Creation-Date: 2026-11-01 17:51+0100\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
|
|||||||
Reference in new issue
Block a user