"""The Browse page's filter and sort. Filter: whitespace-separated conditions that must all match, e.g. *Beach* name:*Beach* ext:png type:image size:>=5MB modified:>=2026-07-01 Colour:Orange tag.Rating:>=2 Name:!=? Text compares case-insensitively with shell wildcards (* ? [..]); numbers, sizes and dates also take > >= < <= != (= is the default). Sort: a chain of criteria (Name, Modified, Accessed, Created, FileType, FileSize, Record, Tag:), the first one deciding most. Files without a value for a criterion always go last. Directories come before files. """ import os import re from dataclasses import dataclass, field from datetime import datetime from fnmatch import fnmatchcase from functools import cmp_to_key from pathlib import Path from typing import Any from Books import Book from Library import FormatValue, TagType, Unset ImageExtensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"} VideoExtensions = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v", ".wmv", ".flv", ".mpg", ".mpeg"} Operators = (">=", "<=", "!=", ">", "<", "=") SortNames = { "Name": "Name", "Modified": "Modified", "Accessed": "Accessed", "Created": "Created (changed)", "FileType": "File type", "FileSize": "File size", "Record": "Record", } @dataclass class Entry: Path: Path IsDirectory: bool Stat: os.stat_result | None = None Book: Book | None = None BookError: str = "" RecordName: str = "" # which Record it's from, when browsing a Library Extra: dict[str, Any] = field(default_factory=dict) @property def Kind(self) -> str: if self.IsDirectory: return "directory" Suffix = self.Path.suffix.lower() return "image" if Suffix in ImageExtensions else "video" if Suffix in VideoExtensions else "file" def TagValue(self, TagName: str) -> Any: """The value, matched case-insensitively, or None if unset.""" if self.Book is None: return None Wanted = TagName.casefold() for Key, Value in self.Book.Tags.items(): if Key.casefold() == Wanted: return None if Value == Unset else Value return None def TagDefinition(self, TagName: str) -> dict[str, Any]: if self.Book is None: return {} Wanted = TagName.casefold() return next((Definition for Key, Definition in self.Book.Definitions.items() if Key.casefold() == Wanted), {}) def NaturalKey(Text: str) -> tuple: return tuple((0, int(Part)) if Part.isdigit() else (1, Part) for Part in re.split(r"(\d+)", Text.casefold()) if Part) # Filter def ParseFilter(Text: str) -> list[tuple[str, str, str]]: """[(field, operator, value)]. Raises ValueError with a readable message.""" Conditions = [] for Token in Text.split(): FieldName, RawValue = Token.split(":", 1) if ":" in Token else ("name", Token) if not FieldName: raise ValueError(f"'{Token}' is missing a field name before the colon") Operator = next((Candidate for Candidate in Operators if RawValue.startswith(Candidate)), "=") Value = RawValue[len(Operator):] if RawValue.startswith(Operator) else RawValue if not Value: raise ValueError(f"'{Token}' needs a value") Conditions.append((FieldName.strip(), Operator, Value.strip())) return Conditions def _CompareText(Actual: str, Operator: str, Expected: str) -> bool: Actual, Expected = Actual.casefold(), Expected.casefold() if Operator in ("=", "!="): return fnmatchcase(Actual, Expected) == (Operator == "=") return {">": Actual > Expected, "<": Actual < Expected, ">=": Actual >= Expected, "<=": Actual <= Expected}[Operator] def _CompareNumbers(Actual: float, Operator: str, Expected: float) -> bool: return {"=": Actual == Expected, "!=": Actual != Expected, ">": Actual > Expected, "<": Actual < Expected, ">=": Actual >= Expected, "<=": Actual <= Expected}[Operator] def _ParseSize(Text: str) -> float: Text = Text.strip().casefold() for Unit, Multiplier in (("tb", 1024 ** 4), ("gb", 1024 ** 3), ("mb", 1024 ** 2), ("kb", 1024), ("b", 1)): if Text.endswith(Unit): return float(Text[:-len(Unit)].strip()) * Multiplier return float(Text) def _ParseBoolean(Text: str) -> bool | None: Text = Text.casefold() return True if Text in ("true", "yes", "on", "1") else False if Text in ("false", "no", "off", "0") else None def Matches(Item: Entry, Conditions: list[tuple[str, str, str]]) -> bool: return all(_MatchesOne(Item, *Condition) for Condition in Conditions) def _MatchesOne(Item: Entry, FieldName: str, Operator: str, Expected: str) -> bool: Field = FieldName.casefold() if Field in ("name", "filename"): return _CompareText(Item.Path.name, Operator, Expected) if Field in ("extension", "ext"): return _CompareText(Item.Path.suffix.lstrip("."), Operator, Expected.lstrip(".")) if Field == "type": return _CompareText(Item.Kind, Operator, Expected) if Field == "record": return _CompareText(Item.RecordName, Operator, Expected) if Field in ("size", "modified", "accessed", "created"): if Item.Stat is None: return False try: if Field == "size": return _CompareNumbers(Item.Stat.st_size, Operator, _ParseSize(Expected)) Times = {"modified": Item.Stat.st_mtime, "accessed": Item.Stat.st_atime, "created": Item.Stat.st_ctime} return _CompareNumbers(Times[Field], Operator, datetime.fromisoformat(Expected).timestamp()) except ValueError: return False TagName = FieldName[4:] if Field.startswith("tag.") else FieldName if Item.Book is None: return False Raw = next((Value for Key, Value in Item.Book.Tags.items() if Key.casefold() == TagName.casefold()), None) if Raw is None: return False if Raw == Unset or isinstance(Raw, str): return _CompareText(str(Raw), Operator, Expected) if isinstance(Raw, bool): Wanted = _ParseBoolean(Expected) return Wanted is not None and Operator in ("=", "!=") and (Raw == Wanted) == (Operator == "=") if isinstance(Raw, (int, float)): try: return _CompareNumbers(float(Raw), Operator, float(Expected)) except ValueError: return _CompareText(str(Raw), Operator, Expected) return _CompareText(FormatValue(Raw), Operator, Expected) # Sort def CriterionLabel(Criterion: str) -> str: return f"Tag: {Criterion[4:]}" if Criterion.startswith("Tag:") else SortNames.get(Criterion, Criterion) def _CriterionValue(Item: Entry, Criterion: str) -> tuple[bool, Any]: """(missing, comparable value).""" Stat = Item.Stat if Criterion == "Name": return False, NaturalKey(Item.Path.name) if Criterion in ("Modified", "Accessed", "Created", "FileSize"): if Stat is None: return True, 0 return False, {"Modified": Stat.st_mtime, "Accessed": Stat.st_atime, "Created": Stat.st_ctime, "FileSize": Stat.st_size}[Criterion] if Criterion == "FileType": return False, NaturalKey("" if Item.IsDirectory else Item.Path.suffix.lstrip(".")) if Criterion == "Record": return not Item.RecordName, NaturalKey(Item.RecordName) if Criterion.startswith("Tag:"): TagName = Criterion[4:] Value = Item.TagValue(TagName) if Value is None: return True, 0 Definition = Item.TagDefinition(TagName) Kind = TagType(Definition) if Kind == "Boolean": return False, int(bool(Value)) if Kind in ("Number", "Rating"): try: return False, float(Value) except (TypeError, ValueError): return True, 0 if Kind == "List" and isinstance(Definition.get("Values"), list): Values = Definition["Values"] return False, Values.index(Value) if Value in Values else len(Values) return False, NaturalKey(str(Value)) return False, NaturalKey(Item.Path.name) def _Compare(Left: Any, Right: Any) -> int: try: return (Left > Right) - (Left < Right) except TypeError: return _Compare(str(Left), str(Right)) def SortEntries(Items: list[Entry], Criteria: list[str], Descending: bool) -> list[Entry]: Direction = -1 if Descending else 1 def Compare(Left: Entry, Right: Entry) -> int: for Criterion in Criteria: LeftMissing, LeftValue = _CriterionValue(Left, Criterion) RightMissing, RightValue = _CriterionValue(Right, Criterion) if LeftMissing != RightMissing: return 1 if LeftMissing else -1 # missing values last, either direction if not LeftMissing and (Result := _Compare(LeftValue, RightValue)): return Result * Direction return _Compare(NaturalKey(Left.Path.name), NaturalKey(Right.Path.name)) * Direction Key = cmp_to_key(Compare) return sorted((Item for Item in Items if Item.IsDirectory), key=Key) + sorted((Item for Item in Items if not Item.IsDirectory), key=Key)