mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
222 lines
9.0 KiB
Python
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)
|