diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index ca889b9..894c778 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -40,6 +40,7 @@ use widgets::crumb::*; use widgets::details::*; use widgets::file_list::*; use widgets::menu::*; +use widgets::photo_drag::*; use widgets::scrubber::*; use widgets::share_dialog::*; use widgets::thumbnails::*; @@ -525,6 +526,7 @@ fn build_window(app: &adw::Application) { jump: Cell::new(None), burst: Cell::new(false), import_banner: gallery_widgets.import_banner.clone(), + upload_bar: gallery_widgets.upload_bar.clone(), upload: gallery_widgets.upload.clone(), empty_actions: gallery_widgets.empty_actions.clone(), title: gallery_widgets.title.clone(), @@ -2036,6 +2038,13 @@ mod tests { assert!(next_group_member(&[], "jpeg").is_none()); } + /// The upload bar counts the upload under way, not the ones finished. + #[test] + fn the_upload_bar_counts_the_upload_under_way() { + assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…"); + assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…"); + } + /// The pill names what kind of shot it switches through, and lights up /// on the file that is not the shot's main one. #[test] diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index e41e1ff..659b6a5 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -37,6 +37,9 @@ pub(crate) struct GalleryState { pub(crate) burst: Cell, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, + /// The bar that counts uploads from this computer through, under the + /// header while any run. + pub(crate) upload_bar: UploadBar, pub(crate) upload: adw::SplitButton, /// Upload / Import offered on the *empty timeline* status page, so a fresh /// account is a place to start rather than a dead end. Hidden on every other @@ -478,6 +481,9 @@ pub(crate) struct GalleryWidgets { pub(crate) scrubber: Scrubber, /// Says a Google Photos import is running, with a way to its page. pub(crate) import_banner: adw::Banner, + /// The bar that counts uploads from this computer through, under the + /// header while any run. + pub(crate) upload_bar: UploadBar, pub(crate) list: gtk4::ListView, pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) retry: gtk4::Button, @@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .action_name("win.show-import") .build(); + let upload_bar = UploadBar::new(); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); @@ -913,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); body.append(&import_banner); + body.append(&upload_bar.revealer); body.append(&inner); // The title slot holds the view switch on the page itself, the album's or // place's name inside one, and the count in selection mode (see @@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { pager, scrubber, import_banner, + upload_bar, list, scroll, retry, @@ -1384,6 +1394,13 @@ pub(crate) fn wire_gallery( sync_float_heading(&ui_scroll); ui_scroll.gallery.scrubber.flash(); }); + // Zooming out or making the window taller can leave every loaded photo on + // one screen. Nothing scrolls then, so the scrolling above never asks for + // the next page: ask whenever the content shrinks under the viewport. + let ui_fill = ui.clone(); + scroll + .vadjustment() + .connect_changed(move |_| fill_viewport(&ui_fill)); wire_scrubber(ui); wire_drag_select(ui); @@ -1513,9 +1530,11 @@ pub(crate) fn wire_gallery( gtk4::gdk::DragAction::COPY, ); let ui_drop = ui.clone(); - drop.connect_accept(move |_, _| { + drop.connect_accept(move |_, drop| { let gallery = &ui_drop.gallery; - !gallery.in_collection() + // Photos dragged out of the timeline are already in it. + drop.drag().is_none() + && !gallery.in_collection() && gallery.view_switch.active() == VIEW_TIMELINE && gallery.library.get() && *ui_drop.mounted.borrow() @@ -1913,6 +1932,86 @@ pub(crate) fn copy_without_overwrite( unreachable!("some numbered name is free") } +/// How far the uploads from this computer have come: a count and a bar, so a +/// drop of a few hundred photos shows it is moving. Batches started while one +/// runs join its count. +#[derive(Clone)] +pub(crate) struct UploadBar { + pub(crate) revealer: gtk4::Revealer, + label: gtk4::Label, + progress: gtk4::ProgressBar, + /// Uploads finished, and uploads asked for, since the bar last emptied. + count: Rc>, +} + +impl UploadBar { + fn new() -> Self { + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + let progress = gtk4::ProgressBar::builder().hexpand(true).build(); + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + content.set_margin_top(10); + content.set_margin_bottom(4); + content.set_margin_start(16); + content.set_margin_end(16); + content.append(&label); + content.append(&progress); + let revealer = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideDown) + .child(&content) + .build(); + Self { + revealer, + label, + progress, + count: Rc::default(), + } + } + + /// Count `n` more uploads in. + pub(crate) fn add(&self, n: usize) { + let (done, total) = self.count.get(); + self.count.set((done, total + n)); + self.paint(); + } + + /// Count one upload as finished, whether or not it went through. + pub(crate) fn finish(&self) { + let (done, total) = self.count.get(); + let done = (done + 1).min(total); + if done == total { + self.count.set((0, 0)); + self.revealer.set_reveal_child(false); + return; + } + self.count.set((done, total)); + self.paint(); + } + + fn paint(&self) { + let (done, total) = self.count.get(); + if total == 0 { + return; + } + self.label.set_label(&upload_progress_text(done, total)); + self.progress.set_fraction(done as f64 / total as f64); + self.revealer.set_reveal_child(true); + } +} + +/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`. +pub(crate) fn upload_progress_text(done: usize, total: usize) -> String { + // Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are. + ngettext_f( + "Uploading {position} of {n} item…", + "Uploading {position} of {n} items…", + total as u64, + &[("position", &thousands(done + 1))], + ) +} + /// Upload photos and videos from this computer into the timeline, one after /// the other, then reload it once. pub(crate) fn upload_media(ui: &Rc, paths: Vec) { @@ -1927,15 +2026,7 @@ pub(crate) fn upload_media(ui: &Rc, paths: Vec) { let total = uploads.len(); ui.busy_begin(); if total > 1 { - toast( - ui, - &ngettext_f( - "Uploading {n} item…", - "Uploading {n} items…", - total as u64, - &[], - ), - ); + ui.gallery.upload_bar.add(total); } let ui = ui.clone(); glib::spawn_future_local(async move { @@ -1973,6 +2064,9 @@ pub(crate) fn upload_media(ui: &Rc, paths: Vec) { )) } } + if total > 1 { + ui.gallery.upload_bar.finish(); + } } ui.busy_end(); if uploaded > 0 { @@ -2381,6 +2475,23 @@ impl TileWidget { }); button.add_controller(context); + // A picked photo drags out, with the rest of the selection, into a + // file manager or any app that takes files. A drag from anywhere else + // selects instead (see `wire_drag_select`). + let drag_out = gtk4::DragSource::new(); + drag_out.set_actions(gtk4::gdk::DragAction::COPY); + let ui_drag = ui.clone(); + let drag_uid = uid.clone(); + drag_out.connect_prepare(move |source, x, y| { + let photos = dragged_photos(&ui_drag, &drag_uid()?)?; + if let Some(tile) = source.widget() { + let icon = gtk4::WidgetPaintable::new(Some(&tile)); + source.set_icon(Some(&icon), x as i32, y as i32); + } + Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast()) + }); + button.add_controller(drag_out); + // A tile opens in the in-app lightbox, which plays a video in place. // In selection mode a tile picks instead of opening: the lightbox is // one Escape away. @@ -2478,6 +2589,23 @@ impl TileWidget { } } +/// The photos a drag that starts on `uid` carries out: the whole selection, +/// in timeline order, as uid and name. `None` unless `uid` is picked. +fn dragged_photos(ui: &Rc, uid: &str) -> Option)>> { + let selected = ui.gallery.selected.borrow(); + if !ui.gallery.selecting.get() || !selected.contains(uid) { + return None; + } + let model = &ui.gallery.model; + let photos: Vec<(String, Option)> = (0..model.n_items()) + .filter_map(|index| model.item(index).and_downcast::()) + .map(|boxed| boxed.borrow::().clone()) + .filter(|photo| selected.contains(&photo.uid)) + .map(|photo| (photo.uid, photo.name)) + .collect(); + (!photos.is_empty()).then_some(photos) +} + /// Add or remove one CSS class, for widgets that are rebound rather than /// rebuilt. fn set_css_class(widget: &impl IsA, class: &str, on: bool) { @@ -2556,6 +2684,9 @@ struct DragSelect { pointer: (f64, f64), /// The edge autoscroll, while the pointer is near the top or bottom. scroll: Option, + /// The drag started on a picked photo, so it carries the selection out + /// of the app rather than selecting. + out: bool, } /// Dragging across the timeline selects every photo from where the drag @@ -2572,21 +2703,33 @@ fn wire_drag_select(ui: &Rc) { let state = Rc::new(RefCell::new(DragSelect::default())); let state_begin = state.clone(); - drag.connect_drag_begin(move |_, x, y| { + let ui_begin = ui.clone(); + drag.connect_drag_begin(move |gesture, x, y| { let mut drag = state_begin.borrow_mut(); if let Some(source) = drag.scroll.take() { source.remove(); } + let out = ui_begin.gallery.selecting.get() + && tile_at(&ui_begin, x, y) + .is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid)); *drag = DragSelect { start: (x, y), pointer: (x, y), + out, ..DragSelect::default() }; + // Left to the tile's own drag source. + if out { + gesture.set_state(gtk4::EventSequenceState::Denied); + } }); let ui_update = ui.clone(); let state_update = state.clone(); drag.connect_drag_update(move |gesture, dx, dy| { + if state_update.borrow().out { + return; + } let (sx, sy) = state_update.borrow().start; state_update.borrow_mut().pointer = (sx + dx, sy + dy); if !state_update.borrow().active { @@ -3954,17 +4097,32 @@ fn continue_jump(ui: &Rc) { /// Load the next page when the timeline does not fill the window yet: with no /// scrollbar there is no scrolling to trigger it. fn fill_viewport(ui: &Rc) { + if !ui.gallery.has_more.get() || ui.gallery.loading.get() { + return; + } let ui = ui.clone(); glib::idle_add_local_once(move || { let Some(adj) = ui.gallery.list.vadjustment() else { return; }; - if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 { + let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline"); + if timeline + && ui.gallery.has_more.get() + && adj.page_size() > 0.0 + && viewport_unfilled(adj.upper(), adj.page_size()) + { load_gallery(&ui, true); } }); } +/// Whether content `upper` px tall leaves a viewport `page_size` px tall short +/// of a screen and a half to scroll through, the margin the scrolling pager +/// keeps. Below it the pager would never fire. +pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool { + upper <= page_size * 2.5 + 1.0 +} + /// Select every photo loaded so far (Ctrl+A). pub(crate) fn select_all_photos(ui: &Rc) { set_selection_mode(ui, true); @@ -4358,10 +4516,10 @@ pub(crate) fn play_external(path: &str) { #[cfg(test)] mod tests { use super::{ - Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, + Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for, memory_years, month_index, month_range, plan_grid, plan_justified, section_heading, - tile_ratio, + tile_ratio, viewport_unfilled, }; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth}; @@ -4484,6 +4642,18 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A first page zoomed all the way out fits on one screen, with nothing + /// to scroll: the next page has to load without a scroll asking for it. + #[test] + fn a_page_zoomed_out_onto_one_screen_asks_for_more() { + let (columns, size) = plan_grid(1580, ROW_MIN); + let rows = PHOTOS_PAGE.div_ceil(columns) as i32; + let height = f64::from(rows * (size + TILE_GAP) + 40); + assert!(viewport_unfilled(height, 880.0)); + assert!(viewport_unfilled(880.0, 880.0)); + assert!(!viewport_unfilled(880.0 * 3.0, 880.0)); + } + #[test] fn zooming_in_puts_fewer_tiles_in_a_row() { assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0); diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index e0f5620..464b276 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod crumb; pub(crate) mod details; pub(crate) mod file_list; pub(crate) mod menu; +pub(crate) mod photo_drag; pub(crate) mod scrubber; pub(crate) mod share_dialog; pub(crate) mod thumbnails; diff --git a/crates/pdfs-gui/src/app/widgets/photo_drag.rs b/crates/pdfs-gui/src/app/widgets/photo_drag.rs new file mode 100644 index 0000000..955a77f --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/photo_drag.rs @@ -0,0 +1,237 @@ +//! Dragging photos out of the timeline into a file manager or another app. +//! +//! A photo is not a file anywhere the receiver can read until it has been +//! downloaded, and a drag has to start the moment the pointer moves. So the +//! drag offers a list of files without having them: [`PhotoDrag`] downloads +//! the photos only when the receiver asks for the list, which it does once the +//! photos are dropped on it, and then hands over copies under their own names. + +use std::future::Future; +use std::pin::Pin; +use std::time::{Duration, SystemTime}; + +use gtk4::gdk; +use gtk4::gdk::subclass::prelude::*; + +use crate::*; + +/// The one format a drag of photos offers. GTK turns it into a file list for +/// a receiver in this app, and every file manager reads it. +const URI_LIST: &str = "text/uri-list"; + +/// How long the copies handed to a receiver are kept. The receiver copies +/// them on drop, so a day is far more than it needs. +const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60); + +mod imp { + use super::*; + + #[derive(Default)] + pub struct PhotoDrag { + /// The photos dragged, as uid and Drive name. + pub(super) photos: RefCell)>>, + pub(super) socket: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PhotoDrag { + const NAME: &'static str = "PdfsPhotoDrag"; + type Type = super::PhotoDrag; + type ParentType = gdk::ContentProvider; + } + + impl ObjectImpl for PhotoDrag {} + + impl ContentProviderImpl for PhotoDrag { + fn formats(&self) -> gdk::ContentFormats { + gdk::ContentFormats::new(&[URI_LIST]) + } + + fn write_mime_type_future( + &self, + mime_type: &str, + stream: &gio::OutputStream, + io_priority: glib::Priority, + ) -> Pin> + 'static>> { + let photos = self.photos.borrow().clone(); + let socket = self.socket.borrow().clone(); + let stream = stream.clone(); + let wanted = mime_type == URI_LIST; + Box::pin(async move { + if !wanted { + return Err(glib::Error::new( + gio::IOErrorEnum::NotSupported, + "photos are offered as a list of files only", + )); + } + let files = fetch_dragged(socket, photos).await?; + let list: String = files + .iter() + .map(|path| format!("{}\r\n", gio::File::for_path(path).uri())) + .collect(); + stream + .write_all_future(list.into_bytes(), io_priority) + .await + .map_err(|(_, e)| e)?; + Ok(()) + }) + } + } +} + +glib::wrapper! { + /// The content of a drag of photos: their files, fetched when dropped. + pub struct PhotoDrag(ObjectSubclass) + @extends gdk::ContentProvider; +} + +impl PhotoDrag { + pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option)>) -> Self { + let drag: Self = glib::Object::new(); + *drag.imp().photos.borrow_mut() = photos; + *drag.imp().socket.borrow_mut() = socket; + drag + } +} + +/// Download each dragged photo and put a copy under its Drive name where the +/// receiver can read it. A photo that fails is left out; only when every one +/// fails does the drop fail. +async fn fetch_dragged( + socket: PathBuf, + photos: Vec<(String, Option)>, +) -> Result, glib::Error> { + let base = glib::user_cache_dir().join("pdfs-drag"); + let mut files = Vec::new(); + let mut last_error = None; + for (uid, name) in photos { + let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() }); + let source = match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => path, + Ok(Ok(Response::Error { message, .. })) => { + last_error = Some(message); + continue; + } + _ => { + last_error = Some("the Proton Drive service didn't respond".to_string()); + continue; + } + }; + let base = base.clone(); + let copied = gio::spawn_blocking(move || { + prune_dragged(&base, SystemTime::now()); + let name = name.unwrap_or_else(|| "photo".to_string()); + hand_over(&base, Path::new(&source), &uid, &name) + }) + .await; + match copied { + Ok(Ok(path)) => files.push(path), + Ok(Err(e)) => last_error = Some(e.to_string()), + Err(_) => last_error = Some("the copy was interrupted".to_string()), + } + } + if files.is_empty() { + let message = last_error.unwrap_or_else(|| "nothing to drop".to_string()); + tracing::warn!("dragging photos out failed: {message}"); + return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message)); + } + Ok(files) +} + +/// Put the cached photo `source` at `base//`, where a receiver +/// copies it from. +/// +/// A hard link when the cache is on the same file system, so a large video is +/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a +/// symlink as a link, which would leave the user a link into the cache. +pub(crate) fn hand_over( + base: &Path, + source: &Path, + uid: &str, + name: &str, +) -> std::io::Result { + let file_name = Path::new(name) + .file_name() + .ok_or_else(|| std::io::Error::other("the photo has no usable name"))?; + let dir = base.join(uid.replace('/', "_")); + std::fs::create_dir_all(&dir)?; + let dest = dir.join(file_name); + let _ = std::fs::remove_file(&dest); + if std::fs::hard_link(source, &dest).is_err() { + std::fs::copy(source, &dest)?; + } + Ok(dest) +} + +/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`. +pub(crate) fn prune_dragged(base: &Path, now: SystemTime) { + let Ok(entries) = std::fs::read_dir(base) else { + return; + }; + for entry in entries.flatten() { + let old = entry + .metadata() + .and_then(|meta| meta.modified()) + .is_ok_and(|modified| { + now.duration_since(modified) + .is_ok_and(|age| age > KEEP_DRAGGED) + }); + if old { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_dragged_photo_is_handed_over_under_its_own_name() { + let dir = scratch("name"); + let source = dir.join("0f3a9c"); + std::fs::write(&source, b"jpeg bytes").unwrap(); + let base = dir.join("out"); + + let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap(); + assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG")); + assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes"); + assert!(!path.is_symlink()); + + // Dragged again, it replaces the copy rather than failing on it. + std::fs::write(&source, b"new bytes").unwrap(); + let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap(); + assert_eq!(std::fs::read(again).unwrap(), b"new bytes"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_name_cannot_climb_out_of_its_folder() { + let dir = scratch("climb"); + let source = dir.join("blob"); + std::fs::write(&source, b"x").unwrap(); + let base = dir.join("out"); + let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap(); + assert_eq!(path, base.join("u").join("escape.jpg")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn only_old_handovers_are_pruned() { + let dir = scratch("prune"); + std::fs::create_dir_all(dir.join("fresh")).unwrap(); + prune_dragged(&dir, SystemTime::now()); + assert!(dir.join("fresh").exists()); + prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2); + assert!(!dir.join("fresh").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2d832f9..0c0d40c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -88,6 +88,13 @@ Existing folders stay device backups. Older builds cannot open the upgraded data slideshow. - The line under the date names the town the photo was taken in, when it is known. +- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat + or any app that takes files. The photos download when dropped and arrive under their own + names. A drag that starts on a photo that is not selected still selects, as before. + +- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12 + items…" with a progress bar above the timeline, instead of a single toast at the start. + ### Changed - **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter diff --git a/po/de.po b/po/de.po index 725588f..288bc29 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-11-14 13:53+0100\n" +"POT-Creation-Date: 2026-11-14 15:22+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3571 +#: crates/pdfs-gui/src/app/pages/photos.rs:3707 msgid "%H:%M" msgstr "%H:%M" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 @@ -70,75 +70,75 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 +#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 #: crates/pdfs-gui/src/app/pages/albums.rs:636 -#: crates/pdfs-gui/src/app/pages/photos.rs:920 +#: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:885 +#: crates/pdfs-gui/src/app/main.rs:887 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:888 +#: crates/pdfs-gui/src/app/main.rs:890 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:889 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:893 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1243 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1264 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1265 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1355 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -150,23 +150,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1368 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1378 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1383 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1387 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -191,7 +191,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1395 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3571 +#: crates/pdfs-gui/src/app/pages/photos.rs:3707 msgid "%H:%M" msgstr "" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 @@ -73,75 +73,75 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:877 crates/pdfs-gui/src/app/main.rs:1547 +#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 #: crates/pdfs-gui/src/app/pages/albums.rs:636 -#: crates/pdfs-gui/src/app/pages/photos.rs:920 +#: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:880 +#: crates/pdfs-gui/src/app/main.rs:882 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:885 +#: crates/pdfs-gui/src/app/main.rs:887 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:888 +#: crates/pdfs-gui/src/app/main.rs:890 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:889 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:893 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1243 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1264 +#: crates/pdfs-gui/src/app/main.rs:1266 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1265 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1355 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1368 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1378 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1383 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1385 +#: crates/pdfs-gui/src/app/main.rs:1387 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -178,21 +178,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1393 +#: crates/pdfs-gui/src/app/main.rs:1395 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -212,7 +212,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1403 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1409 +#: crates/pdfs-gui/src/app/main.rs:1411 msgid "" "The street map, when turned on, comes from OpenFreeMap in the