From 47934b7ddb1ef8b1c763b904a91ccedec8bbe482 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 16 Oct 2026 12:12:41 +0200 Subject: [PATCH] fix(core,fuse,gui): date photos by their EXIF when nothing else does A Takeout photo with no sidecar and no date in its name was filed under the day of the import, and the app dated uploads by modification time. A new `exiftime` module reads DateTimeOriginal (then DateTimeDigitized) with its offset. Takeout uses it for undated photos it copies out for a thumbnail, and the daemon uses it for app uploads before falling back to the modification time (B244). --- Cargo.lock | 1 + crates/pdfs-core/Cargo.toml | 1 + crates/pdfs-core/src/exiftime.rs | 133 ++++++++++++++++++++++++ crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/takeout.rs | 2 +- crates/pdfs-fuse/src/control.rs | 11 ++ crates/pdfs-fuse/src/takeout.rs | 8 ++ crates/pdfs-gui/src/app/pages/photos.rs | 8 +- docs/BUGS.md | 30 ++++++ 9 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 crates/pdfs-core/src/exiftime.rs diff --git a/Cargo.lock b/Cargo.lock index c571294..f2f1b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3497,6 +3497,7 @@ dependencies = [ "dbus-secret-service-keyring-store", "directories", "ignore", + "kamadak-exif", "keyring-core", "libc", "parking_lot", diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index 0804486..82acd1a 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -30,3 +30,4 @@ async-trait.workspace = true libc.workspace = true zeroize.workspace = true base64.workspace = true +exif = { version = "0.6", package = "kamadak-exif" } diff --git a/crates/pdfs-core/src/exiftime.rs b/crates/pdfs-core/src/exiftime.rs new file mode 100644 index 0000000..a7cb391 --- /dev/null +++ b/crates/pdfs-core/src/exiftime.rs @@ -0,0 +1,133 @@ +//! When a photo was taken, as its own EXIF tags say. +//! +//! The fallback for a photo nothing else dates: a Takeout photo without a +//! sidecar or a date in its name, and a photo uploaded from the app. Without +//! it the first was filed under the moment of the import and the second under +//! the file's modification time, which a copy resets. + +use std::io::{BufRead, Seek}; +use std::path::Path; + +use crate::takeout::days_from_civil; + +/// The capture time in the EXIF of the file at `path`, in epoch seconds. +pub fn capture_time_of(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + capture_time(&mut std::io::BufReader::new(file)) +} + +/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then +/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera +/// wrote one. Without an offset the time is read as UTC, as a date in a file +/// name is (`takeout::capture_time_from_name`). +pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option { + let exif = exif::Reader::new().read_from_container(reader).ok()?; + let ascii = |tag: exif::Tag| -> Option { + let field = exif.get_field(tag, exif::In::PRIMARY)?; + match &field.value { + exif::Value::Ascii(parts) => parts.first().map(|part| { + String::from_utf8_lossy(part) + .trim_matches('\0') + .trim() + .to_string() + }), + _ => None, + } + }; + [ + (exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal), + (exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized), + ] + .into_iter() + .find_map(|(time, offset)| { + let local = parse_exif_time(&ascii(time)?)?; + let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0); + Some(local - offset) + }) +} + +/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the +/// all-zero or blank value cameras write when they do not know. +fn parse_exif_time(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' { + return None; + } + let number = |range: std::ops::Range| text.get(range)?.parse::().ok(); + let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?); + let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?); + if !(1800..=9999).contains(&year) + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour > 23 + || minute > 59 + || second > 60 + { + return None; + } + Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second) +} + +/// `+HH:MM` or `-HH:MM` as seconds east of UTC. +fn parse_offset(text: &str) -> Option { + let sign = match text.as_bytes().first()? { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let (hours, minutes) = text.get(1..)?.split_once(':')?; + let (hours, minutes) = (hours.parse::().ok()?, minutes.parse::().ok()?); + (hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_exif_time_reads_as_utc_and_its_offset_moves_it() { + // 2023-06-01 12:00:00 UTC. + let noon = 1_685_620_800; + assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon)); + assert_eq!(parse_offset("+02:00"), Some(7200)); + assert_eq!(parse_offset("-05:30"), Some(-19_800)); + assert_eq!( + parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()), + Some(noon) + ); + } + + #[test] + fn an_unknown_exif_time_is_no_time() { + assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None); + assert_eq!(parse_exif_time(" : : : : "), None); + assert_eq!(parse_exif_time(""), None); + assert_eq!(parse_offset("02:00"), None); + } + + #[test] + fn a_photo_is_dated_by_its_original_time_and_offset() { + let ascii = |tag, text: &str| exif::Field { + tag, + ifd_num: exif::In::PRIMARY, + value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]), + }; + let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00"); + let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00"); + let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00"); + let mut writer = exif::experimental::Writer::new(); + writer.push_field(&taken); + writer.push_field(&offset); + writer.push_field(&digitized); + let mut tiff = std::io::Cursor::new(Vec::new()); + writer.write(&mut tiff, false).unwrap(); + tiff.set_position(0); + assert_eq!(capture_time(&mut tiff), Some(1_685_620_800)); + } + + #[test] + fn a_file_without_exif_has_no_capture_time() { + let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec()); + assert_eq!(capture_time(&mut png), None); + } +} diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index f429310..73a84a7 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; pub mod control; pub mod db; pub mod error; +pub mod exiftime; pub mod kernelkey; pub mod localindex; pub mod menu; diff --git a/crates/pdfs-core/src/takeout.rs b/crates/pdfs-core/src/takeout.rs index 669febf..df9cf40 100644 --- a/crates/pdfs-core/src/takeout.rs +++ b/crates/pdfs-core/src/takeout.rs @@ -608,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option { /// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's /// `days_from_civil`). Avoids pulling a date crate in for one conversion. -fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { +pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { let year = year - i64::from(month <= 2); let era = if year >= 0 { year } else { year - 399 } / 400; let year_of_era = year - era * 400; diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 5fe3bb9..1e0f5ee 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -911,6 +911,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U ))), Ok((file, len)) => { let photos = core.photos(); + // Unless the caller knows better: the photo's own EXIF, + // then the file's modification time, which a copy resets. + let capture_time = capture_time.or_else(|| { + let path = Path::new(&source_path); + pdfs_core::exiftime::capture_time_of(path).or_else(|| { + let modified = + std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(since.as_secs() as i64) + }) + }); let metadata = proton_drive_rs::PhotoUploadMetadata { capture_time, ..Default::default() diff --git a/crates/pdfs-fuse/src/takeout.rs b/crates/pdfs-fuse/src/takeout.rs index a3031d6..9194731 100644 --- a/crates/pdfs-fuse/src/takeout.rs +++ b/crates/pdfs-fuse/src/takeout.rs @@ -363,6 +363,14 @@ impl Core { let uploaded = match &staged { Some(path) => { let thumbnails = self.upload_thumbnails(path, &photo.name); + // No sidecar and no date in the name: the photo's own + // EXIF, before Drive files it under today. + let metadata = PhotoUploadMetadata { + capture_time: metadata + .capture_time + .or_else(|| pdfs_core::exiftime::capture_time_of(path)), + ..metadata + }; std::fs::File::open(path) .map_err(|e| { CoreError::internal(format!("open {}: {e}", path.display())) diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 3015e9b..e5b0322 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -1379,11 +1379,9 @@ pub(crate) fn wire_gallery( "tiff" | "tif" => "image/tiff", _ => "image/jpeg", }; - let capture_time = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + // The daemon dates the photo: by its EXIF, or by the file's + // modification time when it has none. + let capture_time = None; ui.busy_begin(); let rx = spawn_request( diff --git a/docs/BUGS.md b/docs/BUGS.md index ca7f099..b672a2f 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B244](#b244--photos-without-a-sidecar-or-a-dated-name-are-filed-under-the-upload-day) | Photos without a sidecar or a dated name are filed under the upload day | | [B243](#b243--a-hung-mount-freezes-the-app-when-a-file-is-opened) | A hung mount freezes the app when a file is opened | | [B242](#b242--opening-a-large-photo-freezes-the-app) | Opening a large photo freezes the app | | [B241](#b241--every-closed-photo-viewer-stays-in-memory-with-its-photo) | Every closed photo viewer stays in memory with its photo | @@ -346,6 +347,35 @@ a live account since. Newest first. +### B244 — Photos without a sidecar or a dated name are filed under the upload day + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` 2.4.3). +**Where:** `crates/pdfs-core/src/exiftime.rs`; the `UploadPhoto` request in +`crates/pdfs-fuse/src/control.rs`; `Core::import_takeout` in `crates/pdfs-fuse/src/takeout.rs`; +the upload in `crates/pdfs-gui/src/app/pages/photos.rs` + +**Repro:** +- Import a Takeout export in which a photo has no sidecar and no date in its name, such as + `DSC01234.JPG`. It lands in the timeline under the day of the import. +- Copy a photo from a camera card with a tool that does not keep modification times, and upload + it from the app's gallery. It lands under the day of the copy. + +**Cause.** Nothing read a photo's own EXIF for its capture time. A Takeout photo fell back to its +sidecar, then to a date in its name, then to nothing, which Drive files as "now". The app sent +the file's modification time. + +**Fix.** `exiftime::capture_time` reads `DateTimeOriginal`, then `DateTimeDigitized`, each moved +by its `OffsetTime…` tag when present and read as UTC otherwise, like a date in a file name. A +Takeout photo with no other date uses it when the photo was copied out of the archive for its +thumbnail (B236). The app no longer sends a time; the daemon dates the upload by EXIF, then by +the modification time. + +**Test:** `exiftime::tests` (four cases, among them +`a_photo_is_dated_by_its_original_time_and_offset`). + +--- + ### B243 — A hung mount freezes the app when a file is opened **Status:** Fixed, not verified live