Before, if the target file was gone, files_around just returned an empty list of files, leading Loupe to think there are no images and showing the empty state view. This was frequently triggered by the "Replace original" function in editing, since the tmp file was already gone, when the view moved to it. Now, the correct error is shown, and scrolling to the other files is possible. Issue #585
539 lines
18 KiB
Rust
539 lines
18 KiB
Rust
// Copyright (c) 2022-2026 Sophie Herold
|
||
// Copyright (c) 2025 Alexandros Kosiaris
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||
//
|
||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
//! Metadata handling
|
||
|
||
mod file;
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::path::PathBuf;
|
||
use std::str::FromStr;
|
||
|
||
use chrono::prelude::*;
|
||
pub use file::FileInfo;
|
||
use glib::TimeZone;
|
||
use glycin::{Frame, FrameDetails, ImageDetails, MemoryFormat};
|
||
use gufo::common::datetime::DateTime;
|
||
use gufo::common::orientation::Orientation;
|
||
use gufo::common::{cicp, physical_dimension};
|
||
|
||
use crate::deps::*;
|
||
use crate::util;
|
||
use crate::util::gettext::*;
|
||
|
||
#[derive(Default, Debug)]
|
||
pub struct Metadata {
|
||
mime_type: Option<String>,
|
||
metadata: gufo::Metadata,
|
||
file_info: Option<FileInfo>,
|
||
image_info: Option<glycin::ImageDetails>,
|
||
frame_info: Option<FrameDetails>,
|
||
memory_format: Option<MemoryFormat>,
|
||
icc_profile: Option<moxcms::ColorProfile>,
|
||
cicp: Option<cicp::Cicp>,
|
||
}
|
||
|
||
impl Metadata {
|
||
fn set_exif_bytes(&mut self, bytes: Vec<u8>) {
|
||
let result = self.metadata.add_raw_exif(bytes);
|
||
|
||
if let Err(err) = &result {
|
||
tracing::warn!("Failed to decode EXIF bytes: {err}");
|
||
}
|
||
}
|
||
|
||
fn set_xmp_bytes(&mut self, bytes: Vec<u8>) {
|
||
let result = self.metadata.add_raw_xmp(bytes);
|
||
|
||
if let Err(err) = &result {
|
||
tracing::warn!("Failed to decode XMP bytes: {err}");
|
||
}
|
||
}
|
||
|
||
fn set_key_value(&mut self, key_value: BTreeMap<String, String>) {
|
||
let result = self.metadata.add_key_value(key_value);
|
||
|
||
if let Err(err) = &result {
|
||
tracing::warn!("Failed to add key-value data: {err}");
|
||
}
|
||
}
|
||
|
||
pub fn set_image_info(&mut self, image_info: ImageDetails) {
|
||
if let Some(exif_raw) = image_info.metadata_exif() {
|
||
self.set_exif_bytes(exif_raw.to_owned());
|
||
}
|
||
if let Some(xmp_raw) = image_info.metadata_xmp() {
|
||
self.set_xmp_bytes(xmp_raw.to_owned());
|
||
}
|
||
if let Some(key_value) = image_info.metadata_key_value() {
|
||
self.set_key_value(key_value.to_owned());
|
||
}
|
||
self.image_info = Some(image_info);
|
||
}
|
||
|
||
pub fn set_frame_metadata(&mut self, frame: &Frame) {
|
||
self.frame_info = Some(frame.details().clone());
|
||
self.memory_format = Some(frame.memory_format());
|
||
if let Some(iccp) = frame.details().color_icc_profile() {
|
||
match moxcms::ColorProfile::new_from_slice(iccp) {
|
||
Err(err) => {
|
||
tracing::warn!("Couldn't parse color profile: {err}");
|
||
}
|
||
Ok(profile) => {
|
||
self.icc_profile = Some(profile);
|
||
}
|
||
}
|
||
}
|
||
self.cicp = frame.details().color_cicp();
|
||
}
|
||
|
||
pub fn set_file_info(&mut self, file_info: FileInfo) {
|
||
self.file_info = Some(file_info);
|
||
}
|
||
|
||
pub fn set_mime_type(&mut self, mime_type: String) {
|
||
self.mime_type = Some(mime_type);
|
||
}
|
||
|
||
pub fn merge(&mut self, other: Self) {
|
||
if self.metadata.is_empty() {
|
||
self.metadata = other.metadata;
|
||
}
|
||
if self.file_info.is_none() {
|
||
self.file_info = other.file_info;
|
||
}
|
||
if self.image_info.is_none() {
|
||
self.image_info = other.image_info;
|
||
}
|
||
if self.mime_type.is_none() {
|
||
self.mime_type = other.mime_type;
|
||
}
|
||
if self.frame_info.is_none() {
|
||
self.frame_info = other.frame_info;
|
||
}
|
||
if self.memory_format.is_none() {
|
||
self.memory_format = other.memory_format;
|
||
}
|
||
if self.icc_profile.is_none() {
|
||
self.icc_profile = other.icc_profile;
|
||
}
|
||
if self.cicp.is_none() {
|
||
self.cicp = other.cicp;
|
||
}
|
||
}
|
||
|
||
pub fn mime_type(&self) -> Option<String> {
|
||
self.mime_type.clone()
|
||
}
|
||
|
||
pub fn format_name(&self) -> Option<String> {
|
||
self.image_info
|
||
.as_ref()
|
||
.and_then(|x| x.info_format_name().map(|x| x.to_string()))
|
||
.or_else(|| self.mime_type())
|
||
}
|
||
|
||
pub fn alpha_channel(&self) -> Option<bool> {
|
||
self.frame_info
|
||
.as_ref()
|
||
.and_then(|x| x.info_alpha_channel())
|
||
}
|
||
|
||
pub fn grayscale(&self) -> Option<bool> {
|
||
self.frame_info.as_ref().and_then(|x| x.info_grayscale())
|
||
}
|
||
|
||
pub fn bit_depth(&self) -> Option<u8> {
|
||
self.frame_info.as_ref().and_then(|x| x.info_bit_depth())
|
||
}
|
||
|
||
pub fn is_svg(&self) -> bool {
|
||
matches!(
|
||
self.mime_type.as_deref(),
|
||
Some("image/svg+xml") | Some("image/svg+xml-compressed")
|
||
)
|
||
}
|
||
|
||
pub fn is_potentially_transparent(&self) -> bool {
|
||
self.memory_format.is_some_and(|x| x.has_alpha())
|
||
}
|
||
|
||
pub fn transformations_applied(&self) -> bool {
|
||
self.image_info
|
||
.as_ref()
|
||
.map(|x| x.transformation_ignore_exif())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub fn orientation(&self) -> Orientation {
|
||
if self.transformations_applied() {
|
||
// HEIF library already does it's transformations on its own
|
||
Orientation::Id
|
||
} else {
|
||
self.metadata.orientation().unwrap_or(Orientation::Id)
|
||
}
|
||
}
|
||
|
||
pub fn file_name(&self) -> Option<String> {
|
||
self.file_info.as_ref().map(|x| x.display_name.to_string())
|
||
}
|
||
|
||
/// Original host path inside flatpaks obtained via xattr
|
||
pub fn host_path(&self) -> Option<PathBuf> {
|
||
self.file_info
|
||
.as_ref()
|
||
.and_then(|x| x.host_path.as_ref())
|
||
.and_then(|x| PathBuf::from_str(x.as_str()).ok())
|
||
}
|
||
|
||
pub fn file_size(&self) -> Option<String> {
|
||
self.file_info
|
||
.as_ref()
|
||
.and_then(|x| x.file_size)
|
||
.map(|x| glib::format_size(x).to_string())
|
||
}
|
||
|
||
pub fn file_created(&self) -> Option<String> {
|
||
self.file_info
|
||
.as_ref()
|
||
.and_then(|x| x.created.as_ref())
|
||
.and_then(util::datetime_fmt)
|
||
}
|
||
|
||
pub fn file_modified(&self) -> Option<String> {
|
||
self.file_info
|
||
.as_ref()
|
||
.and_then(|x| x.modified.as_ref())
|
||
.and_then(util::datetime_fmt)
|
||
}
|
||
|
||
pub fn originally_created(&self) -> Option<String> {
|
||
if let Some(date_time) = &self.metadata.date_time_original() {
|
||
let glib_date_time = match date_time {
|
||
DateTime::Naive(naive) => glib::DateTime::from_local(
|
||
naive.year(),
|
||
naive.month() as i32,
|
||
naive.day() as i32,
|
||
naive.hour() as i32,
|
||
naive.minute() as i32,
|
||
naive.second() as f64,
|
||
)
|
||
.ok(),
|
||
DateTime::FixedOffset(fixed) => {
|
||
let naive = fixed.naive_utc();
|
||
glib::DateTime::new(
|
||
&TimeZone::from_offset(fixed.timezone().local_minus_utc() * 60),
|
||
naive.year(),
|
||
naive.month() as i32,
|
||
naive.day() as i32,
|
||
naive.hour() as i32,
|
||
naive.minute() as i32,
|
||
naive.second() as f64,
|
||
)
|
||
.ok()
|
||
}
|
||
};
|
||
|
||
if let Some(glib_date_time) = glib_date_time {
|
||
util::datetime_fmt(&glib_date_time)
|
||
} else {
|
||
Some(date_time.to_string())
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn user_comment(&self) -> Option<String> {
|
||
self.metadata.user_comment()
|
||
}
|
||
|
||
pub fn f_number(&self) -> Option<String> {
|
||
if let Some(f_number) = self.metadata.f_number() {
|
||
return Some(
|
||
// Translators: Display of the f-number <https://en.wikipedia.org/wiki/F-number>. {} will be replaced with the number
|
||
gettext_f(r"\u{192}\u{2215}{}", [f_number.to_string()]),
|
||
);
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
pub fn exposure_time(&self) -> Option<String> {
|
||
if let Some(exposure_time) = self.metadata.exposure_time() {
|
||
let speed = exposure_time.as_f64();
|
||
if speed <= 0.5 {
|
||
let exposure = format!("{:.0}", 1. / speed);
|
||
// Translators: Fractional exposure time (photography) in seconds
|
||
Some(gettext_f(r"1\u{2215}{}\u{202F}s", [exposure]))
|
||
} else {
|
||
let exposure = if speed < 5. {
|
||
format!("{:.1}", speed)
|
||
} else {
|
||
format!("{:.0}", speed)
|
||
};
|
||
// Translators: Exposure time (photography) in seconds
|
||
Some(gettext_f(r"{}\u{202F}s", [exposure]))
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn iso(&self) -> Option<String> {
|
||
self.metadata.iso_speed_rating().map(|iso| iso.to_string())
|
||
}
|
||
|
||
pub fn focal_length(&self) -> Option<String> {
|
||
self.metadata
|
||
.focal_length()
|
||
.map(|focal_length| gettext_f(r"{}\u{202F}mm", [focal_length.as_f32().to_string()]))
|
||
}
|
||
|
||
/// Combined maker and model info
|
||
pub fn maker_model(&self) -> Option<String> {
|
||
if let Some(mut model) = self.model() {
|
||
if let Some(maker) = self.maker() {
|
||
// This is to avoid doubling the maker name
|
||
// Canon for example also puts "Canon" in the model as well
|
||
// NIKON sometimes puts "NIKON" in model and "NIKON CORPORATION" in maker
|
||
if model.split_whitespace().next() != maker.split_whitespace().next() {
|
||
model = format!("{maker} {model}");
|
||
}
|
||
}
|
||
|
||
Some(model)
|
||
} else {
|
||
self.maker()
|
||
}
|
||
}
|
||
|
||
pub fn model(&self) -> Option<String> {
|
||
self.metadata.model()
|
||
}
|
||
|
||
pub fn maker(&self) -> Option<String> {
|
||
self.metadata.make()
|
||
}
|
||
|
||
pub fn camera_owner_name(&self) -> Option<String> {
|
||
self.metadata.camera_owner_name()
|
||
}
|
||
|
||
pub fn creator(&self) -> Option<String> {
|
||
self.metadata.creator()
|
||
}
|
||
|
||
pub fn lens(&self) -> Option<String> {
|
||
if let Some(lens) = self.metadata.lens_specification()
|
||
&& lens.min_f_number_max_focal_length.numerator > 0
|
||
&& lens.min_f_number_max_focal_length.denominator > 0
|
||
{
|
||
let mut s = if lens.min_focal_length == lens.max_focal_length {
|
||
format!("{}\u{202F}mm", lens.min_focal_length.as_f32())
|
||
} else {
|
||
format!(
|
||
"{}–{}\u{202F}mm",
|
||
lens.min_focal_length.as_f32(),
|
||
lens.max_focal_length.as_f32()
|
||
)
|
||
};
|
||
|
||
if lens.min_f_number_min_focal_length == lens.min_f_number_max_focal_length {
|
||
s.push_str(&format!(
|
||
" \u{192}\u{2215}{}",
|
||
lens.min_f_number_min_focal_length.as_f32()
|
||
));
|
||
} else {
|
||
s.push_str(&format!(
|
||
" \u{192}\u{2215}{}–{}",
|
||
lens.min_f_number_min_focal_length.as_f32(),
|
||
lens.min_f_number_max_focal_length.as_f32()
|
||
));
|
||
}
|
||
|
||
Some(s)
|
||
} else {
|
||
self.metadata.lens_model()
|
||
}
|
||
}
|
||
|
||
pub fn rights(&self) -> Option<String> {
|
||
self.metadata.rights()
|
||
}
|
||
|
||
pub fn software(&self) -> Option<String> {
|
||
self.metadata.software()
|
||
}
|
||
|
||
pub fn gps_location(&self) -> Option<gufo::common::geography::Location> {
|
||
self.metadata.gps_location()
|
||
}
|
||
|
||
pub fn gps_location_display(&self) -> Option<String> {
|
||
if let Some(location) = self.gps_location() {
|
||
let (lat_ref, (lat_deg, lat_min, lat_sec)) = location.lat_ref_deg_min_sec();
|
||
|
||
let lat_ref = match lat_ref {
|
||
gufo::common::geography::LatRef::North => {
|
||
// Translators: short for "north" in GPS coordinate
|
||
gettext("N")
|
||
}
|
||
gufo::common::geography::LatRef::South => {
|
||
// Translators: short for "south" in GPS coordinate
|
||
gettext("S")
|
||
}
|
||
};
|
||
|
||
let (lon_ref, (lon_deg, lon_min, lon_sec)) = location.lon_ref_deg_min_sec();
|
||
|
||
let lon_ref = match lon_ref {
|
||
gufo::common::geography::LonRef::East => {
|
||
// Translators: short for "east" in GPS coordinate
|
||
gettext("E")
|
||
}
|
||
gufo::common::geography::LonRef::West => {
|
||
// Translators: short for "west" in GPS coordinate
|
||
gettext("W")
|
||
}
|
||
};
|
||
|
||
let lat = format!("{lat_deg}° {lat_min}′ {lat_sec:05.2}″ {lat_ref}");
|
||
let lon = format!("{lon_deg}° {lon_min}′ {lon_sec:05.2}″ {lon_ref}");
|
||
Some(format!("{lat}\n{lon}"))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Outputs location as "City, County" if possible
|
||
///
|
||
/// Falls back to coordinates
|
||
pub fn gps_location_place(&self) -> Option<String> {
|
||
if let Some(location) = self.gps_location() {
|
||
if let Some(world) = libgweather::Location::world() {
|
||
let lat = location.lat.0;
|
||
let lon = location.lon.0;
|
||
let nearest_city = world.find_nearest_city(lat, lon);
|
||
let location_exact = libgweather::Location::new_detached("", None, lat, lon);
|
||
|
||
// do not use city if more than 15 km away
|
||
if nearest_city.distance(&location_exact) < 15.
|
||
&& let (Some(city), Some(country)) =
|
||
(nearest_city.city_name(), nearest_city.country_name())
|
||
{
|
||
return Some(format!("{city}, {country}"));
|
||
}
|
||
}
|
||
|
||
self.gps_location_display()
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn pixel_density(&self) -> Option<physical_dimension::PixelDensity> {
|
||
self.frame_info.as_ref().and_then(|x| x.pixel_density())
|
||
}
|
||
|
||
pub fn physical_size(&self) -> Option<physical_dimension::PhysicalSize> {
|
||
self.frame_info.as_ref().and_then(|x| x.physical_size())
|
||
}
|
||
|
||
pub fn icc_profile_description(&self) -> Option<String> {
|
||
if self.prefer_cicp() && self.cicp.is_some() {
|
||
return None;
|
||
}
|
||
|
||
if let Some(icc_profile) = &self.icc_profile {
|
||
icc_profile.description.as_ref().and_then(Self::icc_string)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn icc_string(text: &moxcms::ProfileText) -> Option<String> {
|
||
let s = match text {
|
||
moxcms::ProfileText::Description(desc) => Some(desc.unicode_string.clone()),
|
||
moxcms::ProfileText::Localizable(local) => local.first().map(|x| x.value.clone()),
|
||
moxcms::ProfileText::PlainString(s) => Some(s.to_owned()),
|
||
};
|
||
|
||
if let Some(s) = s {
|
||
let s = s.trim().replace('\0', "");
|
||
if s.is_empty() { None } else { Some(s) }
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn cicp_description(&self) -> Option<String> {
|
||
if !self.prefer_cicp() && self.icc_profile.is_some() {
|
||
return None;
|
||
}
|
||
|
||
if let Some(cicp) = self.cicp {
|
||
let primaries = match cicp.color_primaries {
|
||
cicp::ColorPrimaries::DciP3 => gettext("DCI P3"),
|
||
cicp::ColorPrimaries::DisplayP3 => gettext("Display P3"),
|
||
cicp::ColorPrimaries::Rec2020 => gettext("Rec.2020"),
|
||
cicp::ColorPrimaries::Srgb => gettext("sRGB"),
|
||
cicp::ColorPrimaries::Unspecified => gettext("Unspecified"),
|
||
cicp::ColorPrimaries::Unknown(n) => gettext_f("Unknown ({})", [n.to_string()]),
|
||
_ => String::new(),
|
||
};
|
||
|
||
let coefficients = match cicp.matrix_coefficients {
|
||
cicp::MatrixCoefficients::Identity => None,
|
||
cicp::MatrixCoefficients::ICtCp => Some(gettext("ICtCp")),
|
||
cicp::MatrixCoefficients::Unspecified => Some(gettext("Unspecified")),
|
||
cicp::MatrixCoefficients::Unknown(n) => {
|
||
Some(gettext_f("Unknown ({})", [n.to_string()]))
|
||
}
|
||
_ => None,
|
||
};
|
||
|
||
let transfer = match cicp.transfer_characteristics {
|
||
cicp::TransferCharacteristics::Dci => gettext("DCI"),
|
||
cicp::TransferCharacteristics::Pq => gettext("PQ"),
|
||
cicp::TransferCharacteristics::Srgb => gettext("Gamma 2.4"),
|
||
cicp::TransferCharacteristics::Unknown(n) => {
|
||
gettext_f("Unknown ({})", [n.to_string()])
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
|
||
if let Some(coefficients) = coefficients {
|
||
Some(format!("{primaries}, {transfer}, {coefficients}"))
|
||
} else {
|
||
Some(format!("{primaries}, {transfer}"))
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn prefer_cicp(&self) -> bool {
|
||
!matches!(
|
||
self.frame_info
|
||
.as_ref()
|
||
.map(|x| x.color_profile_preference()),
|
||
Some(glycin::ColorProfilePreference::IccProfile)
|
||
)
|
||
}
|
||
}
|