mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(sharing,gui,cli): change public links and show folder sizes
The share dialog gets Link settings under an existing link: role, password and expiry apply at once and keep the URL. The daemon fills in the options a change keeps from the link as it stands, and refuses to keep a password it cannot read rather than dropping it. pdfs public-link update does the same from a terminal, and pdfs members shows the expiry. Details shows a folder's size and item count as Proton Drive counts it, trash included, and pdfs du prints it. Both are refused offline and for a folder that is not on Drive yet. Needs proton-drive-rs with update_public_link, get_folder_size and PublicLink::custom_password.
This commit is contained in:
1 parent
ee59e338b6
commit
4edcff3f87
21 files changed
+1824
-686
No files matched your search
@@ -13,7 +13,7 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||
ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||
daemon_outdated, pending_summary,
|
||||
};
|
||||
@@ -368,6 +368,14 @@ enum Command {
|
||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Show how much a folder holds on Proton Drive.
|
||||
///
|
||||
/// Proton Drive adds it up, so nothing is downloaded. The size is the
|
||||
/// encrypted bytes on storage and includes items in the trash.
|
||||
Du {
|
||||
/// Folder path, inside My files or relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Trash a file or folder via the running daemon.
|
||||
Rm {
|
||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||
@@ -789,6 +797,29 @@ enum PublicLinkCmd {
|
||||
#[arg(long)]
|
||||
expires: Option<i64>,
|
||||
},
|
||||
/// Change a node's public link. The URL stays the same; an option not
|
||||
/// given is kept.
|
||||
Update {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Public-link id, from `pdfs members`.
|
||||
id: String,
|
||||
/// New role: viewer or editor.
|
||||
#[arg(long)]
|
||||
role: Option<String>,
|
||||
/// New custom password protecting the link.
|
||||
#[arg(long, conflicts_with = "no_password")]
|
||||
password: Option<String>,
|
||||
/// Remove the custom password.
|
||||
#[arg(long)]
|
||||
no_password: bool,
|
||||
/// New expiry, Unix epoch seconds.
|
||||
#[arg(long, conflicts_with = "no_expiry")]
|
||||
expires: Option<i64>,
|
||||
/// Remove the expiry.
|
||||
#[arg(long)]
|
||||
no_expiry: bool,
|
||||
},
|
||||
/// Remove a node's public link.
|
||||
Remove {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
@@ -935,6 +966,7 @@ fn main() -> Result<()> {
|
||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||
cmd_copy(paths, new_parent)
|
||||
}
|
||||
Command::Du { path } => cmd_du(path),
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
||||
@@ -1528,7 +1560,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
|
||||
Some(l) => {
|
||||
let url = l.url.as_deref().unwrap_or("(url hidden)");
|
||||
let pw = if l.has_password { " +password" } else { "" };
|
||||
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
|
||||
let expiry = l
|
||||
.expires
|
||||
.map(|at| format!(", expires {}", format_epoch_utc(at)))
|
||||
.unwrap_or_default();
|
||||
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
|
||||
}
|
||||
None => println!("public link: none"),
|
||||
}
|
||||
@@ -1580,6 +1616,27 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
PublicLinkCmd::Update {
|
||||
path,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
no_password,
|
||||
expires,
|
||||
no_expiry,
|
||||
} => match control_request(CtlRequest::UpdatePublicLink {
|
||||
path: path_arg(&path)?,
|
||||
id,
|
||||
role,
|
||||
password: link_change(password, no_password),
|
||||
expires: link_change(expires, no_expiry),
|
||||
})? {
|
||||
CtlResponse::PublicLink { link } => {
|
||||
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
PublicLinkCmd::Remove { path, id } => {
|
||||
ok_or_bail(control_request(CtlRequest::RemovePublicLink {
|
||||
path: path_arg(&path)?,
|
||||
@@ -1590,6 +1647,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
|
||||
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
|
||||
match (value, remove) {
|
||||
(Some(value), _) => LinkChange::Set(value),
|
||||
(None, true) => LinkChange::Remove,
|
||||
(None, false) => LinkChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_shared() -> Result<()> {
|
||||
match control_request(CtlRequest::ListSharedByMe)? {
|
||||
CtlResponse::SharedByMe { items } if items.is_empty() => {
|
||||
@@ -2966,6 +3032,23 @@ fn cmd_copy(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_du(path: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::FolderSize {
|
||||
path: path_arg(&path)?,
|
||||
})? {
|
||||
CtlResponse::FolderSize { size, items } => {
|
||||
let noun = if items == 1 { "item" } else { "items" };
|
||||
println!(
|
||||
"{} in {items} {noun} (on Proton Drive, trash included)",
|
||||
human_bytes(size)
|
||||
);
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rm(path: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::Delete {
|
||||
path: path_arg(&path)?,
|
||||
@@ -3952,6 +4035,13 @@ mod login_wait_tests {
|
||||
assert_eq!(slept, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_flag_pair_keeps_removes_or_sets() {
|
||||
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
|
||||
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
|
||||
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_seconds_format_as_utc_calendar_dates() {
|
||||
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
||||
|
||||
@@ -350,6 +350,11 @@ pub enum Request {
|
||||
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Copy { path: String, new_parent: String },
|
||||
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
|
||||
/// holds. Replies with [`Response::FolderSize`].
|
||||
FolderSize { path: String },
|
||||
/// The by-uid twin of [`Request::FolderSize`].
|
||||
FolderSizeByUid { uid: String },
|
||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Delete { path: String },
|
||||
@@ -602,6 +607,24 @@ pub enum Request {
|
||||
RemovePublicLink { path: String, id: String },
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
/// Change the public link `id` on the node at `path`, keeping its URL.
|
||||
/// `role` is "viewer" or "editor", or `None` to keep the current one.
|
||||
/// Replies with [`Response::PublicLink`].
|
||||
UpdatePublicLink {
|
||||
path: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// The by-uid twin of [`Request::UpdatePublicLink`].
|
||||
UpdatePublicLinkByUid {
|
||||
uid: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||
/// invitation and every member. Each removal is attempted even if an
|
||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||
@@ -1081,6 +1104,18 @@ pub struct PublicLinkInfo {
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
|
||||
/// custom password or its expiry.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LinkChange<T> {
|
||||
/// Leave the option as it is.
|
||||
Keep,
|
||||
/// Remove the option: no custom password, or no expiry.
|
||||
Remove,
|
||||
/// Set the option to this value.
|
||||
Set(T),
|
||||
}
|
||||
|
||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct InvitationInfo {
|
||||
@@ -2192,9 +2227,14 @@ pub enum Response {
|
||||
entries: Vec<ShareEntry>,
|
||||
link: Option<PublicLinkInfo>,
|
||||
},
|
||||
/// A freshly created public link, carrying the shareable URL (reply to
|
||||
/// [`Request::CreatePublicLink`]).
|
||||
/// A freshly created or changed public link, carrying the shareable URL
|
||||
/// (reply to [`Request::CreatePublicLink`] and
|
||||
/// [`Request::UpdatePublicLink`]).
|
||||
PublicLink { link: PublicLinkInfo },
|
||||
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
|
||||
/// the bytes on storage and the number of items below it, folders and
|
||||
/// trashed items included.
|
||||
FolderSize { size: u64, items: u64 },
|
||||
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
||||
Invitations { items: Vec<InvitationInfo> },
|
||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||
@@ -3258,6 +3298,24 @@ mod tests {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
},
|
||||
Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: Some("editor".into()),
|
||||
password: LinkChange::Set("pw".into()),
|
||||
expires: LinkChange::Remove,
|
||||
},
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
},
|
||||
Request::FolderSize { path: "a".into() },
|
||||
Request::FolderSizeByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
Request::ListSharedByMe,
|
||||
Request::ListActivity { limit: 100 },
|
||||
Request::ListSharedWithMe,
|
||||
@@ -3632,6 +3690,16 @@ mod tests {
|
||||
},
|
||||
"RemovePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "link".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Keep,
|
||||
},
|
||||
"UpdatePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::StopSharingByUid {
|
||||
uid: "vol~link".into(),
|
||||
@@ -3657,6 +3725,7 @@ mod tests {
|
||||
| Request::RemoveShareEntryByUid { uid, .. }
|
||||
| Request::CreatePublicLinkByUid { uid, .. }
|
||||
| Request::RemovePublicLinkByUid { uid, .. }
|
||||
| Request::UpdatePublicLinkByUid { uid, .. }
|
||||
| Request::StopSharingByUid { uid } => uid,
|
||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||
};
|
||||
@@ -3665,6 +3734,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_update_names_what_it_keeps_removes_and_sets() {
|
||||
let request = Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&request).unwrap(),
|
||||
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
|
||||
r#""Remove""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
||||
|
||||
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Answer a [`CtlRequest::FolderSize`].
|
||||
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
|
||||
match size {
|
||||
Ok(size) => CtlResponse::FolderSize {
|
||||
size: size.size,
|
||||
items: size.descendants,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||
/// into an error that names what is still shared.
|
||||
fn stop_sharing_reply(
|
||||
@@ -392,6 +403,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::CreatePublicLinkByUid { .. }
|
||||
| R::RemovePublicLink { .. }
|
||||
| R::RemovePublicLinkByUid { .. }
|
||||
| R::UpdatePublicLink { .. }
|
||||
| R::UpdatePublicLinkByUid { .. }
|
||||
| R::StopSharing { .. }
|
||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||
R::LeaveShared { .. }
|
||||
@@ -1088,6 +1101,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
|
||||
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.delete(&rel) {
|
||||
Ok(name) => {
|
||||
@@ -1655,6 +1673,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLink {
|
||||
path,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => {
|
||||
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLinkByUid {
|
||||
uid,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
|
||||
@@ -5288,6 +5288,43 @@ impl Core {
|
||||
Ok(copy_name)
|
||||
}
|
||||
|
||||
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
|
||||
/// number of items below it. Drive adds it up, so nothing is listed here;
|
||||
/// trashed items count, and the bytes are encrypted ones.
|
||||
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let is_folder = self
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| entry.node.is_folder());
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is not a folder",
|
||||
rel.display()
|
||||
)));
|
||||
}
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::folder_size`] for a folder addressed through any daemon location.
|
||||
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
if is_local_uid(uid) {
|
||||
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
|
||||
}
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.get_folder_size(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "folder size"))
|
||||
}
|
||||
|
||||
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
||||
/// evicts any cached content, and invalidates the parent listing.
|
||||
fn delete(&self, rel: &Path) -> CoreResult<String> {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
|
||||
SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
@@ -367,6 +368,89 @@ impl Core {
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Change the public link `id` on the node at `rel`, keeping its URL.
|
||||
pub(crate) fn update_public_link(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::update_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// The SDK sets all three options on every update, so each `Keep` is
|
||||
/// filled in from the link as it stands.
|
||||
fn update_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
let role = match role {
|
||||
Some(role) => role_from_str(role)?,
|
||||
None => link.role,
|
||||
};
|
||||
let unchanged = role == link.role
|
||||
&& matches!(password, LinkChange::Keep)
|
||||
&& matches!(expires, LinkChange::Keep);
|
||||
if unchanged {
|
||||
return Ok(public_link_info(link));
|
||||
}
|
||||
let password = match password {
|
||||
LinkChange::Keep if link.has_custom_password => {
|
||||
// Sending `None` here would quietly remove the password.
|
||||
Some(link.custom_password.clone().ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the link's password could not be read; set a new one or remove it",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
LinkChange::Keep | LinkChange::Remove => None,
|
||||
LinkChange::Set(password) if password.is_empty() => {
|
||||
return Err(CoreError::invalid("the new password is empty"));
|
||||
}
|
||||
LinkChange::Set(password) => Some(password),
|
||||
};
|
||||
let expires = match expires {
|
||||
LinkChange::Keep => link.expiration_time,
|
||||
LinkChange::Remove => None,
|
||||
LinkChange::Set(at) => Some(at),
|
||||
};
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.update_public_link(uid, role, password.as_deref(), expires),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
|
||||
@@ -75,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||
TransferDirection, TransferItem, send,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
|
||||
@@ -368,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
gettext("File")
|
||||
});
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
// A folder's size is Proton Drive's count, which takes a request; it lands
|
||||
// in `load_details_extras`.
|
||||
if entry.is_dir {
|
||||
d.size_row.set_title(&gettext("Size on Proton Drive"));
|
||||
d.size_row.set_subtitle("—");
|
||||
d.size_row.set_tooltip_text(Some(&gettext(
|
||||
"Counted by Proton Drive, including items in the trash.",
|
||||
)));
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
});
|
||||
d.size_row.set_visible(!entry.is_dir);
|
||||
d.size_row.set_title(&pgettext("column", "Size"));
|
||||
d.size_row.set_subtitle(&human_bytes(entry.size));
|
||||
d.size_row.set_tooltip_text(None);
|
||||
}
|
||||
d.modified_row
|
||||
.set_subtitle(&dates::short_date(entry.modified));
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -426,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
d.pages.set_visible_child_name("details");
|
||||
}
|
||||
|
||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
||||
/// land only if the pane still shows the same entry.
|
||||
/// Fill the Sharing and Versions rows, and a folder's size, which need the
|
||||
/// network. The answers land only if the pane still shows the same entry.
|
||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -453,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
});
|
||||
|
||||
if entry.is_dir {
|
||||
let req = if by_uid {
|
||||
Request::FolderSizeByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::FolderSize { path: rel }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
|
||||
_ => gettext("Couldn't load the size"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.size_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let req = if by_uid {
|
||||
@@ -499,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
|
||||
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
|
||||
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
|
||||
// and folders inside a folder.
|
||||
ngettext_f(
|
||||
"{size} · {n} item",
|
||||
"{size} · {n} items",
|
||||
items,
|
||||
&[("size", &human_bytes(size))],
|
||||
)
|
||||
}
|
||||
|
||||
/// "3 versions · latest 2 Sep 2026".
|
||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
@@ -547,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_size_summary_reads_size_and_item_count() {
|
||||
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
|
||||
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_summary_names_people_and_link() {
|
||||
assert_eq!(sharing_summary(0, false), "Only you");
|
||||
|
||||
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
||||
.map(|days| now + days * 86_400)
|
||||
}
|
||||
|
||||
/// The labels of the Expires dropdown on an existing link. When the link has
|
||||
/// an expiry, the first entry is that expiry, so the dropdown shows what is
|
||||
/// set and choosing it again changes nothing.
|
||||
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
|
||||
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
|
||||
current
|
||||
.into_iter()
|
||||
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
|
||||
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
|
||||
let idx = match (current, idx) {
|
||||
(Some(_), 0) => return LinkChange::Keep,
|
||||
(Some(_), idx) => idx - 1,
|
||||
(None, idx) => idx,
|
||||
};
|
||||
match link_expiry_at(idx, now) {
|
||||
Some(at) => LinkChange::Set(at),
|
||||
None if current.is_some() => LinkChange::Remove,
|
||||
None => LinkChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
/// A row with a title and a dropdown of `labels` as its suffix.
|
||||
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(labels))
|
||||
.selected(selected)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let row = adw::ActionRow::builder().title(title).build();
|
||||
row.add_suffix(&drop);
|
||||
(row, drop)
|
||||
}
|
||||
|
||||
/// The roles a public link can grant, in dropdown order.
|
||||
fn link_roles() -> [String; 2] {
|
||||
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
|
||||
}
|
||||
|
||||
/// A loose shape check for an email address: one `@` with something before
|
||||
/// it and a dotted domain after it. The server does the real validation; this
|
||||
/// only catches typos before the Invite button is pressed.
|
||||
@@ -237,6 +279,31 @@ impl ShareTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_link(
|
||||
&self,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::UpdatePublicLink {
|
||||
path: path.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_link(&self, id: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||
@@ -585,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Rebuild the public-link section: an existing link (copy / remove) or a
|
||||
/// create control.
|
||||
/// Rebuild the public-link section: an existing link (copy / remove, and its
|
||||
/// settings) or a create control.
|
||||
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
@@ -670,30 +737,19 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
state.link_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
|
||||
let settings = link_settings_row(state, link);
|
||||
state.link_group.add(&settings);
|
||||
rows.push(settings.upcast());
|
||||
}
|
||||
None => {
|
||||
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_row = adw::ActionRow::builder()
|
||||
.title(gettext("Link role"))
|
||||
.build();
|
||||
role_row.add_suffix(&role_drop);
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Password (optional)"))
|
||||
.build();
|
||||
let expiry_names: Vec<String> =
|
||||
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
||||
let expiry_drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
|
||||
let create = group_button(&gettext("Create Public Link"));
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
@@ -734,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The settings of an existing link, collapsed under one row. Each control
|
||||
/// applies at once, the way a member's role does. While a change is on its way
|
||||
/// the whole row is insensitive: the daemon fills in what a change keeps from
|
||||
/// the link as it stands, so two changes in flight could undo each other.
|
||||
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(gettext("Link settings"))
|
||||
.subtitle(gettext(
|
||||
"Change who can edit, the password or the expiry. The link stays the same.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let selected_role = u32::from(link.role == "editor");
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
role_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let role = if d.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
"viewer"
|
||||
};
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
Some(role.into()),
|
||||
LinkChange::Keep,
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&role_row);
|
||||
|
||||
let current = link.expires;
|
||||
let (expiry_row, expiry_drop) =
|
||||
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
expiry_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
|
||||
if expires == LinkChange::Keep {
|
||||
return;
|
||||
}
|
||||
let request = state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Keep, expires);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&expiry_row);
|
||||
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(if link.has_password {
|
||||
gettext("New password")
|
||||
} else {
|
||||
gettext("Add a password")
|
||||
})
|
||||
.show_apply_button(true)
|
||||
.build();
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
pw_row.connect_apply(move |row| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let password = row.text().to_string();
|
||||
if password.is_empty() {
|
||||
return;
|
||||
}
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
None,
|
||||
LinkChange::Set(password),
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&pw_row);
|
||||
|
||||
if link.has_password {
|
||||
let remove = gtk4::Button::builder()
|
||||
.label(gettext("Remove"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("Remove the password"))
|
||||
.subtitle(gettext(
|
||||
"Anyone with the link can open it without a password.",
|
||||
))
|
||||
.build();
|
||||
row.add_suffix(&remove);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
remove.connect_clicked(move |_| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let request =
|
||||
state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&row);
|
||||
}
|
||||
expander
|
||||
}
|
||||
|
||||
/// Send a link change, then reload the dialog: on success to show the link as
|
||||
/// it now is, on failure to put the controls back to what the link still is.
|
||||
fn share_dialog_update_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
request: Request,
|
||||
settings: &adw::ExpanderRow,
|
||||
) {
|
||||
settings.set_sensitive(false);
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't change the link"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
share_dialog_reload(&state);
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a public link, then reload the dialog so the copy/remove controls
|
||||
/// replace the create form (and the freshly minted URL is shown).
|
||||
pub(crate) fn share_dialog_create_link(
|
||||
@@ -863,6 +1055,28 @@ mod tests {
|
||||
assert_eq!(link_expiry_at(99, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_expiry_keeps_removes_or_sets_it() {
|
||||
let now = 1_000;
|
||||
// No expiry yet: the choices are just Never and the lifetimes.
|
||||
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
|
||||
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, None, now),
|
||||
LinkChange::Set(now + 7 * 86_400)
|
||||
);
|
||||
|
||||
// An expiry set: it comes first, then Never and the lifetimes.
|
||||
let at = Some(2_000_000_000);
|
||||
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
|
||||
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
|
||||
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, at, now),
|
||||
LinkChange::Set(now + 86_400)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_expiry_reads_expired() {
|
||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||
|
||||
Reference in new issue
Block a user