From 9b6a12f3dab65e166c9d851ec61cbd6392ca98dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klemens=20Sch=C3=B6lhorn?= Date: Mon, 13 Mar 2023 23:10:14 +0100 Subject: [PATCH] Sort images by creation date read from EXIF or file modify date --- Cargo.lock | 31 +++++++++++++++++++++---- Cargo.toml | 1 + src/main.rs | 66 +++++++++++++++++++++++++++++++++++------------------ 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90d2dfc..5123102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,14 +378,17 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.23" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", + "js-sys", "num-integer", "num-traits", "serde", + "time 0.1.45", + "wasm-bindgen", "winapi", ] @@ -434,7 +437,7 @@ dependencies = [ "rand", "sha2 0.10.6", "subtle", - "time", + "time 0.3.20", "version_check", ] @@ -676,7 +679,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -858,6 +861,7 @@ dependencies = [ "axum-sessions", "axum-template", "chacha20poly1305", + "chrono", "hex", "image", "kamadak-exif", @@ -1020,7 +1024,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.45.0", ] @@ -1473,6 +1477,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.20" @@ -1727,6 +1742,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 4d56477..ef67a23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ axum = { version = "0.6.7", features = ["form", "macros"] } axum-sessions = "0.4.1" axum-template = { version = "0.14.0", features = ["minijinja"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] } +chrono = "0.4.24" hex = { version = "0.4.3", features = ["serde"] } image = { version = "0.24.5", default-features = false, features = ["jpeg", "webp-encoder"] } kamadak-exif = "0.5.5" diff --git a/src/main.rs b/src/main.rs index 9424422..8e9d178 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ -use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::{OsStr, OsString}, fs::File, io::{BufReader, Seek, Cursor, BufRead}, time::SystemTime, env::args_os, os::unix::prelude::OsStrExt}; +use std::{net::SocketAddr, path::{Path, PathBuf}, ffi::{OsStr, OsString}, fs::File, io::{BufReader, Seek, Cursor, BufRead}, env::args_os, os::unix::prelude::OsStrExt, cmp::Reverse}; use anyhow::{Context, Result, anyhow}; use axum::{Router, routing::{get, post}, response::{IntoResponse, Redirect}, http::{StatusCode, header}, extract::{self, State}, Form, handler::Handler}; use axum_sessions::{async_session::CookieStore, SessionLayer, extractors::{ReadableSession, WritableSession}}; use axum_template::{RenderHtml, engine::Engine}; use chacha20poly1305::{XChaCha20Poly1305, KeyInit, AeadCore, aead::{OsRng, rand_core::RngCore, Aead}, XNonce}; +use chrono::{DateTime, Utc}; use error::AppError; -use exif::{Tag, Value, Field, In, Exif}; +use exif::Exif; use image::{imageops::FilterType, DynamicImage}; use serde::{Serialize, Deserialize}; use tokio::task; @@ -17,11 +18,10 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx mod error; // TODO -// * sort images by date -// * Cache generated images (+ cache cleanup) +// * Polish the css // * Add configuration file // * Support for headlines -// * Polish the css +// * Cache generated images (+ cache cleanup) type TemplateEngine = Engine>; type ImageDir = PathBuf; @@ -87,6 +87,7 @@ async fn main() { pub struct ImageInfo { width: u32, height: u32, + created: DateTime, #[serde(with = "hex")] encrypted_name: Vec, #[serde(skip_serializing)] @@ -113,7 +114,7 @@ async fn index( // Encrypt image names let chacha_key = chacha20poly1305::Key::from_slice(&secret_key[0..32]); let chacha = XChaCha20Poly1305::new(chacha_key); - let images = images.into_iter().map(|mut image_info| { + let mut images = images.into_iter().map(|mut image_info| { let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); let mut ciphertext = chacha.encrypt(&nonce, image_info.name.as_bytes())?; @@ -123,7 +124,7 @@ async fn index( Ok(image_info) }).collect::>>()?; - let images = images.into_iter().rev().collect(); + images.sort_by_key(|entry| Reverse(entry.created)); Ok(RenderHtml("index", engine, IndexTempalte { title: "Some pictures".into(), @@ -231,7 +232,7 @@ fn read_image_size(mut file: impl BufRead+Seek, exif: Option<&Exif>) -> Result<( .into_dimensions()?; if let Some(exif) = exif { - if let Some(orientation) = exif.get_field(Tag::Orientation, In::PRIMARY) { + if let Some(orientation) = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { if let Some(5 | 6 | 7 | 8) = orientation.value.get_uint(0) { std::mem::swap(&mut width, &mut height); }; @@ -243,7 +244,7 @@ fn read_image_size(mut file: impl BufRead+Seek, exif: Option<&Exif>) -> Result<( // How many degrees to rotate clockwise, does not support flipped images fn fix_image_orientation(image: DynamicImage, exif: &Exif) -> DynamicImage { - match exif.get_field(Tag::Orientation, In::PRIMARY) { + match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { Some(orientation) => match orientation.value.get_uint(0) { Some(1) => image, @@ -284,34 +285,55 @@ fn read_images(directory: &Path) -> Result> { Ok(files) } +fn extract_exif_string(field: &exif::Field) -> Option { + match field.value { + exif::Value::Ascii(ref value) if !value.is_empty() => { + String::from_utf8(value[0].clone()).ok() + } + _ => None, + } +} + fn read_image_info(path: &Path) -> Result { let file = File::open(path)?; let mut file = BufReader::new(file); - let exif = read_exif_data(&mut file); + let exif = read_exif_data(&mut file).ok(); // Check if we need to flip the coordinates - let (width, height) = read_image_size(&mut file, exif.as_ref().ok()) + let (width, height) = read_image_size(&mut file, exif.as_ref()) .context("Could not read image size")?; - let datetime = 'datetime: { + let datetime: Option> = exif.and_then(|exif| { // First, try to read creation date from EXIF data - if let Ok(ref exif) = exif { - match exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) { - Some(Field { value: Value::Ascii(value), ..}) if !value.is_empty() => { - break 'datetime String::from_utf8_lossy(&value[0]).into_owned() - } - _ => {} - }; - } + let datetime_without_timezone = exif + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .and_then(extract_exif_string); + let timezone = exif + .get_field(exif::Tag::OffsetTimeOriginal, exif::In::PRIMARY) + .and_then(extract_exif_string); + match (datetime_without_timezone, timezone) { + (Some(datetime), Some(timezone)) => (datetime + &timezone).parse().ok(), + (Some(datetime), None) => (datetime + "Z").parse().ok(), + _ => None, + } + }).or_else(|| { // If that doesn't work, fall back to the file modification time - format!("{:?}", std::fs::metadata(path).unwrap().modified().unwrap().duration_since(SystemTime::UNIX_EPOCH).unwrap()) - }; + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(DateTime::from) + .ok() + }); + + if datetime.is_none() { + tracing::warn!("Could not determine original datetime for {:?}", path); + } Ok(ImageInfo { width, height, + created: datetime.unwrap_or_default(), name: path.file_name().expect("invalid file path").to_owned(), ..Default::default() })