mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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).
This commit is contained in:
1 parent
48c8910e7b
commit
47934b7ddb
9 files changed
+189
-6
No files matched your search
Generated
+1
@@ -3497,6 +3497,7 @@ dependencies = [
|
||||
"dbus-secret-service-keyring-store",
|
||||
"directories",
|
||||
"ignore",
|
||||
"kamadak-exif",
|
||||
"keyring-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
|
||||
@@ -30,3 +30,4 @@ async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
zeroize.workspace = true
|
||||
base64.workspace = true
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
@@ -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<i64> {
|
||||
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<i64> {
|
||||
let exif = exif::Reader::new().read_from_container(reader).ok()?;
|
||||
let ascii = |tag: exif::Tag| -> Option<String> {
|
||||
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<i64> {
|
||||
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<usize>| text.get(range)?.parse::<i64>().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<i64> {
|
||||
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::<i64>().ok()?, minutes.parse::<i64>().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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -608,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option<i64> {
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user