Files
SaraTag/Query.py
T

222 lines
9.0 KiB
Python

"""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:<name>), 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)