mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-14 09:27:04 +01:00
`pdfs copy PATH... NEW_PARENT` sends a new control request, Copy, which copies a file with its content or a folder with its subtree on Proton Drive through proton-drive-rs's copy_node. Nothing is downloaded or uploaded. A name already taken in the destination gets the next free " (n)" suffix through get_available_name. A copy cannot wait in the queue, because Drive copies what it holds. So it is refused offline, and while the mount still owes Drive part of the node: a local placeholder, a queued op, unsaved writes, or, for a folder, any queued op at all. Local-first does not change this. DriveApi gains copy_node and get_available_name, and the fake drive copies subtrees, so a FUSE sim test covers the copy, the suffix, the folder subtree and both refusals. The activity feed logs a new Copy kind, translated in every catalog. Needs the proton-drive-rs release that adds copy_node; until then, build with a [patch.crates-io] pointing at proton-sdk-rs.
719 lines
28 KiB
Rust
719 lines
28 KiB
Rust
//! Activity entries as the feed words them.
|
|
//!
|
|
//! The daemon logs each entry in plain English for its own log and the CLI:
|
|
//! a target ("report.pdf", "3 item(s)", "sync conflict in Docs") and a detail
|
|
//! ("to /Documents", "was notes.txt", "2 uploaded, 1 deleted"). This module
|
|
//! reads the shapes the daemon writes and says them again in the user's
|
|
//! language. A shape it does not know, which is mostly an error message,
|
|
//! shows as the daemon wrote it.
|
|
|
|
use crate::*;
|
|
|
|
/// What a feed row says about one entry.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct Described {
|
|
pub(crate) title: String,
|
|
/// Subtitle parts, shown joined by " · ".
|
|
pub(crate) details: Vec<String>,
|
|
}
|
|
|
|
/// The title and subtitle parts for `entry`.
|
|
pub(crate) fn describe(entry: &ActivityEntry) -> Described {
|
|
let title = entry_title(entry);
|
|
let mut details = Vec::new();
|
|
if entry.kind == ActivityKind::Conflict
|
|
&& let Some(stamp) = conflict_stamp(&entry.target)
|
|
{
|
|
let when = dates::relative(stamp);
|
|
if !when.is_empty() {
|
|
// Translators: {time} is when a conflicting copy was made, such as
|
|
// "Sep 21" or "5 min ago".
|
|
details.push(gettext_f("Made {time}", &[("time", &when)]));
|
|
}
|
|
}
|
|
if let Some(detail) = detail_label(entry) {
|
|
details.insert(0, detail);
|
|
}
|
|
Described { title, details }
|
|
}
|
|
|
|
/// The row title: what happened to what, one sentence per case so a
|
|
/// translator can place the name where their language needs it.
|
|
fn entry_title(entry: &ActivityEntry) -> String {
|
|
use ActivityKind::*;
|
|
let target = entry.target.as_str();
|
|
let ok = entry.ok;
|
|
|
|
// Entries whose target is a count or a fixed phrase rather than a name.
|
|
match (entry.kind, target) {
|
|
(Upload, "Google Photos import") => {
|
|
return if ok {
|
|
gettext("Imported from Google Photos")
|
|
} else {
|
|
gettext("Couldn't import from Google Photos")
|
|
};
|
|
}
|
|
(Upload, "bulk upload") => return gettext("Couldn't upload files"),
|
|
(Upload, "add sync folder") => return gettext("Couldn't add a sync folder"),
|
|
(Download, "restore folders") => return gettext("Couldn't restore folders"),
|
|
(Unshare, "shared item") => return gettext("Left a shared item"),
|
|
_ => {}
|
|
}
|
|
if matches!(entry.kind, Restore | DeleteForever | EmptyTrash)
|
|
&& let Some(n) = leading_count(target, &["item(s)", "item", "items"])
|
|
{
|
|
return match entry.kind {
|
|
Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
|
|
DeleteForever => ngettext_f(
|
|
"Deleted {n} item forever",
|
|
"Deleted {n} items forever",
|
|
n,
|
|
&[],
|
|
),
|
|
_ => ngettext_f(
|
|
"Emptied the Trash ({n} item)",
|
|
"Emptied the Trash ({n} items)",
|
|
n,
|
|
&[],
|
|
),
|
|
};
|
|
}
|
|
if entry.kind == Download
|
|
&& let Some(rest) = target.strip_prefix("restored ")
|
|
&& let Some(n) = leading_count(rest, &["folder(s)"])
|
|
{
|
|
return ngettext_f("Restored {n} folder", "Restored {n} folders", n, &[]);
|
|
}
|
|
if entry.kind == Sync
|
|
&& let Some(title) = sync_title(target)
|
|
{
|
|
return title;
|
|
}
|
|
|
|
let Some(name) = target_name(target) else {
|
|
return match entry.kind {
|
|
Share => gettext("Shared an item"),
|
|
PublicLink => gettext("Created a link to an item"),
|
|
Unshare if entry.detail == "link removed" => gettext("Removed a link to an item"),
|
|
Unshare => gettext("Stopped sharing an item"),
|
|
_ => activity_title(entry.kind, ok, target),
|
|
};
|
|
};
|
|
if entry.kind == Unshare && entry.detail == "link removed" {
|
|
// Translators: {name} is a file or folder name.
|
|
return gettext_f("Removed the link to {name}", &[("name", &name)]);
|
|
}
|
|
if entry.kind == Conflict
|
|
&& let Some(base) = conflict_base(&name)
|
|
{
|
|
// Translators: {name} is the file the conflicting copy was made from.
|
|
return gettext_f("Conflicting copy of {name}", &[("name", &base)]);
|
|
}
|
|
activity_title(entry.kind, ok, &name)
|
|
}
|
|
|
|
/// The title of a sync pass or mode switch, whose target is a phrase.
|
|
fn sync_title(target: &str) -> Option<String> {
|
|
if let Some(name) = target.strip_prefix("sync conflict in ") {
|
|
// Translators: {name} is a synced folder's name.
|
|
return Some(gettext_f(
|
|
"Conflicting changes in {name}",
|
|
&[("name", name)],
|
|
));
|
|
}
|
|
if let Some(name) = target.strip_prefix("sync incomplete for ") {
|
|
// Translators: {name} is a synced folder's name.
|
|
return Some(gettext_f(
|
|
"Some items in {name} didn't sync",
|
|
&[("name", name)],
|
|
));
|
|
}
|
|
if let Some(name) = target.strip_prefix("sync failed for ") {
|
|
// Translators: {name} is a synced folder's name.
|
|
return Some(gettext_f("Couldn't sync {name}", &[("name", name)]));
|
|
}
|
|
if let Some(name) = target.strip_prefix("couldn't switch ") {
|
|
// Translators: {name} is a synced folder's name.
|
|
return Some(gettext_f(
|
|
"Couldn't change how {name} syncs",
|
|
&[("name", name)],
|
|
));
|
|
}
|
|
if let Some(path) = target.strip_suffix(" is now on-demand") {
|
|
let name = file_name(path);
|
|
// Translators: {name} is a folder's name.
|
|
return Some(gettext_f("{name} is now online only", &[("name", name)]));
|
|
}
|
|
if let Some(path) = target.strip_suffix(" is mirroring again; downloading") {
|
|
let name = file_name(path);
|
|
// Translators: {name} is a folder's name.
|
|
return Some(gettext_f("{name} is synced again", &[("name", name)]));
|
|
}
|
|
if target.starts_with("already ") {
|
|
return Some(gettext("Sync mode was already set"));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The row title for an activity on a named item.
|
|
pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String {
|
|
let args = [("name", name)];
|
|
match (kind, ok) {
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Upload, true) => gettext_f("Uploaded {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Upload, false) => gettext_f("Couldn't upload {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Download, true) => gettext_f("Downloaded {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Download, false) => gettext_f("Couldn't download {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Sync, true) => gettext_f("Synced {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Sync, false) => gettext_f("Couldn't sync {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Rename, true) => gettext_f("Renamed {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Rename, false) => gettext_f("Couldn't rename {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
|
|
// Translators: {name} is a folder name.
|
|
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
|
// Translators: {name} is a folder name.
|
|
(ActivityKind::CreateFolder, false) => gettext_f("Couldn't create folder {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Trash, true) => gettext_f("Moved {name} to the Trash", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Trash, false) => gettext_f("Couldn't move {name} to the Trash", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Restore, true) => gettext_f("Restored {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Restore, false) => gettext_f("Couldn't restore {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::DeleteForever, true) => gettext_f("Deleted {name} forever", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::DeleteForever, false) => gettext_f("Couldn't delete {name}", &args),
|
|
(ActivityKind::EmptyTrash, true) => gettext("Emptied the Trash"),
|
|
(ActivityKind::EmptyTrash, false) => gettext("Couldn't empty the Trash"),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Share, true) => gettext_f("Shared {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Share, false) => gettext_f("Couldn't share {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::PublicLink, true) => gettext_f("Created a link to {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::PublicLink, false) => gettext_f("Couldn't create a link to {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Unshare, true) => gettext_f("Stopped sharing {name}", &args),
|
|
// Translators: {name} is a file or folder name.
|
|
(ActivityKind::Unshare, false) => gettext_f("Couldn't stop sharing {name}", &args),
|
|
// Translators: {name} is the name of the conflicting copy.
|
|
(ActivityKind::Conflict, _) => gettext_f("Conflicting copy {name}", &args),
|
|
}
|
|
}
|
|
|
|
/// The subtitle's first part: the daemon's detail in the user's language, or
|
|
/// `None` when it only repeats the title.
|
|
fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
|
let detail = entry.detail.trim();
|
|
let name = |s: &str| file_name(s).to_string();
|
|
match detail {
|
|
""
|
|
| "uploaded"
|
|
| "created"
|
|
| "trashed"
|
|
| "trashed from the mount"
|
|
| "stopped sharing"
|
|
| "left"
|
|
| "link created"
|
|
| "link removed"
|
|
| "access removed"
|
|
| "removed" => {
|
|
return None;
|
|
}
|
|
"removed on Drive" => return Some(gettext("Removed on Proton Drive")),
|
|
"removed locally" => return Some(gettext("Removed on this computer")),
|
|
"replaced by a rename from the mount" => {
|
|
return Some(gettext("Replaced by a renamed file"));
|
|
}
|
|
"destination folder no longer exists; the file was left in place" => {
|
|
return Some(gettext(
|
|
"The destination folder no longer exists, so the file stayed where it was",
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
if entry.kind == ActivityKind::Upload
|
|
&& entry.target == "Google Photos import"
|
|
&& entry.ok
|
|
&& let Some(summary) = import_summary(detail)
|
|
{
|
|
return Some(summary);
|
|
}
|
|
if entry.kind == ActivityKind::Sync
|
|
&& let Some(summary) = sync_summary(detail)
|
|
{
|
|
return Some(summary);
|
|
}
|
|
if let Some(dest) = detail.strip_prefix("to ") {
|
|
// Translators: {name} is the folder an item was moved into.
|
|
return Some(gettext_f("To {name}", &[("name", &folder_name(dest))]));
|
|
}
|
|
if let Some(old) = detail.strip_prefix("was ") {
|
|
// Translators: {name} is an item's previous name.
|
|
return Some(gettext_f("Previously {name}", &[("name", &name(old))]));
|
|
}
|
|
if detail.starts_with("restored version ") {
|
|
return Some(gettext("Restored an earlier version"));
|
|
}
|
|
if detail.starts_with("deleted version ") {
|
|
return Some(gettext("Deleted an earlier version"));
|
|
}
|
|
if let Some(rest) = detail.strip_prefix("saved version ")
|
|
&& let Some((_, path)) = rest.split_once(" to ")
|
|
{
|
|
// Translators: {path} is where an earlier version was saved.
|
|
return Some(gettext_f(
|
|
"Saved an earlier version to {path}",
|
|
&[("path", &tilde_path(path))],
|
|
));
|
|
}
|
|
if let Some(copy) = detail.strip_prefix("kept both; the copy is now ") {
|
|
// Translators: {name} is the new name of the conflicting copy.
|
|
return Some(gettext_f(
|
|
"Kept both; the copy is now {name}",
|
|
&[("name", copy)],
|
|
));
|
|
}
|
|
if let Some(base) = detail
|
|
.strip_prefix("kept ")
|
|
.and_then(|rest| rest.strip_suffix("; the copy is in Trash"))
|
|
{
|
|
// Translators: {name} is the file that was kept.
|
|
return Some(gettext_f(
|
|
"Kept {name}; the copy is in the Trash",
|
|
&[("name", base)],
|
|
));
|
|
}
|
|
if detail.starts_with("kept the conflict copy ") {
|
|
return Some(gettext("Kept the conflicting copy"));
|
|
}
|
|
if let Some(base) = detail.strip_prefix("kept ") {
|
|
// Translators: {name} is the file that was kept.
|
|
return Some(gettext_f("Kept {name}", &[("name", base)]));
|
|
}
|
|
if let Some(base) = detail.strip_prefix("auto-removed duplicate of ") {
|
|
// Translators: {name} is the file the removed copy was identical to.
|
|
return Some(gettext_f(
|
|
"Same as {name}, so it was removed",
|
|
&[("name", base)],
|
|
));
|
|
}
|
|
if let Some(alt) = detail.strip_prefix("destination already had that name; moved as ") {
|
|
// Translators: {name} is the name the item was given instead.
|
|
return Some(gettext_f(
|
|
"The name was taken, so it was moved as {name}",
|
|
&[("name", alt)],
|
|
));
|
|
}
|
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; renamed to ") {
|
|
// Translators: {name} is the name the item was given instead.
|
|
return Some(gettext_f(
|
|
"The name was taken on Proton Drive, so it was renamed to {name}",
|
|
&[("name", alt)],
|
|
));
|
|
}
|
|
if let Some(alt) = detail.strip_prefix("name was taken remotely; created as ") {
|
|
// Translators: {name} is the name the item was given instead.
|
|
return Some(gettext_f(
|
|
"The name was taken on Proton Drive, so it was created as {name}",
|
|
&[("name", alt)],
|
|
));
|
|
}
|
|
if let Some(alt) =
|
|
detail.strip_prefix("its folder was trashed remotely; created in the root as ")
|
|
{
|
|
// Translators: {name} is the name the item was given.
|
|
return Some(gettext_f(
|
|
"Its folder was moved to the Trash on Proton Drive, so it was created at the top as {name}",
|
|
&[("name", alt)],
|
|
));
|
|
}
|
|
if let Some((reason, copy)) = detail.split_once("; local changes uploaded as ") {
|
|
let args = [("name", copy)];
|
|
return Some(match edit_conflict(reason) {
|
|
// Translators: {name} is the copy the changes made on this
|
|
// computer were uploaded as.
|
|
EditConflict::Gone => gettext_f(
|
|
"It no longer exists on Proton Drive, so your changes were uploaded as {name}",
|
|
&args,
|
|
),
|
|
// Translators: {name} is the copy the changes made on this
|
|
// computer were uploaded as.
|
|
EditConflict::Trashed => gettext_f(
|
|
"It was moved to the Trash on Proton Drive, so your changes were uploaded as {name}",
|
|
&args,
|
|
),
|
|
// Translators: {name} is the copy the changes made on this
|
|
// computer were uploaded as.
|
|
EditConflict::Changed => gettext_f(
|
|
"It was changed on Proton Drive, so your changes were uploaded as {name}",
|
|
&args,
|
|
),
|
|
// Translators: {name} is the copy the changes made on this
|
|
// computer were uploaded as.
|
|
EditConflict::Other => gettext_f("Your changes were uploaded as {name}", &args),
|
|
});
|
|
}
|
|
if let Some((reason, path)) = detail.split_once("; local changes kept at ") {
|
|
let path = tilde_path(path);
|
|
let args = [("path", path.as_str())];
|
|
return Some(match edit_conflict(reason) {
|
|
// Translators: {path} is the file on this computer that holds the
|
|
// changes which could not be uploaded.
|
|
EditConflict::Gone => gettext_f(
|
|
"It no longer exists on Proton Drive, so your changes were kept in {path}",
|
|
&args,
|
|
),
|
|
// Translators: {path} is the file on this computer that holds the
|
|
// changes which could not be uploaded.
|
|
EditConflict::Trashed => gettext_f(
|
|
"It was moved to the Trash on Proton Drive, so your changes were kept in {path}",
|
|
&args,
|
|
),
|
|
// Translators: {path} is the file on this computer that holds the
|
|
// changes which could not be uploaded.
|
|
EditConflict::Changed => gettext_f(
|
|
"It was changed on Proton Drive, so your changes were kept in {path}",
|
|
&args,
|
|
),
|
|
// Translators: {path} is the file on this computer that holds the
|
|
// changes which could not be uploaded.
|
|
EditConflict::Other => gettext_f("Your changes were kept in {path}", &args),
|
|
});
|
|
}
|
|
if let Some(base) = detail.strip_prefix("differs from ") {
|
|
// Translators: {name} is the file the conflicting copy was made from.
|
|
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
|
}
|
|
if let Some(base) = detail
|
|
.strip_prefix("no original ")
|
|
.and_then(|rest| rest.strip_suffix(" to reconcile against"))
|
|
{
|
|
// Translators: {name} is the file the conflicting copy was made from.
|
|
return Some(gettext_f("{name} no longer exists", &[("name", base)]));
|
|
}
|
|
if let Some(base) = detail
|
|
.strip_prefix("identical to ")
|
|
.and_then(|rest| rest.split_once(';').map(|(base, _)| base))
|
|
{
|
|
// Translators: {name} is the file the conflicting copy was made from.
|
|
return Some(gettext_f("Same as {name}", &[("name", base)]));
|
|
}
|
|
if let Some((n, role)) = detail
|
|
.split_once(" recipient(s) as ")
|
|
.and_then(|(n, role)| Some((n.parse::<u64>().ok()?, role)))
|
|
{
|
|
return Some(match role {
|
|
"viewer" => ngettext_f("{n} person can view", "{n} people can view", n, &[]),
|
|
"editor" => ngettext_f("{n} person can edit", "{n} people can edit", n, &[]),
|
|
_ => ngettext_f("{n} person invited", "{n} people invited", n, &[]),
|
|
});
|
|
}
|
|
if let Some(n) = leading_count(detail, &["file(s) kept as conflict copies"]) {
|
|
return Some(ngettext_f(
|
|
"{n} file kept as a conflicting copy",
|
|
"{n} files kept as conflicting copies",
|
|
n,
|
|
&[],
|
|
));
|
|
}
|
|
if let Some(n) = leading_count(detail, &["item(s) failed; will retry"]) {
|
|
return Some(ngettext_f(
|
|
"{n} item failed and will be tried again",
|
|
"{n} items failed and will be tried again",
|
|
n,
|
|
&[],
|
|
));
|
|
}
|
|
Some(capitalize(detail))
|
|
}
|
|
|
|
/// Why a queued edit could not land on its file, from the reason the daemon
|
|
/// gives before "; local changes uploaded as" or "; local changes kept at".
|
|
enum EditConflict {
|
|
Gone,
|
|
Trashed,
|
|
Changed,
|
|
Other,
|
|
}
|
|
|
|
fn edit_conflict(reason: &str) -> EditConflict {
|
|
match reason {
|
|
"the file no longer exists remotely" => EditConflict::Gone,
|
|
"the file was trashed remotely" => EditConflict::Trashed,
|
|
r if r.starts_with("the remote revision changed under the queued write") => {
|
|
EditConflict::Changed
|
|
}
|
|
_ => EditConflict::Other,
|
|
}
|
|
}
|
|
|
|
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
|
/// list of counts. `None` when a part is not one the daemon writes.
|
|
fn sync_summary(detail: &str) -> Option<String> {
|
|
let mut parts = Vec::new();
|
|
for part in detail.split(", ") {
|
|
let (n, label) = part.split_once(' ')?;
|
|
let n = n.parse::<u64>().ok()?;
|
|
parts.push(match label {
|
|
// Translators: part of a list summing up a sync pass.
|
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
|
// Translators: part of a list summing up a sync pass.
|
|
"downloaded" => ngettext_f("{n} downloaded", "{n} downloaded", n, &[]),
|
|
// Translators: part of a list summing up a sync pass.
|
|
"folder(s) created" => ngettext_f("{n} folder created", "{n} folders created", n, &[]),
|
|
// Translators: part of a list summing up a sync pass.
|
|
"deleted" => ngettext_f("{n} deleted", "{n} deleted", n, &[]),
|
|
// Translators: part of a list summing up a sync pass.
|
|
"conflicted" => ngettext_f("{n} in conflict", "{n} in conflict", n, &[]),
|
|
// Translators: part of a list summing up a sync pass; the files are
|
|
// still open in another app.
|
|
"deferred (open for write)" => ngettext_f("{n} still open", "{n} still open", n, &[]),
|
|
// Translators: part of a list summing up a sync pass.
|
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
|
_ => return None,
|
|
});
|
|
}
|
|
Some(parts.join(", "))
|
|
}
|
|
|
|
/// A Google Photos import summary such as "40 uploaded · 2 already there".
|
|
fn import_summary(detail: &str) -> Option<String> {
|
|
let mut parts = Vec::new();
|
|
for part in detail.split(" · ") {
|
|
if part == "cancelled" {
|
|
parts.push(gettext("Cancelled"));
|
|
continue;
|
|
}
|
|
let (n, label) = part.split_once(' ')?;
|
|
let n = n.parse::<u64>().ok()?;
|
|
parts.push(match label {
|
|
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
|
// Translators: photos an import skipped because they were already
|
|
// in Proton Drive.
|
|
"already there" => ngettext_f("{n} already there", "{n} already there", n, &[]),
|
|
"albums" => ngettext_f("{n} album", "{n} albums", n, &[]),
|
|
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
|
_ => return None,
|
|
});
|
|
}
|
|
Some(parts.join(" · "))
|
|
}
|
|
|
|
/// The number in front of `text` when the rest is one of `units`, as in
|
|
/// "3 item(s)".
|
|
fn leading_count(text: &str, units: &[&str]) -> Option<u64> {
|
|
let (n, unit) = text.split_once(' ')?;
|
|
units.contains(&unit).then_some(())?;
|
|
n.parse().ok()
|
|
}
|
|
|
|
/// The name to show for a target: the last part of a path, or `None` when
|
|
/// the daemon logged a node id because it had no path.
|
|
fn target_name(target: &str) -> Option<String> {
|
|
let looks_like_uid =
|
|
target.len() >= 40 && target.matches('~').count() == 1 && !target.contains(['/', ' ', '.']);
|
|
if target.is_empty() || looks_like_uid {
|
|
return None;
|
|
}
|
|
Some(file_name(target).to_string())
|
|
}
|
|
|
|
/// The folder a destination path names, with the root read as "My files".
|
|
fn folder_name(dest: &str) -> String {
|
|
let name = file_name(dest.trim_end_matches('/'));
|
|
if name.is_empty() {
|
|
gettext("My files")
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
/// The file a `name (sync-conflict <stamp>).ext` copy was made from.
|
|
pub(crate) fn conflict_base(name: &str) -> Option<String> {
|
|
let (start, close) = conflict_marker(name)?;
|
|
Some(format!("{}{}", &name[..start], &name[close + 1..]))
|
|
}
|
|
|
|
/// When a conflicting copy was made, from the stamp in its name.
|
|
fn conflict_stamp(name: &str) -> Option<i64> {
|
|
let (start, close) = conflict_marker(name)?;
|
|
let inner = &name[start + MARKER.len()..close];
|
|
inner.split('-').next()?.parse().ok()
|
|
}
|
|
|
|
const MARKER: &str = " (sync-conflict ";
|
|
|
|
/// Where the ` (sync-conflict <stamp>[-<n>])` marker starts and where its
|
|
/// closing parenthesis is. The same shape the daemon reads back.
|
|
fn conflict_marker(name: &str) -> Option<(usize, usize)> {
|
|
let start = name.find(MARKER)?;
|
|
let after = start + MARKER.len();
|
|
let close = after + name[after..].find(')')?;
|
|
let inner = &name[after..close];
|
|
let mut parts = inner.splitn(2, '-');
|
|
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
|
if !digits(parts.next()?) || parts.next().is_some_and(|s| !digits(s)) {
|
|
return None;
|
|
}
|
|
Some((start, close))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn entry(kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
|
ActivityEntry {
|
|
time: 0,
|
|
kind,
|
|
target: target.into(),
|
|
detail: detail.into(),
|
|
ok,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn details_that_repeat_the_title_are_dropped() {
|
|
let d = describe(&entry(ActivityKind::Upload, "a.txt", "uploaded", true));
|
|
assert_eq!(d.title, "Uploaded a.txt");
|
|
assert!(d.details.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn paths_and_counts_read_as_names_and_numbers() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Move,
|
|
"/mnt/a.txt",
|
|
"to /mnt/Docs",
|
|
true,
|
|
));
|
|
assert_eq!(d.title, "Moved a.txt");
|
|
assert_eq!(d.details, vec!["To Docs"]);
|
|
let d = describe(&entry(ActivityKind::Restore, "3 item(s)", "", true));
|
|
assert_eq!(d.title, "Restored 3 items");
|
|
let d = describe(&entry(ActivityKind::EmptyTrash, "1 item", "", true));
|
|
assert_eq!(d.title, "Emptied the Trash (1 item)");
|
|
}
|
|
|
|
#[test]
|
|
fn failures_say_what_could_not_happen() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Upload,
|
|
"a.txt",
|
|
"quota exceeded",
|
|
false,
|
|
));
|
|
assert_eq!(d.title, "Couldn't upload a.txt");
|
|
assert_eq!(d.details, vec!["Quota exceeded"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_passes_are_summed_up() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Sync,
|
|
"Docs",
|
|
"2 uploaded, 1 folder(s) created",
|
|
true,
|
|
));
|
|
assert_eq!(d.title, "Synced Docs");
|
|
assert_eq!(d.details, vec!["2 uploaded, 1 folder created"]);
|
|
let d = describe(&entry(
|
|
ActivityKind::Sync,
|
|
"sync incomplete for Docs",
|
|
"2 item(s) failed; will retry",
|
|
false,
|
|
));
|
|
assert_eq!(d.title, "Some items in Docs didn't sync");
|
|
assert_eq!(d.details, vec!["2 items failed and will be tried again"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_conflicting_copy_names_its_original() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Conflict,
|
|
"notes (sync-conflict 1700000000).txt",
|
|
"differs from notes.txt",
|
|
false,
|
|
));
|
|
assert_eq!(d.title, "Conflicting copy of notes.txt");
|
|
assert_eq!(d.details[0], "Differs from notes.txt");
|
|
assert_eq!(d.details.len(), 2);
|
|
assert_eq!(conflict_base("a (sync-conflict 12-2)"), Some("a".into()));
|
|
assert_eq!(conflict_base("a (sync-conflict x).txt"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn node_ids_are_not_shown_as_names() {
|
|
let uid = format!("{}~{}", "A".repeat(30), "B".repeat(30));
|
|
let d = describe(&entry(
|
|
ActivityKind::Share,
|
|
&uid,
|
|
"1 recipient(s) as viewer",
|
|
true,
|
|
));
|
|
assert_eq!(d.title, "Shared an item");
|
|
assert_eq!(d.details, vec!["1 person can view"]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_edit_kept_as_a_copy_says_why_in_words() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Upload,
|
|
"notes.txt",
|
|
"the remote revision changed under the queued write \
|
|
(based on revision r1, remote now at r2); \
|
|
local changes uploaded as notes (sync-conflict 1700000000).txt",
|
|
false,
|
|
));
|
|
assert_eq!(
|
|
d.details,
|
|
vec![
|
|
"It was changed on Proton Drive, so your changes were uploaded as \
|
|
notes (sync-conflict 1700000000).txt"
|
|
]
|
|
);
|
|
let d = describe(&entry(
|
|
ActivityKind::Upload,
|
|
"notes.txt",
|
|
"the file was trashed remotely; local changes kept at /var/staging/7",
|
|
false,
|
|
));
|
|
assert_eq!(
|
|
d.details,
|
|
vec![
|
|
"It was moved to the Trash on Proton Drive, so your changes were kept in \
|
|
/var/staging/7"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_details_show_as_written() {
|
|
let d = describe(&entry(
|
|
ActivityKind::Download,
|
|
"a.txt",
|
|
"network timeout",
|
|
false,
|
|
));
|
|
assert_eq!(d.details, vec!["Network timeout"]);
|
|
}
|
|
}
|