fix(photos): fix SQL type mismatch, pagination panic, missing icons, and add day/month/year grouping
- Fix photos endpoint 500: remove ::uuid cast on user_id WHERE clause (VARCHAR column) - Fix pagination underflow panic when total_pages is 0 - Add missing 'images' and 'play' icons to SVG icon registry - Add day/month/year grouping toggle with localStorage persistence - Improve grid spacing and group header styling per mode - Add i18n translations for grouping labels (all 9 locales)
This commit is contained in:
+2
-1
@@ -24,7 +24,8 @@ use crate::common::config::AppConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::{
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, TrashDbRepository,
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository,
|
||||
TrashDbRepository,
|
||||
};
|
||||
use crate::infrastructure::services::file_content_cache::{
|
||||
FileContentCache, FileContentCacheConfig,
|
||||
|
||||
@@ -178,7 +178,7 @@ impl FileBlobReadRepository {
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
||||
WHERE fi.user_id = $1::uuid
|
||||
WHERE fi.user_id = $1
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
AND ($2::bigint IS NULL
|
||||
@@ -198,7 +198,9 @@ impl FileBlobReadRepository {
|
||||
let mut sort_dates = Vec::with_capacity(rows.len());
|
||||
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, uid, sd) in rows {
|
||||
files.push(Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)?);
|
||||
files.push(Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, uid,
|
||||
)?);
|
||||
sort_dates.push(sd);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,17 @@ impl FileMetadataRepository {
|
||||
})?;
|
||||
|
||||
Ok(row.map(
|
||||
|(file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height)| {
|
||||
|(
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
)| {
|
||||
StoredMetadata {
|
||||
file_id,
|
||||
captured_at,
|
||||
@@ -152,7 +162,18 @@ impl FileMetadataRepository {
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::with_capacity(rows.len());
|
||||
for (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) in rows {
|
||||
for (
|
||||
file_id,
|
||||
captured_at,
|
||||
latitude,
|
||||
longitude,
|
||||
camera_make,
|
||||
camera_model,
|
||||
orientation,
|
||||
width,
|
||||
height,
|
||||
) in rows
|
||||
{
|
||||
map.insert(
|
||||
file_id.clone(),
|
||||
StoredMetadata {
|
||||
|
||||
@@ -31,9 +31,9 @@ pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||
|
||||
@@ -61,13 +61,23 @@ impl ExifService {
|
||||
|
||||
// ── Camera info ──
|
||||
if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
|
||||
let val = field.display_value().to_string().trim_matches('"').trim().to_string();
|
||||
let val = field
|
||||
.display_value()
|
||||
.to_string()
|
||||
.trim_matches('"')
|
||||
.trim()
|
||||
.to_string();
|
||||
if !val.is_empty() {
|
||||
meta.camera_make = Some(val);
|
||||
}
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
|
||||
let val = field.display_value().to_string().trim_matches('"').trim().to_string();
|
||||
let val = field
|
||||
.display_value()
|
||||
.to_string()
|
||||
.trim_matches('"')
|
||||
.trim()
|
||||
.to_string();
|
||||
if !val.is_empty() {
|
||||
meta.camera_model = Some(val);
|
||||
}
|
||||
@@ -159,7 +169,7 @@ fn parse_u32_value(value: &exif::Value) -> Option<u32> {
|
||||
/// to match the intended orientation.
|
||||
pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage {
|
||||
match orientation {
|
||||
1 => img, // Normal
|
||||
1 => img, // Normal
|
||||
2 => image::DynamicImage::from(image::imageops::flip_horizontal(&img)), // Mirror horizontal
|
||||
3 => image::DynamicImage::from(image::imageops::rotate180(&img)), // Rotate 180°
|
||||
4 => image::DynamicImage::from(image::imageops::flip_vertical(&img)), // Mirror vertical
|
||||
@@ -168,13 +178,13 @@ pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::D
|
||||
let flipped = image::imageops::flip_horizontal(&img);
|
||||
image::DynamicImage::from(image::imageops::rotate270(&flipped))
|
||||
}
|
||||
6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW
|
||||
6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW
|
||||
7 => {
|
||||
// Transverse: flip horizontal then rotate 90°
|
||||
let flipped = image::imageops::flip_horizontal(&img);
|
||||
image::DynamicImage::from(image::imageops::rotate90(&flipped))
|
||||
}
|
||||
8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW
|
||||
8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,9 @@ impl ThumbnailService {
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
||||
use crate::infrastructure::services::exif_service::{
|
||||
ExifService, apply_orientation,
|
||||
};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
@@ -360,7 +362,9 @@ impl ThumbnailService {
|
||||
|
||||
// Apply EXIF orientation so thumbnails display correctly
|
||||
let img = {
|
||||
use crate::infrastructure::services::exif_service::{ExifService, apply_orientation};
|
||||
use crate::infrastructure::services::exif_service::{
|
||||
ExifService, apply_orientation,
|
||||
};
|
||||
let orientation = ExifService::extract(&data)
|
||||
.and_then(|m| m.orientation)
|
||||
.unwrap_or(1);
|
||||
|
||||
@@ -642,7 +642,11 @@ impl FileHandler {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read file for EXIF extraction {}: {}", file_id, e);
|
||||
tracing::warn!(
|
||||
"Failed to read file for EXIF extraction {}: {}",
|
||||
file_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ pub async fn list_photos(
|
||||
|
||||
let file_read = &state.repositories.file_read_repository;
|
||||
|
||||
match file_read.list_media_files(user_id, params.before, limit).await {
|
||||
match file_read
|
||||
.list_media_files(user_id, params.before, limit)
|
||||
.await
|
||||
{
|
||||
Ok((files, sort_dates)) => {
|
||||
info!("Photos: returned {} media files for user", files.len());
|
||||
|
||||
@@ -54,10 +57,9 @@ pub async fn list_photos(
|
||||
// Set cursor header for next page
|
||||
let mut response = Json(&dtos).into_response();
|
||||
if let Some(&last_sd) = sort_dates.last() {
|
||||
response.headers_mut().insert(
|
||||
"X-Next-Cursor",
|
||||
last_sd.to_string().parse().unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("X-Next-Cursor", last_sd.to_string().parse().unwrap());
|
||||
}
|
||||
|
||||
response
|
||||
|
||||
@@ -183,11 +183,7 @@ async fn handle_webdav_methods(
|
||||
/// If `path` doesn't already start with the user's home folder name, prepend
|
||||
/// the home folder path so downstream services can find the resource in the DB.
|
||||
/// Returns `None` when the path already includes the prefix or resolution fails.
|
||||
async fn resolve_webdav_path(
|
||||
state: &Arc<AppState>,
|
||||
user_id: &str,
|
||||
path: &str,
|
||||
) -> Option<String> {
|
||||
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: &str, path: &str) -> Option<String> {
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let home_folders = folder_service
|
||||
.list_folders_for_owner(None, user_id)
|
||||
@@ -213,10 +209,7 @@ async fn handle_webdav_dispatch(
|
||||
// prefix when the path doesn't already include it.
|
||||
// Extract user_id before any async call to keep the future Send.
|
||||
let path = if !path.is_empty() && method.as_str() != "OPTIONS" {
|
||||
let user_id = req
|
||||
.extensions()
|
||||
.get::<CurrentUser>()
|
||||
.map(|u| u.id.clone());
|
||||
let user_id = req.extensions().get::<CurrentUser>().map(|u| u.id.clone());
|
||||
if let Some(uid) = user_id {
|
||||
resolve_webdav_path(&state, &uid, &path)
|
||||
.await
|
||||
|
||||
@@ -265,6 +265,7 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||
size_formatted: format_file_size(fr.size),
|
||||
owner_id: None,
|
||||
sort_date: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user