Files
lightcraft/crates/catalog/src/query.rs
T
NetterSilverandClaude Haiku 5.5 f48b2ad895 Search words with an unknown field prefix as plain text (#2099)
Found while testing crates/catalog/src/query.rs:146-177 (token_matches)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:39:08 -06:00

694 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filtering, search and sorting.
use std::cmp::Ordering::Equal;
use serde::{Deserialize, Serialize};
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RatingOp {
#[default]
AtLeast,
Exactly,
AtMost,
}
/// What the grid shows. Empty/None fields don't filter.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Filter {
/// Free-text search: every token must match filename, title, caption, keywords, camera, lens,
/// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded.
pub text: String,
pub rating: u8,
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
/// Only these photos (Find Similar results…); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub only: Vec<PhotoId>,
/// Any of these labels (the filter bar's multi-select); empty = no constraint.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
/// Show "Recently Deleted" instead of the library.
pub deleted: bool,
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
pub date: Option<String>,
/// A folder the library's photos were imported from: those photos and the ones imported from
/// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part
/// of it, unlike [`Filter::folder`].
pub library_folder: Option<String>,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option<String>,
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
pub person: Option<String>,
pub camera: Option<String>,
/// Lens (case-insensitive substring).
pub lens: Option<String>,
/// Capture date range, inclusive: `dateFrom` is compared as a lower bound (`2026-04-01`),
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix.
pub imported: Option<String>,
/// Imported at or after this time (ISO; Recently Added).
pub imported_from: Option<String>,
/// Photo Merge results: `hdr`, `panorama`, `hdrPanorama` or `any` (see [`merged_kind`]).
pub merged: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_set: Option<crate::RuleSet>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SortKey {
#[default]
CaptureDate,
ImportDate,
EditDate,
FileName,
Rating,
FileSize,
/// A shuffle fixed by [`Sort::seed`]: the same seed always gives the same order, and photos
/// added or edited later never reshuffle the others. Pick a new seed to reshuffle.
Random,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sort {
pub key: SortKey,
pub ascending: bool,
/// Date headers in the grid (date sort keys only).
pub group: crate::GroupBy,
/// Which shuffle [`SortKey::Random`] gives (ignored by the other keys).
pub seed: u64,
}
impl Sort {
/// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last).
pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering {
let o = match self.key {
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => cmp_name(&a.file_name, &b.file_name),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)),
}
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
// order cameras number frames, so the order doesn't depend on how ids were allocated;
// the id only keeps it stable.
.then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
.then(a.id.cmp(&b.id));
if self.ascending { o } else { o.reverse() }
}
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
}
}
/// splitmix64 finaliser: a stateless, platform-independent mix (no RNG state, no `rand` version
/// to drift), so a seed names the same shuffle on every machine.
pub fn mix64(x: u64) -> u64 {
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A photo's place in the shuffle for `seed`.
fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
mix64(mix64(seed) ^ id.0)
}
/// What [`Filter::prepare`] works out once for a pass over many photos.
pub(crate) struct Prepared {
root: Option<String>,
only: Option<std::collections::HashSet<PhotoId>>,
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
_ => ('=', s),
};
rest.parse().ok().map(|v| (op, v))
};
let cmp = |x: f64, s: &str| match num(s) {
Some(('>', v)) => x > v,
Some(('<', v)) => x < v,
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
"person" | "who" => has_person(p, val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
_ => text_matches(p, &t),
};
}
text_matches(p, &t)
}
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
/// format or a keyword of `p`.
fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
p.people().iter().any(|n| n.to_lowercase() == name)
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
self.describe_in(None)
}
/// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`).
pub fn describe_with(&self, cat: &crate::Catalog) -> String {
self.describe_in(Some(cat))
}
fn describe_in(&self, cat: Option<&crate::Catalog>) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
v.push(format!("rating {op} {}", self.rating));
}
if let Some(f) = self.flag {
v.push(format!("flag {}", format!("{f:?}").to_lowercase()));
}
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if !self.labels.is_empty() {
v.push(format!("label {}", self.labels.iter().map(|l| format!("{l:?}").to_lowercase()).collect::<Vec<_>>().join(" or ")));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
if let Some(e) = self.edited {
v.push(if e { "edited".into() } else { "unedited".into() });
}
for (name, val) in [
("keyword", &self.keyword),
("person", &self.person),
("camera", &self.camera),
("lens", &self.lens),
("date", &self.date),
("folder", &self.library_folder),
("imported", &self.imported),
] {
if let Some(x) = val {
v.push(format!("{name} {x}"));
}
}
match (&self.date_from, &self.date_to) {
(Some(a), Some(b)) => v.push(format!("captured {a} – {b}")),
(Some(a), None) => v.push(format!("captured from {a}")),
(None, Some(b)) => v.push(format!("captured until {b}")),
_ => {}
}
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c)));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
/// The albums this filter tests: its album field and its rules' Album rules.
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
let mut out: Vec<crate::AlbumId> = self.album.into_iter().collect();
out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default());
out
}
/// Whether matches depend on the clock (only "in the last…" rules do).
pub fn depends_on_now(&self) -> bool {
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
self.matches_in(p, cat, &self.prepare_one())
}
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
pub(crate) fn prepare_one(&self) -> Prepared {
Prepared { root: self.library_root(), only: None }
}
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
/// once per photo: the library folder's identity and the `only` ids as a set.
pub(crate) fn prepare(&self) -> Prepared {
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
}
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
fn library_root(&self) -> Option<String> {
// as given: a folder may be named with spaces at its edge, and trimming would make it
// another folder
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
}
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
/// big library does that once, not once per photo.
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
if p.deleted != self.deleted {
return false;
}
match &self.folder {
// browsed photos only show in folder views, and in Recently Deleted once deleted (#1430)
None if p.local && !p.deleted => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
if !in_folder(path, dir, self.subfolders) {
return false;
}
}
}
if self.rating > 0 {
let ok = match self.rating_op {
RatingOp::AtLeast => p.rating >= self.rating,
RatingOp::Exactly => p.rating == self.rating,
RatingOp::AtMost => p.rating <= self.rating,
};
if !ok {
return false;
}
}
if self.flag.is_some_and(|f| f != p.flag) || self.label.is_some_and(|l| Some(l) != p.label) || self.kind.is_some_and(|k| k != p.kind) {
return false;
}
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(rs) = &self.rule_set
&& !rs.matches(p, cat)
{
return false;
}
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
return false;
}
if let Some(want) = &self.merged {
match merged_kind(&p.file_name) {
Some(k) if want == "any" || want == k => {}
_ => return false,
}
}
if let Some(a) = self.album
&& !cat.album_contains(a, p)
{
return false;
}
if let Some(from) = &self.date_from
&& match p.captured.as_deref() {
Some(c) => c < from.as_str(),
None => true,
}
{
return false;
}
if let Some(to) = &self.date_to
&& match p.captured.as_deref() {
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
None => true,
}
{
return false;
}
if let Some(l) = &self.lens
&& !p.meta.lens.to_lowercase().contains(&l.to_lowercase())
{
return false;
}
if let Some(d) = &self.date
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
{
return false;
}
if let Some(root) = prepared.root.as_deref()
&& !photo_in_root(p, root)
{
return false;
}
if let Some(d) = &self.imported
&& !p.imported.starts_with(d.as_str())
{
return false;
}
if let Some(d) = &self.imported_from
&& p.imported.as_str() < d.as_str()
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
return false;
}
if let Some(n) = &self.person
&& !has_person(p, n)
{
return false;
}
if let Some(c) = &self.camera
&& !p.meta.camera.eq_ignore_ascii_case(c)
{
return false;
}
self.text.split_whitespace().all(|tok| token_matches(p, tok))
}
}
/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`).
pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
}
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter)
}
/// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort`
/// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
/// the album, the folder; not deleted).
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
let prepared = filter.prepare();
// `within` only decides for the few kept photos: worked out per photo, not hashed
let within_prepared = within.prepare_one();
let mut v: Vec<&Photo> = self
.photos()
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
.collect();
v.sort_by(|a, b| sort.order(a, b));
v.into_iter().map(|p| p.id).collect()
}
/// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops
/// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos
/// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a
/// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What
/// the query would give, without asking every photo again.
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
for id in touched {
let Some(p) = self.photo(*id) else { continue };
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
if filter.matches_in(p, self, &prepared) || kept {
// (the list is in order without the touched photos, and stays so as each is put back)
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
ids.insert(at, *id);
}
}
ids
}
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
pub fn count(&self, filter: &Filter) -> usize {
let prepared = filter.prepare();
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
}
/// Year → month → day counts for the "By Date" section (newest first).
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| p.in_library()) {
let Some(d) = p.captured.as_deref() else { continue };
// `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's
// captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index
// would panic — the same rule as `dates::group_label`.
if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) {
let (months, days) = years.entry(year).or_default();
*months.entry(month).or_default() += 1;
*days.entry(day).or_default() += 1;
}
}
let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>();
years
.into_iter()
.rev()
.map(|(year, (months, days))| DateGroup {
year: year.to_string(),
count: months.values().sum(),
months: newest_first(months),
days: newest_first(days),
})
.collect()
}
/// All keywords with usage counts, sorted by name.
pub fn keywords(&self) -> Vec<(String, usize)> {
let mut m: std::collections::BTreeMap<String, usize> = Default::default();
for p in self.photos().filter(|p| p.in_library()) {
for k in &p.meta.keywords {
*m.entry(k.clone()).or_default() += 1;
}
}
m.into_iter().collect()
}
/// The people named on faces in the library (MWG regions read from XMP): how many photos each
/// appears in and the photo showing their largest face (for a card's picture). Most photos first,
/// then by name. Names that differ only in case are one person, shown as first seen; a person
/// twice in one photo counts once.
pub fn people(&self) -> Vec<Person> {
self.people_in(&Filter::default())
}
/// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the
/// People view offers while other filters (a date, a rating, an album…) are active, so picking
/// a person never ends in an empty grid.
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
let prepared = filter.prepare();
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
let mut seen: Vec<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = name.to_lowercase();
// the face's size in pixels of the photo
let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64;
let (person, best) =
m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area));
if !seen.contains(&key) {
person.count += 1;
seen.push(key);
}
if area > *best || (area == *best && p.id < person.photo) {
(person.photo, person.face, *best) = (p.id, r.rect, area);
}
}
}
let mut v: Vec<Person> = m.into_values().map(|(p, _)| p).collect();
v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
v
}
}
/// A person named on faces in the library ([`Catalog::people`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Person {
pub name: String,
/// Photos they appear in.
pub count: usize,
/// The photo with their largest face, and that face (normalized, in the photo's upright frame).
pub photo: PhotoId,
pub face: lightcraft_meta::Rect,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DateGroup {
pub year: String,
pub count: usize,
/// `YYYY-MM` and counts, newest first.
pub months: Vec<(String, usize)>,
/// `YYYY-MM-DD` and counts, newest first.
pub days: Vec<(String, usize)>,
}
/// Whether file `path` is directly in `dir` (or anywhere below it with `deep`). Both `/` and `\\`
/// separate.
pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let dir = dir.trim_end_matches(['/', '\\']);
let Some(rest) = path.strip_prefix(dir) else { return false };
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`,
/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute.
pub(crate) struct SplitPath {
pub prefix: String,
pub parts: Vec<String>,
pub absolute: bool,
}
/// Both separators, repeated separators (also between a server and its share), `.` and a
/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed;
/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is).
pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
Some(unc) => format!("//{unc}"),
None => rest.to_string(),
};
}
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
let mut segs = unc.split('/').filter(|x| !x.is_empty());
let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or(""));
(format!("//{server}/{share}"), segs.collect::<Vec<_>>().join("/"))
} else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) {
(if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
let mut parts: Vec<String> = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|p| p != "..") => {
parts.pop();
}
".." if absolute => {}
c => parts.push(c.to_string()),
}
}
SplitPath { prefix, parts, absolute }
}
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere
/// (macOS, a Samba share) two spellings that differ in case are two folders here.
/// No file-system access: the paths compared should already be absolute.
pub fn folder_key(path: &str) -> String {
let SplitPath { prefix, parts, absolute } = split_path(path, true);
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
key_within(&folder_key(path), &folder_key(root))
}
/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
/// the names keep their case, which the key lowers on Windows.
pub fn folder_rest(path: &str, root: &str) -> Option<Vec<String>> {
if !key_within(&folder_key(path), &folder_key(root)) {
return None;
}
let depth = split_path(root, false).parts.len();
Some(split_path(path, false).parts.into_iter().skip(depth).collect())
}
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
/// separators) is its own identity, so it is read without building a key for it: the check runs
/// for every photo of the library on each refresh, and this keeps it cheap.
pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool {
if p.local {
return false;
}
let crate::Source::File { path } = &p.source else { return false };
let plain = path.starts_with('/')
&& !path.starts_with("//")
&& !path.ends_with('/')
&& !path.contains(['\\'])
&& !path.contains("//")
&& !path.contains("/.");
if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") {
let r = root.trim_end_matches('/');
return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'));
}
crate::local::folder_of(p).is_some_and(|f| key_within(&f, root))
}
/// [`folder_within`] for two paths already turned into their [`folder_key`].
pub(crate) fn key_within(p: &str, r: &str) -> bool {
// `.` or `a/..` name no folder: not "everything"
if r.is_empty() {
return false;
}
// a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder
let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/'));
p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.
pub fn merged_kind(file_name: &str) -> Option<&'static str> {
let stem = file_name.rsplit_once('.').map_or(file_name, |(s, _)| s).to_ascii_lowercase();
let stem = stem.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-' || c == ' ');
if stem.ends_with("-hdr-pano") {
Some("hdrPanorama")
} else if stem.ends_with("-hdr") {
Some("hdr")
} else if stem.ends_with("-pano") {
Some("panorama")
} else {
None
}
}