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",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"kamadak-exif",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"libc",
|
"libc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ async-trait.workspace = true
|
|||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
base64.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 control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod exiftime;
|
||||||
pub mod kernelkey;
|
pub mod kernelkey;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
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 between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||||
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
/// `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 year = year - i64::from(month <= 2);
|
||||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||||
let year_of_era = year - era * 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)) => {
|
Ok((file, len)) => {
|
||||||
let photos = core.photos();
|
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 {
|
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||||
capture_time,
|
capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -363,6 +363,14 @@ impl Core {
|
|||||||
let uploaded = match &staged {
|
let uploaded = match &staged {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
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)
|
std::fs::File::open(path)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
CoreError::internal(format!("open {}: {e}", path.display()))
|
CoreError::internal(format!("open {}: {e}", path.display()))
|
||||||
|
|||||||
@@ -1379,11 +1379,9 @@ pub(crate) fn wire_gallery(
|
|||||||
"tiff" | "tif" => "image/tiff",
|
"tiff" | "tif" => "image/tiff",
|
||||||
_ => "image/jpeg",
|
_ => "image/jpeg",
|
||||||
};
|
};
|
||||||
let capture_time = std::fs::metadata(&path)
|
// The daemon dates the photo: by its EXIF, or by the file's
|
||||||
.and_then(|m| m.modified())
|
// modification time when it has none.
|
||||||
.ok()
|
let capture_time = None;
|
||||||
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
|
|
||||||
.map(|d| d.as_secs() as i64);
|
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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.
|
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
|
### B243 — A hung mount freezes the app when a file is opened
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
Reference in new issue
Block a user