docs(core,fuse,gui,cli): US spelling and correct schema lines

User-visible strings, doc comments and docs now say "Favorite" and
"color", matching the `pdfs favorite` command; UI_UX_PLAN.md §8 records
the choice. The changelog's 1.11.x and Unreleased entries said schema 30,
but V31 (`photos.resolved_at`) shipped in 1.11.0. Adds the missing
release links for 1.10.0 to 1.11.1.
This commit is contained in:
nvrl committed 2026-09-23 03:01:27 +02:00
1 parent 8351382ef2
commit 91a5f21340
18 files changed
+78 -74

No files matched your search

+3 -3
View File
@@ -146,11 +146,11 @@ enum Command {
/// Skip this many photos from the start of the timeline. /// Skip this many photos from the start of the timeline.
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
offset: usize, offset: usize,
/// List only favourited photos. /// List only favorited photos.
#[arg(long)] #[arg(long)]
favorites: bool, favorites: bool,
}, },
/// Add or remove a photo's favourite mark. /// Add or remove a photo's favorite mark.
Favorite { Favorite {
/// Photo node uid in `volume~link` form (from `pdfs photos`). /// Photo node uid in `volume~link` form (from `pdfs photos`).
uid: String, uid: String,
@@ -188,7 +188,7 @@ enum Command {
/// and rewrites them at the right time. /// and rewrites them at the right time.
/// ///
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading /// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
/// the photo and trashing the original — favourites and albums are carried /// the photo and trashing the original — favorites and albums are carried
/// across, and the original goes to Proton's trash, not away. Run it with /// across, and the original goes to Proton's trash, not away. Run it with
/// `--dry-run` first. /// `--dry-run` first.
RedatePhotos { RedatePhotos {
+5 -5
View File
@@ -63,7 +63,7 @@ pub enum Request {
/// timeline. Like `kind`, the offset is relative to the filtered set. /// timeline. Like `kind`, the offset is relative to the filtered set.
#[serde(default)] #[serde(default)]
range: Option<(i64, i64)>, range: Option<(i64, i64)>,
/// Restrict the page to favourited photos. Older front-ends omit it and /// Restrict the page to favorited photos. Older front-ends omit it and
/// get the whole timeline, as before. /// get the whole timeline, as before.
#[serde(default)] #[serde(default)]
favorites: bool, favorites: bool,
@@ -181,7 +181,7 @@ pub enum Request {
/// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is /// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is
/// the signature of an import whose metadata sidecars did not match. /// the signature of an import whose metadata sidecars did not match.
/// ///
/// Favourites and album memberships are carried across; the original is /// Favorites and album memberships are carried across; the original is
/// **trashed**, not deleted, so a bad run is recoverable from Proton's /// **trashed**, not deleted, so a bad run is recoverable from Proton's
/// trash. With `dry_run` nothing is uploaded or trashed and the report says /// trash. With `dry_run` nothing is uploaded or trashed and the report says
/// what would have been done. /// what would have been done.
@@ -1395,8 +1395,8 @@ pub struct PhotoItem {
/// the split omit it; a front-end then treats everything as a still photo. /// the split omit it; a front-end then treats everything as a still photo.
#[serde(default = "default_photo_kind")] #[serde(default = "default_photo_kind")]
pub kind: PhotoKind, pub kind: PhotoKind,
/// Whether the photo is favourited. Older daemons omit it, which a front-end /// Whether the photo is favorited. Older daemons omit it, which a front-end
/// reads as "not favourited" — the same thing it would show for a photo whose /// reads as "not favorited" — the same thing it would show for a photo whose
/// tags it has not learned yet. /// tags it has not learned yet.
#[serde(default)] #[serde(default)]
pub favorite: bool, pub favorite: bool,
@@ -2703,7 +2703,7 @@ mod tests {
} }
} }
/// A front-end that predates the favourites filter sends no `favorites` /// A front-end that predates the favorites filter sends no `favorites`
/// field, and must keep getting the whole timeline rather than an empty page. /// field, and must keep getting the whole timeline rather than an empty page.
#[test] #[test]
fn timeline_requests_without_the_favorites_filter_still_decode() { fn timeline_requests_without_the_favorites_filter_still_decode() {
+2 -2
View File
@@ -178,7 +178,7 @@ impl Db {
ratio: r.get(3)?, ratio: r.get(3)?,
thumb_state: r.get(4)?, thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?), kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album // Album rows have no favorite state of their own: an album
// photo on someone else's volume is not in our timeline, and // photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway. // favouriting one is not supported yet anyway.
favorite: false, favorite: false,
@@ -239,7 +239,7 @@ impl Db {
ratio: r.get(3)?, ratio: r.get(3)?,
thumb_state: r.get(4)?, thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?), kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album // Album rows have no favorite state of their own: an album
// photo on someone else's volume is not in our timeline, and // photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway. // favouriting one is not supported yet anyway.
favorite: false, favorite: false,
+2 -2
View File
@@ -684,7 +684,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid); CREATE INDEX idx_album_photos_uid ON album_photos(uid);
"; ";
/// V20: whether a photo is a favourite. /// V20: whether a photo is a favorite.
/// ///
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a /// A column rather than a tag table: `Favorite` is the only Proton photo tag a
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the /// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
@@ -938,7 +938,7 @@ CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
/// Schema v31: which photos a refresh still has to read from the server. /// Schema v31: which photos a refresh still has to read from the server.
/// ///
/// A refresh used to resolve every photo's node to learn its name, media type, /// A refresh used to resolve every photo's node to learn its name, media type,
/// favourite tag, content hash and photo relation — minutes of round-trips for a /// favorite tag, content hash and photo relation — minutes of round-trips for a
/// library of any size, repeated every time the timeline went stale. `resolved_at` /// library of any size, repeated every time the timeline went stale. `resolved_at`
/// records that the work was already done, so a refresh only reads the photos it /// records that the work was already done, so a refresh only reads the photos it
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`). /// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
+2 -2
View File
@@ -455,7 +455,7 @@ impl Db {
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set, /// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the /// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave. /// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos. /// `favorites` restricts the page to favorited photos.
/// ///
/// The page holds one row per *group*: the RAW and the JPEG of one shot are /// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because /// one tile. The Raw tab is the exception and lists every raw file, because
@@ -620,7 +620,7 @@ impl Db {
Ok(photos) Ok(photos)
} }
/// Record a photo's favourite flag locally, after the server accepted the /// Record a photo's favorite flag locally, after the server accepted the
/// change. A uid the timeline does not hold is a no-op — an album photo on /// change. A uid the timeline does not hold is a no-op — an album photo on
/// someone else's volume is never in our own `photos` table. /// someone else's volume is never in our own `photos` table.
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> { pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
+1 -1
View File
@@ -222,7 +222,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
assert_eq!( assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(), favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"], ["p1"],
"only the favourited photo is in a favourites page" "only the favorited photo is in a favorites page"
); );
// A local toggle survives a refresh that could not resolve the photo's node // A local toggle survives a refresh that could not resolve the photo's node
+1 -1
View File
@@ -812,7 +812,7 @@ mod tests {
} }
#[test] #[test]
fn sidecar_supplies_capture_time_and_favourite() { fn sidecar_supplies_capture_time_and_favorite() {
let mut sidecars = HashMap::new(); let mut sidecars = HashMap::new();
sidecar( sidecar(
&mut sidecars, &mut sidecars,
+4 -4
View File
@@ -511,7 +511,7 @@ impl PhotoEventPlan {
/// ///
/// `is_echo` claims an event as this daemon's own write. The feed replays our /// `is_echo` claims an event as this daemon's own write. The feed replays our
/// writes back at us and a `NodeUpdated` carries no revision id, so without this /// writes back at us and a `NodeUpdated` carries no revision id, so without this
/// every favourite the user toggles here would mark its own photo stale and buy /// every favorite the user toggles here would mark its own photo stale and buy
/// a resolve of a node we just wrote. /// a resolve of a node we just wrote.
fn classify_photo_events( fn classify_photo_events(
events: &[DriveEvent], events: &[DriveEvent],
@@ -529,7 +529,7 @@ fn classify_photo_events(
if *is_trashed { if *is_trashed {
plan.gone.push(node_uid.to_string()); plan.gone.push(node_uid.to_string());
} else if !is_echo(node_uid) { } else if !is_echo(node_uid) {
// An untrash, a rename, a favourite toggled on the phone: the // An untrash, a rename, a favorite toggled on the phone: the
// event says which node changed but never what about it, so // event says which node changed but never what about it, so
// the uid is marked stale and the next refresh reads it. // the uid is marked stale and the next refresh reads it.
plan.touched.push(node_uid.to_string()); plan.touched.push(node_uid.to_string());
@@ -974,7 +974,7 @@ mod tests {
}, },
DriveEvent::NodeUpdated { DriveEvent::NodeUpdated {
id: DriveEventId::from("e3"), id: DriveEventId::from("e3"),
node_uid: uid("favourited"), node_uid: uid("favorited"),
parent_node_uid: None, parent_node_uid: None,
is_trashed: false, is_trashed: false,
is_shared: false, is_shared: false,
@@ -986,7 +986,7 @@ mod tests {
plan, plan,
PhotoEventPlan { PhotoEventPlan {
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()], gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
touched: vec![uid("favourited").to_string()], touched: vec![uid("favorited").to_string()],
full: false, full: false,
} }
); );
+2 -2
View File
@@ -387,9 +387,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) { Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
Ok(()) => CtlResponse::Ok { Ok(()) => CtlResponse::Ok {
message: if favorite { message: if favorite {
"added to favourites".to_string() "added to favorites".to_string()
} else { } else {
"removed from favourites".to_string() "removed from favorites".to_string()
}, },
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
+3 -3
View File
@@ -501,7 +501,7 @@ impl Core {
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?; .map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
// The photo event feed replays this write back at us as a bare // The photo event feed replays this write back at us as a bare
// `NodeUpdated`, which is indistinguishable from the same photo being // `NodeUpdated`, which is indistinguishable from the same photo being
// favourited on a phone. Claim the echo, or the gallery pays for a // favorited on a phone. Claim the echo, or the gallery pays for a
// resolve of a node whose one changed field is already stored below. // resolve of a node whose one changed field is already stored below.
self.note_self_change(uid); self.note_self_change(uid);
self.db self.db
@@ -1469,7 +1469,7 @@ impl Core {
NodeKind::File { media_type, .. } => Some(media_type.clone()), NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None, NodeKind::Folder => None,
}; };
// The favourite state is a tag on the photo node, so it // The favorite state is a tag on the photo node, so it
// rides along with the metadata resolve rather than // rides along with the metadata resolve rather than
// costing a listing of its own. // costing a listing of its own.
let favorite = node let favorite = node
@@ -1507,7 +1507,7 @@ impl Core {
let key = it.uid.to_string(); let key = it.uid.to_string();
let main_uid = main_of.get(&key).cloned(); let main_uid = main_of.get(&key).cloned();
// An unresolved photo keeps whatever was learned before, for the // An unresolved photo keeps whatever was learned before, for the
// favourite flag as much as for the name and media type. // favorite flag as much as for the name and media type.
match meta.get(&key).cloned() { match meta.get(&key).cloned() {
Some(m) => db::TimelineRow { Some(m) => db::TimelineRow {
name: m.name, name: m.name,
+3 -3
View File
@@ -8,7 +8,7 @@
//! //!
//! Proton has no API for editing a sealed capture time, so the repair is a //! Proton has no API for editing a sealed capture time, so the repair is a
//! rewrite: download the photo, upload it again with the capture time its file //! rewrite: download the photo, upload it again with the capture time its file
//! name implies, put the new copy back into the albums and favourites the old //! name implies, put the new copy back into the albums and favorites the old
//! one was in, then **trash** (not delete) the original. Trash rather than //! one was in, then **trash** (not delete) the original. Trash rather than
//! delete so a bad run is recoverable from Proton's own trash for 30 days. //! delete so a bad run is recoverable from Proton's own trash for 30 days.
//! //!
@@ -213,7 +213,7 @@ impl Core {
drop(guard); drop(guard);
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?; let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
// Favourite and albums before the trash: a failure here leaves both // Favorite and albums before the trash: a failure here leaves both
// copies on the account, which is recoverable by hand. Losing the // copies on the account, which is recoverable by hand. Losing the
// original first would not be. // original first would not be.
if candidate.favorite { if candidate.favorite {
@@ -227,7 +227,7 @@ impl Core {
// just set does not come back as a remote change. // just set does not come back as a remote change.
Ok(_) => self.note_self_change(&new_uid), Ok(_) => self.note_self_change(&new_uid),
Err(e) => { Err(e) => {
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo") warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
} }
} }
} }
+3 -3
View File
@@ -357,9 +357,9 @@ impl Core {
} }
} }
// Favourites are a separate call: the upload seal takes classification // Favorites are a separate call: the upload seal takes classification
// tags, but `Favorite` is not one of them — it goes through the photos // tags, but `Favorite` is not one of them — it goes through the photos
// volume's dedicated favourite endpoint. // volume's dedicated favorite endpoint.
if !favorites.is_empty() { if !favorites.is_empty() {
let updates: Vec<PhotoTagsUpdate> = favorites let updates: Vec<PhotoTagsUpdate> = favorites
.into_iter() .into_iter()
@@ -370,7 +370,7 @@ impl Core {
}) })
.collect(); .collect();
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) { if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
warn!(error = %e, "marking imported favourites failed"); warn!(error = %e, "marking imported favorites failed");
} }
} }
Ok(()) Ok(())
@@ -19,7 +19,7 @@ pub(crate) struct Viewer {
pub(crate) info_map: gtk4::Button, pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown. /// Coordinates behind `info_map`, once a photo with GPS tags is shown.
pub(crate) coords: RefCell<Option<(f64, f64)>>, pub(crate) coords: RefCell<Option<(f64, f64)>>,
/// The favourite toggle in the top bar, and whether it is being set from /// The favorite toggle in the top bar, and whether it is being set from
/// the model rather than by the user — the same suppression the details pane /// the model rather than by the user — the same suppression the details pane
/// uses for its pin switch, so painting a photo doesn't fire a round-trip. /// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton, pub(crate) favorite: gtk4::ToggleButton,
@@ -51,7 +51,7 @@ pub(crate) struct ExifInfo {
pub(crate) coords: Option<(f64, f64)>, pub(crate) coords: Option<(f64, f64)>,
} }
/// Paint the favourite toggle without firing its handler. /// Paint the favorite toggle without firing its handler.
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) { pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
viewer.favorite_suppress.set(true); viewer.favorite_suppress.set(true);
viewer.favorite.set_active(favorite); viewer.favorite.set_active(favorite);
@@ -59,7 +59,7 @@ pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
viewer.favorite_suppress.set(false); viewer.favorite_suppress.set(false);
} }
/// Filled star for a favourite, outline for the rest. /// Filled star for a favorite, outline for the rest.
fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) { fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) {
button.set_icon_name(if favorite { button.set_icon_name(if favorite {
"starred-symbolic" "starred-symbolic"
@@ -68,7 +68,7 @@ fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) {
}); });
} }
/// Write a favourite change back into the loaded gallery page, so returning to /// Write a favorite change back into the loaded gallery page, so returning to
/// the grid (or reopening the photo) shows what was just set without a reload. /// the grid (or reopening the photo) shows what was just set without a reload.
pub(crate) fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) { pub(crate) fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else { let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
@@ -147,7 +147,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
*viewer.path.borrow_mut() = None; *viewer.path.borrow_mut() = None;
clear_info(viewer); clear_info(viewer);
// The favourite state comes from the timeline page the gallery already has, // The favorite state comes from the timeline page the gallery already has,
// so the button is right on the first frame instead of after a round-trip. // so the button is right on the first frame instead of after a round-trip.
let favorite = find_photo_index(&ui.gallery.model, &uid) let favorite = find_photo_index(&ui.gallery.model, &uid)
.and_then(|idx| ui.gallery.model.item(idx)) .and_then(|idx| ui.gallery.model.item(idx))
@@ -556,7 +556,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let favorite_btn = gtk4::ToggleButton::builder() let favorite_btn = gtk4::ToggleButton::builder()
.icon_name("non-starred-symbolic") .icon_name("non-starred-symbolic")
.tooltip_text("Favourite") .tooltip_text("Favorite")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("flat");
@@ -758,7 +758,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
_ => Some("The mount service didn't respond.".to_string()), _ => Some("The mount service didn't respond.".to_string()),
}; };
if let Some(detail) = failed { if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favourite", &detail); toast_error(&ui_result, "Couldn't change the favorite", &detail);
// The server refused, so the button must go back to describing // The server refused, so the button must go back to describing
// what is actually stored — without firing this handler again. // what is actually stored — without firing this handler again.
if *viewer_result.uid.borrow() == uid { if *viewer_result.uid.borrow() == uid {
+16 -16
View File
@@ -84,7 +84,7 @@ pub(crate) struct GalleryState {
/// The capture-time window the timeline is currently filtered to, or `None` /// The capture-time window the timeline is currently filtered to, or `None`
/// for the whole span. Read by [`load_gallery`], set by the date dropdown. /// for the whole span. Read by [`load_gallery`], set by the date dropdown.
pub(crate) range: Cell<Option<(i64, i64)>>, pub(crate) range: Cell<Option<(i64, i64)>>,
/// The favourites toggle, and whether it is on. When on, the timeline is /// The favorites toggle, and whether it is on. When on, the timeline is
/// restricted to photos carrying Proton's `Favorite` tag. /// restricted to photos carrying Proton's `Favorite` tag.
pub(crate) favorites_btn: gtk4::ToggleButton, pub(crate) favorites_btn: gtk4::ToggleButton,
pub(crate) favorites: Cell<bool>, pub(crate) favorites: Cell<bool>,
@@ -495,12 +495,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
tab_group.append(btn); tab_group.append(btn);
} }
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so // Favorites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
// it stays outside the segmented control rather than becoming a fifth option // it stays outside the segmented control rather than becoming a fifth option
// that would silently drop the kind the user picked. // that would silently drop the kind the user picked.
let favorites_btn = gtk4::ToggleButton::builder() let favorites_btn = gtk4::ToggleButton::builder()
.icon_name("starred-symbolic") .icon_name("starred-symbolic")
.tooltip_text("Show only favourites") .tooltip_text("Show only favorites")
.build(); .build();
favorites_btn.add_css_class("pill"); favorites_btn.add_css_class("pill");
@@ -962,7 +962,7 @@ pub(crate) fn wire_gallery(
}); });
} }
// Favourites: reload the timeline restricted to favourites (or back to all). // Favorites: reload the timeline restricted to favorites (or back to all).
// Independent of the kind tabs and the date jump, both of which keep their // Independent of the kind tabs and the date jump, both of which keep their
// current value across the toggle. // current value across the toggle.
let ui_fav = ui.clone(); let ui_fav = ui.clone();
@@ -2118,7 +2118,7 @@ pub(crate) fn short_capture_time(secs: i64) -> String {
/// Fetch a timeline page from the daemon. When `append` is false the model is /// Fetch a timeline page from the daemon. When `append` is false the model is
/// cleared first (fresh load); otherwise the next page is tacked on. /// cleared first (fresh load); otherwise the next page is tacked on.
/// The empty state for a timeline filtered to `kind`, favourites and/or a /// The empty state for a timeline filtered to `kind`, favorites and/or a
/// `month`: what is missing, in the filter's own words. /// `month`: what is missing, in the filter's own words.
pub(crate) fn empty_timeline_text( pub(crate) fn empty_timeline_text(
kind: Option<PhotoKind>, kind: Option<PhotoKind>,
@@ -2138,12 +2138,12 @@ pub(crate) fn empty_timeline_text(
"Photos you upload to Proton Drive appear here.".to_string(), "Photos you upload to Proton Drive appear here.".to_string(),
), ),
(true, None, None) => ( (true, None, None) => (
"No favourites yet".to_string(), "No favorites yet".to_string(),
"Star a photo in the viewer or from its menu to find it here.".to_string(), "Star a photo in the viewer or from its menu to find it here.".to_string(),
), ),
(true, _, _) => ( (true, _, _) => (
format!("No favourite {what}{when}"), format!("No favorite {what}{when}"),
"Turn off the favourites filter to see everything.".to_string(), "Turn off the favorites filter to see everything.".to_string(),
), ),
(false, _, _) => ( (false, _, _) => (
format!("No {what}{when}"), format!("No {what}{when}"),
@@ -2222,9 +2222,9 @@ fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64
let (ui_c, uid, favorite) = (ui.clone(), photo.uid.clone(), photo.favorite); let (ui_c, uid, favorite) = (ui.clone(), photo.uid.clone(), photo.favorite);
item( item(
if favorite { if favorite {
"Remove from Favourites" "Remove from Favorites"
} else { } else {
"Add to Favourites" "Add to Favorites"
}, },
if favorite { if favorite {
"non-starred-symbolic" "non-starred-symbolic"
@@ -2269,18 +2269,18 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
toast( toast(
&ui, &ui,
if favorite { if favorite {
"Added to Favourites" "Added to Favorites"
} else { } else {
"Removed from Favourites" "Removed from Favorites"
}, },
); );
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
toast_error(&ui, "Couldn't change the favourite", &message) toast_error(&ui, "Couldn't change the favorite", &message)
} }
_ => toast_error( _ => toast_error(
&ui, &ui,
"Couldn't change the favourite", "Couldn't change the favorite",
"The mount service didn't respond.", "The mount service didn't respond.",
), ),
} }
@@ -2540,14 +2540,14 @@ mod tests {
#[test] #[test]
fn an_empty_filter_names_what_it_filtered() { fn an_empty_filter_names_what_it_filtered() {
assert_eq!(empty_timeline_text(None, false, None).0, "No photos yet"); assert_eq!(empty_timeline_text(None, false, None).0, "No photos yet");
assert_eq!(empty_timeline_text(None, true, None).0, "No favourites yet"); assert_eq!(empty_timeline_text(None, true, None).0, "No favorites yet");
assert_eq!( assert_eq!(
empty_timeline_text(Some(PhotoKind::Video), false, Some("June 2024")).0, empty_timeline_text(Some(PhotoKind::Video), false, Some("June 2024")).0,
"No videos in June 2024" "No videos in June 2024"
); );
assert_eq!( assert_eq!(
empty_timeline_text(Some(PhotoKind::Raw), true, None).0, empty_timeline_text(Some(PhotoKind::Raw), true, None).0,
"No favourite raw files" "No favorite raw files"
); );
} }
} }
+2 -6
View File
@@ -190,7 +190,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build(); let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build();
let accent_row = adw::SwitchRow::builder() let accent_row = adw::SwitchRow::builder()
.title("Proton purple accent") .title("Proton purple accent")
.subtitle("Use the Proton brand colour instead of the system accent colour") .subtitle("Use the Proton brand color instead of the system accent color")
.build(); .build();
appearance_group.add(&accent_row); appearance_group.add(&accent_row);
@@ -531,11 +531,7 @@ pub(crate) fn wire_settings(
let mut config = ui_accent.dirs.load_config(); let mut config = ui_accent.dirs.load_config();
config.proton_accent = Some(on); config.proton_accent = Some(on);
if let Err(e) = ui_accent.dirs.save_config(&config) { if let Err(e) = ui_accent.dirs.save_config(&config) {
toast_error( toast_error(&ui_accent, "Couldn't save the accent color", &e.to_string());
&ui_accent,
"Couldn't save the accent colour",
&e.to_string(),
);
} }
}); });
} }
+10 -3
View File
@@ -11,7 +11,7 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
Schema: **30** (unchanged). Schema: **31** (unchanged).
### Added ### Added
- **Pause and resume sync.** The Sync page, the tray and `pdfs sync pause [--for 1h]` / - **Pause and resume sync.** The Sync page, the tray and `pdfs sync pause [--for 1h]` /
@@ -66,10 +66,12 @@ Schema: **30** (unchanged).
keyring, a skipped two-factor or verification step — instead of the raw error. The button spins keyring, a skipped two-factor or verification step — instead of the raw error. The button spins
while signing in, and the page links to account creation and password reset. The two-factor while signing in, and the page links to account creation and password reset. The two-factor
dialog mentions recovery codes. dialog mentions recovery codes.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command.
## [1.11.1] — 2026-09-19 ## [1.11.1] — 2026-09-19
Schema: **30** (unchanged). Schema: **31** (unchanged).
### Fixed ### Fixed
- **An upload parked behind a temp-file rename no longer stays parked forever.** A create for a - **An upload parked behind a temp-file rename no longer stays parked forever.** A create for a
@@ -96,7 +98,7 @@ Schema: **30** (unchanged).
## [1.11.0] — 2026-09-19 ## [1.11.0] — 2026-09-19
Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` / Schema: **31** (V30 `photos.content_hash` / `main_uid` / `group_key`, V31 `photos.resolved_at`); SDK bumped to `proton-sdk` /
`proton-drive-rs` **0.6.5**. `proton-drive-rs` **0.6.5**.
### Changed ### Changed
@@ -882,6 +884,11 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
[1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0
[1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2
[1.10.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.1
[1.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.0
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3 [1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2 [1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1 [1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
+4 -4
View File
@@ -14,7 +14,7 @@ Consolidated from the historical feature roadmap; current bug status lives in [`
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration. * **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`). * **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges. * **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favourites are implemented. * **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favorites are implemented.
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending. * **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches. * **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls. * **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
@@ -135,10 +135,10 @@ Three properties that shape the code:
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button. - **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards. - **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
### Photo Favourites (Implemented) ### Photo Favorites (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs) **Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`. The gallery can mark and filter favorites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favorites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing. Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
@@ -155,7 +155,7 @@ The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` /
### RAW + JPEG Grouping (Implemented) ### RAW + JPEG Grouping (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs) **Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand. Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favorite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo. - **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan. - **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
+8 -7
View File
@@ -21,7 +21,7 @@ The client is functionally rich but the UI is built page by page, and it shows:
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current `AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
folder in the title. folder in the title.
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses - **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
stars (reads as "favourite"), trash icons mean "stop syncing", "remove device" and "remove stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept. bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect". "Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile - **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
@@ -212,7 +212,7 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle, - Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at `+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library. `photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favourites) instead of - Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
two segmented rows where "Photos" appears twice. Counts follow the active filter. two segmented rows where "Photos" appears twice. Counts follow the active filter.
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real - **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
"Filter by date" if kept. "Filter by date" if kept.
@@ -221,9 +221,9 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent - Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
shadow on a 2 px gap grid), last row justified up to a max stretch. shadow on a 2 px gap grid), last row justified up to a max stretch.
- Context menu on tiles: Open, Favourite, Add to album, Download/Save copy, Show in My files, Move to - Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
Trash. Selection: Shift range, Ctrl+A, bulk Favourite, Add to album, Download. Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
- Empty states per filter ("No favourites yet", "No videos in June 2024") instead of the global - Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
"No photos yet" with upload buttons. "No photos yet" with upload buttons.
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and - Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
Back returns to where the user came from. Back returns to where the user came from.
@@ -233,7 +233,7 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar, (pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
pages beyond the loaded 60, counter against the real total, videos play inline with pages beyond the loaded 60, counter against the real total, videos play inline with
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy", `gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
success toast, "Open with…" app chooser, favourite tooltip reflects state, readable dates. success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
### Shared with me / Shared by me ### Shared with me / Shared by me
@@ -292,7 +292,8 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
| Photos section | Photos | `image-x-generic-symbolic` | | Photos section | Photos | `image-x-generic-symbolic` |
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) | | Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
Spelling: pick one (US "Favorite" or UK "Favourite") and apply it everywhere. Title Case for Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
Proton's own apps. Title Case for
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG). dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
## 9. Bugs found during the audit (quick wins) ## 9. Bugs found during the audit (quick wins)