diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index e3dfbf2d..2fc85b7c 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -12,9 +12,9 @@ jobs: uses: actions/checkout@v4 - name: Execute Ruff format - uses: chartboost/ruff-action@v1 + uses: astral-sh/ruff-action@v3 with: - version: 0.8.1 + version: 0.11.0 args: format --check ruff-check: @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 - name: Execute Ruff check - uses: chartboost/ruff-action@v1 + uses: astral-sh/ruff-action@v3 with: - version: 0.8.1 + version: 0.11.8 args: check diff --git a/flake.lock b/flake.lock index 46071142..c3122303 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1725024810, - "narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=", + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "af510d4a62d071ea13925ce41c95e3dec816c01d", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", "type": "github" }, "original": { @@ -22,11 +22,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1741173522, - "narHash": "sha256-k7VSqvv0r1r53nUI/IfPHCppkUAddeXn843YlAC5DR0=", + "lastModified": 1744932701, + "narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d69ab0d71b22fa1ce3dbeff666e6deb4917db049", + "rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 62143cb4..3f4a4eda 100644 --- a/flake.nix +++ b/flake.nix @@ -30,19 +30,40 @@ { packages = let - pythonPackages = pkgs.python312Packages; + python3Packages = pkgs.python312Packages; - pillow-jxl-plugin = pythonPackages.callPackage ./nix/package/pillow-jxl-plugin.nix { + pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix { inherit (pkgs) cmake; inherit pyexiv2; - inherit (pkgs) rustPlatform; }; - pyexiv2 = pythonPackages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; }; - vtf2img = pythonPackages.callPackage ./nix/package/vtf2img.nix { }; + pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; }; + vtf2img = python3Packages.callPackage ./nix/package/vtf2img.nix { }; in rec { default = tagstudio; - tagstudio = pythonPackages.callPackage ./nix/package { + tagstudio = pkgs.callPackage ./nix/package { + # HACK: Remove when PySide6 is bumped to 6.9.x. + # Sourced from https://github.com/NixOS/nixpkgs/commit/2f9c1ad5e19a6154d541f878774a9aacc27381b7. + pyside6 = + if lib.versionAtLeast python3Packages.pyside6.version "6.9.0" then + (python3Packages.pyside6.override { + shiboken6 = python3Packages.shiboken6.overrideAttrs { + version = "6.8.0.2"; + + src = pkgs.fetchurl { + url = "mirror://qt/official_releases/QtForPython/shiboken6/PySide6-6.8.0.2-src/pyside-setup-everywhere-src-6.8.0.tar.xz"; + hash = "sha256-Ghohmo8yfjQNJYJ1+tOp8mG48EvFcEF0fnPdatJStOE="; + }; + + sourceRoot = "pyside-setup-everywhere-src-6.8.0/sources/shiboken6"; + + patches = [ ./nix/package/shiboken6-fix-include-qt-headers.patch ]; + }; + }).overrideAttrs + { sourceRoot = "pyside-setup-everywhere-src-6.8.0/sources/pyside6"; } + else + python3Packages.pyside6; + inherit pillow-jxl-plugin vtf2img; }; tagstudio-jxl = tagstudio.override { withJXLSupport = true; }; diff --git a/nix/package/default.nix b/nix/package/default.nix index 952e770e..a5d62026 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -1,44 +1,23 @@ { - buildPythonApplication, - chardet, ffmpeg-headless, - ffmpeg-python, - hatchling, - humanfriendly, lib, - mutagen, - numpy, - opencv-python, - pillow, - pillow-heif, - pillow-jxl-plugin, pipewire, - pydantic, - pydub, - pyside6, - pytest-qt, - pytest-xdist, - pytestCheckHook, - pythonRelaxDepsHook, + python3Packages, qt6, - rawpy, - send2trash, - sqlalchemy, stdenv, - structlog, - syrupy, - toml, - ujson, - vtf2img, wrapGAppsHook, + pillow-jxl-plugin, + pyside6, + vtf2img, + withJXLSupport ? false, }: let pyproject = (lib.importTOML ../../pyproject.toml).project; in -buildPythonApplication { +python3Packages.buildPythonApplication { pname = pyproject.name; inherit (pyproject) version; pyproject = true; @@ -46,7 +25,7 @@ buildPythonApplication { src = ../../.; nativeBuildInputs = [ - pythonRelaxDepsHook + python3Packages.pythonRelaxDepsHook qt6.wrapQtAppsHook # INFO: Should be unnecessary once PR is pulled. @@ -59,7 +38,7 @@ buildPythonApplication { qt6.qtmultimedia ]; - nativeCheckInputs = [ + nativeCheckInputs = with python3Packages; [ pytest-qt pytest-xdist pytestCheckHook @@ -80,30 +59,41 @@ buildPythonApplication { lib.makeLibraryPath [ pipewire ] }"; - pythonRemoveDeps = true; + pythonRemoveDeps = lib.optional (!withJXLSupport) [ "pillow_jxl" ]; + pythonRelaxDeps = [ + "numpy" + "pillow" + "pillow-heif" + "pillow-jxl-plugin" + "structlog" + "typing-extensions" + ]; pythonImportsCheck = [ "tagstudio" ]; - build-system = [ hatchling ]; - dependencies = [ - chardet - ffmpeg-python - humanfriendly - mutagen - numpy - opencv-python - pillow - pillow-heif - pydantic - pydub - pyside6 - rawpy - send2trash - sqlalchemy - structlog - toml - ujson - vtf2img - ] ++ lib.optional withJXLSupport pillow-jxl-plugin; + build-system = with python3Packages; [ hatchling ]; + dependencies = + with python3Packages; + [ + chardet + ffmpeg-python + humanfriendly + mutagen + numpy + opencv-python + pillow + pillow-heif + pydantic + pydub + pyside6 + rawpy + send2trash + sqlalchemy + structlog + toml + ujson + vtf2img + ] + ++ lib.optional withJXLSupport pillow-jxl-plugin; disabledTests = [ # INFO: These tests require modifications to a library, which does not work diff --git a/nix/package/shiboken6-fix-include-qt-headers.patch b/nix/package/shiboken6-fix-include-qt-headers.patch new file mode 100644 index 00000000..6ebca006 --- /dev/null +++ b/nix/package/shiboken6-fix-include-qt-headers.patch @@ -0,0 +1,81 @@ +Sourced from https://github.com/NixOS/nixpkgs/blob/5ba0f1ea90b0afa2abc23a43edb63af51d932e6d/pkgs/development/python-modules/shiboken6/fix-include-qt-headers.patch. +--- a/ApiExtractor/clangparser/compilersupport.cpp ++++ b/ApiExtractor/clangparser/compilersupport.cpp +@@ -16,6 +16,7 @@ + #include + #include + #include ++#include + + #include + +@@ -341,6 +342,13 @@ QByteArrayList emulatedCompilerOptions() + { + QByteArrayList result; + HeaderPaths headerPaths; ++ ++ bool isNixDebug = qgetenv("NIX_DEBUG").toInt() > 0; ++ // examples: ++ // /nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-qtsensors-6.4.2-dev/include ++ // /nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-qtbase-6.4.2-dev/include ++ QRegularExpression qtHeaderRegex(uR"(/[0-9a-z]{32}-qt[a-z0-9]+-)"_s); ++ + switch (compiler()) { + case Compiler::Msvc: + result.append(QByteArrayLiteral("-fms-compatibility-version=19.26.28806")); +@@ -352,9 +360,30 @@ QByteArrayList emulatedCompilerOptions() + appendClangBuiltinIncludes(&headerPaths); + break; + case Compiler::Clang: +- headerPaths.append(gppInternalIncludePaths(compilerFromCMake(u"clang++"_s))); ++ // fix: error: cannot jump from switch statement to this case label: case Compiler::Gpp ++ // note: jump bypasses variable initialization: const HeaderPaths clangPaths = ++ { ++ //headerPaths.append(gppInternalIncludePaths(compilerFromCMake(u"clang++"_s))); ++ // fix: qt.shiboken: x is specified in typesystem, but not defined. This could potentially lead to compilation errors. ++ // PySide requires that Qt headers are not -isystem ++ // https://bugreports.qt.io/browse/PYSIDE-787 ++ const HeaderPaths clangPaths = gppInternalIncludePaths(compilerFromCMake(u"clang++"_qs)); ++ for (const HeaderPath &h : clangPaths) { ++ auto match = qtHeaderRegex.match(QString::fromUtf8(h.path)); ++ if (!match.hasMatch()) { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found non-qt header: " << h.path; ++ // add using -isystem ++ headerPaths.append(h); ++ } else { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found qt header: " << h.path; ++ headerPaths.append({h.path, HeaderType::Standard}); ++ } ++ } + result.append(noStandardIncludeOption()); + break; ++ } + case Compiler::Gpp: + if (needsClangBuiltinIncludes()) + appendClangBuiltinIncludes(&headerPaths); +@@ -363,8 +392,20 @@ QByteArrayList emulatedCompilerOptions() + // etc (g++ 11.3). + const HeaderPaths gppPaths = gppInternalIncludePaths(compilerFromCMake(u"g++"_qs)); + for (const HeaderPath &h : gppPaths) { +- if (h.path.contains("c++") || h.path.contains("sysroot")) ++ // fix: qt.shiboken: x is specified in typesystem, but not defined. This could potentially lead to compilation errors. ++ // PySide requires that Qt headers are not -isystem ++ // https://bugreports.qt.io/browse/PYSIDE-787 ++ auto match = qtHeaderRegex.match(QString::fromUtf8(h.path)); ++ if (!match.hasMatch()) { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found non-qt header: " << h.path; ++ // add using -isystem + headerPaths.append(h); ++ } else { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found qt header: " << h.path; ++ headerPaths.append({h.path, HeaderType::Standard}); ++ } + } + break; + } +-- +2.39.0 diff --git a/pyproject.toml b/pyproject.toml index 34706916..22503b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,42 +8,43 @@ description = "A User-Focused Photo & File Management System." version = "9.5.2" license = "GPL-3.0-only" readme = "README.md" +requires-python = ">=3.12,<3.13" dependencies = [ - "chardet==5.2.0", - "ffmpeg-python==0.2.0", - "humanfriendly==10.0", - "mutagen==1.47.0", - "numpy==2.1.0", - "opencv_python==4.10.0.84", - "Pillow==10.3.0", - "pillow-heif==0.16.0", - "pillow-jxl-plugin==1.3.0", - "pydub==0.25.1", - "PySide6==6.8.0.1", - "rawpy==0.22.0", - "Send2Trash==1.8.3", - "SQLAlchemy==2.0.34", - "structlog==24.4.0", - "typing_extensions>=3.10.0.0,<4.11.0", - "ujson>=5.8.0,<5.9.0", - "vtf2img==0.1.0", - "toml==0.10.2", - "pydantic==2.9.2", + "chardet~=5.2", + "ffmpeg-python~=0.2", + "humanfriendly==10.*", + "mutagen~=1.47", + "numpy~=2.2", + "opencv_python~=4.11", + "Pillow~=11.2", + "pillow-heif~=0.22", + "pillow-jxl-plugin~=1.3", + "pydantic~=2.10", + "pydub~=0.25", + "PySide6==6.8.0.*", + "rawpy~=0.24", + "Send2Trash~=1.8", + "SQLAlchemy~=2.0", + "structlog~=25.3", + "toml~=0.10", + "typing_extensions~=4.13", + "ujson~=5.10", + "vtf2img~=0.1", ] [project.optional-dependencies] dev = ["tagstudio[mkdocs,mypy,pre-commit,pyinstaller,pytest,ruff]"] -mkdocs = ["mkdocs-material[imaging]==9.*"] -mypy = ["mypy==1.11.2", "mypy-extensions==1.*", "types-ujson>=5.8.0,<5.9.0"] -pre-commit = ["pre-commit==3.7.0"] -pyinstaller = ["Pyinstaller==6.6.0"] +mkdocs = ["mkdocs-material==9.*"] +mypy = ["mypy==1.15.0", "mypy-extensions==1.*", "types-ujson~=5.10"] +pre-commit = ["pre-commit~=4.2"] +pyinstaller = ["Pyinstaller~=6.13"] pytest = [ - "pytest==8.2.0", - "pytest-cov==5.0.0", + "pytest==8.3.5", + "pytest-cov==6.1.1", "pytest-qt==4.4.0", - "syrupy==4.7.1", + "syrupy==4.9.1", ] -ruff = ["ruff==0.8.1"] +ruff = ["ruff==0.11.8"] [project.gui-scripts] tagstudio = "tagstudio.main:main" diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index 70b4144c..a30fdeec 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -2,6 +2,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import platform +from datetime import datetime from enum import Enum from pathlib import Path from typing import override @@ -50,6 +51,12 @@ class GlobalSettings(BaseModel): show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) theme: Theme = Field(default=Theme.SYSTEM) + date_format: str = Field(default="%x") + hour_format: bool = Field(default=True) + zero_padding: bool = Field(default=True) + + loaded_from: Path = Field(default=DEFAULT_GLOBAL_SETTINGS_PATH, exclude=True) + @staticmethod def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings": if path.exists(): @@ -58,14 +65,34 @@ class GlobalSettings(BaseModel): if len(filecontents.strip()) != 0: logger.info("[Settings] Reading Global Settings File", path=path) settings_data = toml.loads(filecontents) - settings = GlobalSettings(**settings_data) + settings = GlobalSettings(**settings_data, loaded_from=path) return settings - return GlobalSettings() + return GlobalSettings(loaded_from=path) - def save(self, path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> None: + def save(self, path: Path | None = None) -> None: + if path is None: + path = self.loaded_from if not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w") as f: - toml.dump(dict(self), f, encoder=TomlEnumEncoder()) + toml.dump(self.model_dump(), f, encoder=TomlEnumEncoder()) + + def format_datetime(self, dt: datetime) -> str: + date_format = self.date_format + is_24h = self.hour_format + hour_format = "%H:%M:%S" if is_24h else "%I:%M:%S %p" + zero_padding = self.zero_padding + zero_padding_symbol = "" + + if not zero_padding: + zero_padding_symbol = "#" if platform.system() == "Windows" else "-" + date_format = date_format.replace("%d", f"%{zero_padding_symbol}d").replace( + "%m", f"%{zero_padding_symbol}m" + ) + hour_format = hour_format.replace("%H", f"%{zero_padding_symbol}H").replace( + "%I", f"%{zero_padding_symbol}I" + ) + + return datetime.strftime(dt, f"{date_format}, {hour_format}") diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index ee7f3ca0..980ddd2d 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -4,7 +4,7 @@ from pathlib import Path import structlog -from tagstudio.core.query_lang.ast import AST, Constraint, ConstraintType +from tagstudio.core.query_lang.ast import AST from tagstudio.core.query_lang.parser import Parser MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140 @@ -72,59 +72,57 @@ class SortingModeEnum(enum.Enum): @dataclass -class FilterState: +class BrowsingState: """Represent a state of the Library grid view.""" - # these should remain - page_size: int page_index: int = 0 sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED ascending: bool = True - # these should be erased on update + query: str | None = None + # Abstract Syntax Tree Of the current Search Query - ast: AST | None = None - @property - def limit(self): - return self.page_size - - @property - def offset(self): - return self.page_size * self.page_index + def ast(self) -> AST | None: + if self.query is None: + return None + return Parser(self.query).parse() @classmethod - def show_all(cls, page_size: int) -> "FilterState": - return FilterState(page_size=page_size) + def show_all(cls) -> "BrowsingState": + return BrowsingState() @classmethod - def from_search_query(cls, search_query: str, page_size: int) -> "FilterState": - return cls(ast=Parser(search_query).parse(), page_size=page_size) + def from_search_query(cls, search_query: str) -> "BrowsingState": + return cls(query=search_query) @classmethod - def from_tag_id(cls, tag_id: int | str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), []), page_size=page_size) + def from_tag_id(cls, tag_id: int | str) -> "BrowsingState": + return cls(query=f"tag_id:{str(tag_id)}") @classmethod - def from_path(cls, path: Path | str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), []), page_size=page_size) + def from_path(cls, path: Path | str) -> "BrowsingState": + return cls(query=f'path:"{str(path).strip()}"') @classmethod - def from_mediatype(cls, mediatype: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.MediaType, mediatype, []), page_size=page_size) + def from_mediatype(cls, mediatype: str) -> "BrowsingState": + return cls(query=f"mediatype:{mediatype}") @classmethod - def from_filetype(cls, filetype: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.FileType, filetype, []), page_size=page_size) + def from_filetype(cls, filetype: str) -> "BrowsingState": + return cls(query=f"filetype:{filetype}") @classmethod - def from_tag_name(cls, tag_name: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.Tag, tag_name, []), page_size=page_size) + def from_tag_name(cls, tag_name: str) -> "BrowsingState": + return cls(query=f'tag:"{tag_name}"') - def with_sorting_mode(self, mode: SortingModeEnum) -> "FilterState": + def with_page_index(self, index: int) -> "BrowsingState": + return replace(self, page_index=index) + + def with_sorting_mode(self, mode: SortingModeEnum) -> "BrowsingState": return replace(self, sorting_mode=mode) - def with_sorting_direction(self, ascending: bool) -> "FilterState": + def with_sorting_direction(self, ascending: bool) -> "BrowsingState": return replace(self, ascending=ascending) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 81e259d4..c01c20b7 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -61,8 +61,8 @@ from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.db import make_tables from tagstudio.core.library.alchemy.enums import ( MAX_SQL_VARIABLES, + BrowsingState, FieldTypeEnum, - FilterState, SortingModeEnum, ) from tagstudio.core.library.alchemy.fields import ( @@ -857,13 +857,14 @@ class Library: def search_library( self, - search: FilterState, + search: BrowsingState, + page_size: int, ) -> SearchResult: """Filter library by search query. :return: number of entries matching the query and one page of results. """ - assert isinstance(search, FilterState) + assert isinstance(search, BrowsingState) assert self.engine with Session(self.engine, expire_on_commit=False) as session: @@ -902,7 +903,7 @@ class Library: sort_on = func.lower(Entry.path) statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) - statement = statement.limit(search.limit).offset(search.offset) + statement = statement.limit(page_size).offset(search.page_index * page_size) logger.info( "searching library", diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py index 31a10d76..b3d173e3 100644 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ b/src/tagstudio/core/library/alchemy/visitors.py @@ -147,7 +147,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]): # raise exception if Constraint stays unhandled raise NotImplementedError("This type of constraint is not implemented yet") - def visit_property(self, node: Property) -> None: + def visit_property(self, node: Property) -> ColumnElement[bool]: raise NotImplementedError("This should never be reached!") def visit_not(self, node: Not) -> ColumnElement[bool]: diff --git a/src/tagstudio/core/query_lang/ast.py b/src/tagstudio/core/query_lang/ast.py index 9ebab448..102203ed 100644 --- a/src/tagstudio/core/query_lang/ast.py +++ b/src/tagstudio/core/query_lang/ast.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Union class ConstraintType(Enum): @@ -12,7 +12,7 @@ class ConstraintType(Enum): Special = 5 @staticmethod - def from_string(text: str) -> "ConstraintType": + def from_string(text: str) -> Union["ConstraintType", None]: return { "tag": ConstraintType.Tag, "tag_id": ConstraintType.TagID, @@ -24,7 +24,7 @@ class ConstraintType(Enum): class AST: - parent: "AST" = None + parent: Union["AST", None] = None def __str__(self): class_name = self.__class__.__name__ diff --git a/src/tagstudio/core/query_lang/tokenizer.py b/src/tagstudio/core/query_lang/tokenizer.py index 07c40e7d..4970a5fe 100644 --- a/src/tagstudio/core/query_lang/tokenizer.py +++ b/src/tagstudio/core/query_lang/tokenizer.py @@ -26,19 +26,19 @@ class Token: start: int end: int - def __init__(self, type: TokenType, value: Any, start: int = None, end: int = None) -> None: + def __init__(self, type: TokenType, value: Any, start: int, end: int) -> None: self.type = type self.value = value self.start = start self.end = end @staticmethod - def from_type(type: TokenType, pos: int = None) -> "Token": + def from_type(type: TokenType, pos: int) -> "Token": return Token(type, None, pos, pos) @staticmethod - def EOF() -> "Token": # noqa: N802 - return Token.from_type(TokenType.EOF) + def EOF(pos: int) -> "Token": # noqa: N802 + return Token.from_type(TokenType.EOF, pos) def __str__(self) -> str: return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover @@ -50,7 +50,7 @@ class Token: class Tokenizer: text: str pos: int - current_char: str + current_char: str | None ESCAPABLE_CHARS = ["\\", '"', '"'] NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","] @@ -63,7 +63,7 @@ class Tokenizer: def get_next_token(self) -> Token: self.__skip_whitespace() if self.current_char is None: - return Token.EOF() + return Token.EOF(self.pos) if self.current_char in ("'", '"'): return self.__quoted_string() @@ -119,6 +119,8 @@ class Tokenizer: out = "" while escape or self.current_char != quote: + if self.current_char is None: + raise ParsingError(start, self.pos, "Unterminated quoted string") if escape: escape = False if self.current_char not in Tokenizer.ESCAPABLE_CHARS: diff --git a/src/tagstudio/core/utils/dupe_files.py b/src/tagstudio/core/utils/dupe_files.py index 4ffc08d2..eb5d6a4b 100644 --- a/src/tagstudio/core/utils/dupe_files.py +++ b/src/tagstudio/core/utils/dupe_files.py @@ -4,7 +4,7 @@ from pathlib import Path import structlog -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry @@ -52,7 +52,7 @@ class DupeRegistry: continue results = self.library.search_library( - FilterState.from_path(path_relative, page_size=500), + BrowsingState.from_path(path_relative), 500 ) if not results: diff --git a/src/tagstudio/qt/modals/ffmpeg_checker.py b/src/tagstudio/qt/modals/ffmpeg_checker.py index c87f538a..776032b2 100644 --- a/src/tagstudio/qt/modals/ffmpeg_checker.py +++ b/src/tagstudio/qt/modals/ffmpeg_checker.py @@ -41,7 +41,7 @@ class FfmpegChecker(QMessageBox): red = get_ui_color(ColorType.PRIMARY, UiColor.RED) green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN) - missing = f"{Translations["generic.missing"]}" + missing = f"{Translations['generic.missing']}" found = f"{Translations['about.module.found']}" status = Translations.format( "ffmpeg.missing.status", @@ -50,4 +50,4 @@ class FfmpegChecker(QMessageBox): ffprobe=ffprobe, ffprobe_status=found if which(FFPROBE_CMD) else missing, ) - self.setText(f"{Translations["ffmpeg.missing.description"]}

{status}") + self.setText(f"{Translations['ffmpeg.missing.description']}

{status}") diff --git a/src/tagstudio/qt/modals/fix_unlinked.py b/src/tagstudio/qt/modals/fix_unlinked.py index 23b7c4d2..89eced5f 100644 --- a/src/tagstudio/qt/modals/fix_unlinked.py +++ b/src/tagstudio/qt/modals/fix_unlinked.py @@ -63,7 +63,7 @@ class FixUnlinkedEntriesModal(QWidget): self.relink_class.done.connect( # refresh the grid lambda: ( - self.driver.filter_items(), + self.driver.update_browsing_state(), self.refresh_missing_files(), ) ) @@ -78,7 +78,7 @@ class FixUnlinkedEntriesModal(QWidget): lambda: ( self.set_missing_count(), # refresh the grid - self.driver.filter_items(), + self.driver.update_browsing_state(), ) ) self.delete_button.clicked.connect(self.delete_modal.show) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 905c65d8..820be028 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -37,6 +37,24 @@ THEME_MAP: dict[Theme, str] = { Theme.SYSTEM: Translations["settings.theme.system"], } +DATE_FORMAT_MAP: dict[str, str] = { + "%d/%m/%y": "21/08/24", + "%d/%m/%Y": "21/08/2024", + "%d.%m.%y": "21.08.24", + "%d.%m.%Y": "21.08.2024", + "%d-%m-%y": "21-08-24", + "%d-%m-%Y": "21-08-2024", + "%x": "08/21/24", + "%m/%d/%Y": "08/21/2024", + "%m-%d-%y": "08-21-24", + "%m-%d-%Y": "08-21-2024", + "%m.%d.%y": "08.21.24", + "%m.%d.%Y": "08.21.2024", + "%Y/%m/%d": "2024/08/21", + "%Y-%m-%d": "2024-08-21", + "%Y.%m.%d": "2024.08.21", +} + class SettingsPanel(PanelWidget): driver: "QtDriver" @@ -147,6 +165,27 @@ class SettingsPanel(PanelWidget): self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label) form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox) + # Date Format + self.dateformat_combobox = QComboBox() + for k in DATE_FORMAT_MAP: + self.dateformat_combobox.addItem(DATE_FORMAT_MAP[k], k) + dateformat: str = self.driver.settings.date_format + if dateformat not in DATE_FORMAT_MAP: + dateformat = "%x" + self.dateformat_combobox.setCurrentIndex(list(DATE_FORMAT_MAP.keys()).index(dateformat)) + self.dateformat_combobox.currentIndexChanged.connect(self.__update_restart_label) + form_layout.addRow(Translations["settings.dateformat.label"], self.dateformat_combobox) + + # 24-Hour Format + self.hourformat_checkbox = QCheckBox() + self.hourformat_checkbox.setChecked(self.driver.settings.hour_format) + form_layout.addRow(Translations["settings.hourformat.label"], self.hourformat_checkbox) + + # Zero-padding + self.zeropadding_checkbox = QCheckBox() + self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding) + form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox) + def __build_library_settings(self): self.library_settings_container = QWidget() form_layout = QFormLayout(self.library_settings_container) @@ -167,6 +206,9 @@ class SettingsPanel(PanelWidget): "page_size": int(self.page_size_line_edit.text()), "show_filepath": self.filepath_combobox.currentData(), "theme": self.theme_combobox.currentData(), + "date_format": self.dateformat_combobox.currentData(), + "hour_format": self.hourformat_checkbox.isChecked(), + "zero_padding": self.zeropadding_checkbox.isChecked(), } def update_settings(self, driver: "QtDriver"): @@ -179,6 +221,9 @@ class SettingsPanel(PanelWidget): driver.settings.page_size = settings["page_size"] driver.settings.show_filepath = settings["show_filepath"] driver.settings.theme = settings["theme"] + driver.settings.date_format = settings["date_format"] + driver.settings.hour_format = settings["hour_format"] + driver.settings.zero_padding = settings["zero_padding"] driver.settings.save() diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index 3f2fe0af..8a1403dc 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/tag_color_manager.py @@ -2,7 +2,8 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import TYPE_CHECKING, Callable, override +from collections.abc import Callable +from typing import TYPE_CHECKING, override import structlog from PySide6 import QtCore, QtGui diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index 721b55ce..6b2ed4d7 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -24,7 +24,7 @@ from PySide6.QtWidgets import ( ) from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START -from tagstudio.core.library.alchemy.enums import FilterState, TagColorEnum +from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.core.palette import ColorType, get_tag_color @@ -292,9 +292,7 @@ class TagSearchPanel(PanelWidget): tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items( - FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size) - ), + self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) tag_widget.search_for_tag_action.setEnabled(True) diff --git a/src/tagstudio/qt/resources.json b/src/tagstudio/qt/resources.json index 216b9a2b..c2404889 100644 --- a/src/tagstudio/qt/resources.json +++ b/src/tagstudio/qt/resources.json @@ -122,9 +122,5 @@ "thumb_loading": { "path": "qt/images/thumb_loading.png", "mode": "pil" - }, - "placeholder_mp4": { - "path": "qt/videos/placeholder.mp4", - "mode": "rb" } } diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 804f5098..4ed07a33 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -10,7 +10,6 @@ import contextlib import ctypes -import dataclasses import math import os import platform @@ -21,6 +20,7 @@ from argparse import Namespace from pathlib import Path from queue import Queue from shutil import which +from typing import Generic, TypeVar from warnings import catch_warnings import structlog @@ -60,8 +60,8 @@ from tagstudio.core.driver import DriverMixin from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption from tagstudio.core.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme from tagstudio.core.library.alchemy.enums import ( + BrowsingState, FieldTypeEnum, - FilterState, ItemType, SortingModeEnum, ) @@ -121,6 +121,10 @@ else: logger = structlog.get_logger(__name__) +def clamp(value, lower_bound, upper_bound): + return max(lower_bound, min(upper_bound, value)) + + class Consumer(QThread): MARKER_QUIT = "MARKER_QUIT" @@ -139,6 +143,38 @@ class Consumer(QThread): pass +T = TypeVar("T") + + +# Ex. User visits | A ->[B] | +# | A B ->[C]| +# | A [B]<- C | +# |[A]<- B C | Previous routes still exist +# | A ->[D] | Stack is cut from [:A] on new route +class History(Generic[T]): + __history: list[T] + __index: int = 0 + + def __init__(self, initial_value: T): + self.__history = [initial_value] + super().__init__() + + def erase_future(self) -> None: + self.__history = self.__history[: self.__index + 1] + + def push(self, value: T) -> None: + self.erase_future() + self.__history.append(value) + self.__index = len(self.__history) - 1 + + def move(self, delta: int): + self.__index = clamp(self.__index + delta, 0, len(self.__history) - 1) + + @property + def current(self) -> T: + return self.__history[self.__index] + + class QtDriver(DriverMixin, QObject): """A Qt GUI frontend driver for TagStudio.""" @@ -154,10 +190,13 @@ class QtDriver(DriverMixin, QObject): about_modal: AboutModal unlinked_modal: FixUnlinkedEntriesModal dupe_modal: FixDupeFilesModal + applied_theme: Theme lib: Library + browsing_history: History[BrowsingState] + def __init__(self, args: Namespace): super().__init__() # prevent recursive badges update when multiple items selected @@ -167,7 +206,6 @@ class QtDriver(DriverMixin, QObject): self.args = args self.frame_content: list[int] = [] # List of Entry IDs on the current page self.pages_count = 0 - self.applied_theme = None self.scrollbar_pos = 0 self.thumb_size = 128 @@ -195,7 +233,9 @@ class QtDriver(DriverMixin, QObject): "[Settings] Global Settings File does not exist creating", path=self.global_settings_path, ) - self.filter = FilterState.show_all(page_size=self.settings.page_size) + self.applied_theme = self.settings.theme + + self.__reset_navigation() if self.args.cache_file: path = Path(self.args.cache_file) @@ -237,6 +277,9 @@ class QtDriver(DriverMixin, QObject): self.add_tag_to_selected_action: QAction | None = None + def __reset_navigation(self) -> None: + self.browsing_history = History(BrowsingState.show_all()) + def init_workers(self): """Init workers for rendering thumbnails.""" if not self.thumb_threads: @@ -281,7 +324,6 @@ class QtDriver(DriverMixin, QObject): self.app.styleHints().setColorScheme( Qt.ColorScheme.Dark if self.settings.theme == Theme.DARK else Qt.ColorScheme.Light ) - self.applied_theme = self.settings.theme if ( platform.system() == "Darwin" or platform.system() == "Windows" @@ -700,7 +742,6 @@ class QtDriver(DriverMixin, QObject): ] self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] - self.filter = FilterState.show_all(page_size=self.settings.page_size) self.init_library_window() self.migration_modal: JsonMigrationModal = None @@ -744,12 +785,10 @@ class QtDriver(DriverMixin, QObject): # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) - def _filter_items(): + def _update_browsing_state(): try: - self.filter_items( - FilterState.from_search_query( - self.main_window.searchField.text(), page_size=self.settings.page_size - ) + self.update_browsing_state( + BrowsingState.from_search_query(self.main_window.searchField.text()) .with_sorting_mode(self.sorting_mode) .with_sorting_direction(self.sorting_direction) ) @@ -758,21 +797,21 @@ class QtDriver(DriverMixin, QObject): f"{Translations['status.results.invalid_syntax']} " f'"{self.main_window.searchField.text()}"' ) - logger.error("[QtDriver] Could not filter items", error=e) + logger.error("[QtDriver] Could not update BrowsingState", error=e) # Search Button search_button: QPushButton = self.main_window.searchButton - search_button.clicked.connect(_filter_items) + search_button.clicked.connect(_update_browsing_state) # Search Field search_field: QLineEdit = self.main_window.searchField - search_field.returnPressed.connect(_filter_items) + search_field.returnPressed.connect(_update_browsing_state) # Sorting Dropdowns sort_mode_dropdown: QComboBox = self.main_window.sorting_mode_combobox for sort_mode in SortingModeEnum: sort_mode_dropdown.addItem(Translations[sort_mode.value], sort_mode) sort_mode_dropdown.setCurrentIndex( - list(SortingModeEnum).index(self.filter.sorting_mode) - ) # set according to self.filter + list(SortingModeEnum).index(self.browsing_history.current.sorting_mode) + ) # set according to navigation state sort_mode_dropdown.currentIndexChanged.connect(self.sorting_mode_callback) sort_dir_dropdown: QComboBox = self.main_window.sorting_direction_combobox @@ -794,9 +833,9 @@ class QtDriver(DriverMixin, QObject): self._init_thumb_grid() back_button: QPushButton = self.main_window.backButton - back_button.clicked.connect(lambda: self.page_move(-1)) + back_button.clicked.connect(lambda: self.navigation_callback(-1)) forward_button: QPushButton = self.main_window.forwardButton - forward_button.clicked.connect(lambda: self.page_move(1)) + forward_button.clicked.connect(lambda: self.navigation_callback(1)) # NOTE: Putting this early will result in a white non-responsive # window until everything is loaded. Consider adding a splash screen @@ -805,7 +844,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.activateWindow() self.main_window.toggle_landing_page(enabled=True) - self.main_window.pagination.index.connect(lambda i: self.page_move(page_id=i)) + self.main_window.pagination.index.connect(lambda i: self.page_move(i, absolute=True)) self.splash.finish(self.main_window) @@ -825,7 +864,9 @@ class QtDriver(DriverMixin, QObject): ) self.file_extension_panel.setTitle(Translations["ignore_list.title"]) self.file_extension_panel.setWindowTitle(Translations["ignore_list.title"]) - self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items())) + self.file_extension_panel.saved.connect( + lambda: (panel.save(), self.update_browsing_state()) + ) self.manage_file_ext_action.triggered.connect(self.file_extension_panel.show) def show_grid_filenames(self, value: bool): @@ -871,7 +912,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.searchField.setText("") scrollbar: QScrollArea = self.main_window.scrollArea scrollbar.verticalScrollBar().setValue(0) - self.filter = FilterState.show_all(page_size=self.settings.page_size) + self.__reset_navigation() self.lib.close() @@ -1045,7 +1086,7 @@ class QtDriver(DriverMixin, QObject): for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.preview_panel.thumb.stop_file_use() + self.preview_panel.thumb.media_player.stop() if delete_file(self.lib.library_dir / f): self.main_window.statusbar.showMessage( Translations.format( @@ -1059,7 +1100,7 @@ class QtDriver(DriverMixin, QObject): self.selected.clear() if deleted_count > 0: - self.filter_items() + self.update_browsing_state() self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: @@ -1211,7 +1252,7 @@ class QtDriver(DriverMixin, QObject): pw.hide(), pw.deleteLater(), # refresh the library only when new items are added - files_count and self.filter_items(), # type: ignore + files_count and self.update_browsing_state(), # type: ignore ) ) QThreadPool.globalInstance().start(r) @@ -1282,7 +1323,9 @@ class QtDriver(DriverMixin, QObject): def sorting_direction_callback(self): logger.info("Sorting Direction Changed", ascending=self.sorting_direction) - self.filter_items() + self.update_browsing_state( + self.browsing_history.current.with_sorting_direction(self.sorting_direction) + ) @property def sorting_mode(self) -> SortingModeEnum: @@ -1291,7 +1334,9 @@ class QtDriver(DriverMixin, QObject): def sorting_mode_callback(self): logger.info("Sorting Mode Changed", mode=self.sorting_mode) - self.filter_items() + self.update_browsing_state( + self.browsing_history.current.with_sorting_mode(self.sorting_mode) + ) def thumb_size_callback(self, index: int): """Perform actions needed when the thumbnail size selection is changed. @@ -1324,41 +1369,42 @@ class QtDriver(DriverMixin, QObject): def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: - self.page_move(1) + self.navigation_callback(1) elif event.button() == Qt.MouseButton.BackButton: - self.page_move(-1) + self.navigation_callback(-1) - def page_move(self, delta: int = None, page_id: int = None) -> None: - """Navigate a step further into the navigation stack.""" - logger.info( - "page_move", - delta=delta, - page_id=page_id, + def page_move(self, value: int, absolute=False) -> None: + logger.info("page_move", value=value, absolute=absolute) + + if not absolute: + value += self.browsing_history.current.page_index + + self.browsing_history.push( + self.browsing_history.current.with_page_index(clamp(value, 0, self.pages_count - 1)) ) - # Ex. User visits | A ->[B] | - # | A B ->[C]| - # | A [B]<- C | - # |[A]<- B C | Previous routes still exist - # | A ->[D] | Stack is cut from [:A] on new route - - # sb: QScrollArea = self.main_window.scrollArea - # sb_pos = sb.verticalScrollBar().value() - - page_index = page_id if page_id is not None else self.filter.page_index + delta - page_index = max(0, min(page_index, self.pages_count - 1)) - - self.filter.page_index = page_index # TODO: Re-allow selecting entries across multiple pages at once. # This works fine with additive selection but becomes a nightmare with bridging. - self.filter_items() + + self.update_browsing_state() + + def navigation_callback(self, delta: int) -> None: + """Callback for the Forwads and Backwards Navigation Buttons next to the search bar.""" + logger.info( + "navigation_callback", + delta=delta, + ) + + self.browsing_history.move(delta) + + self.update_browsing_state() def remove_grid_item(self, grid_idx: int): self.frame_content[grid_idx] = None self.item_thumbs[grid_idx].hide() def _update_thumb_count(self): - missing_count = max(0, self.filter.page_size - len(self.item_thumbs)) + missing_count = max(0, self.settings.page_size - len(self.item_thumbs)) layout = self.flow_container.layout() for _ in range(missing_count): item_thumb = ItemThumb( @@ -1742,17 +1788,17 @@ class QtDriver(DriverMixin, QObject): pending_entries.get(badge_type, []), BADGE_TAGS[badge_type] ) - def filter_items(self, filter: FilterState | None = None) -> None: + def update_browsing_state(self, state: BrowsingState | None = None) -> None: + """Navigates to a new BrowsingState when state is given, otherwise updates the results.""" if not self.lib.library_dir: logger.info("Library not loaded") return assert self.lib.engine - if filter: - self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter)) - else: - self.filter.sorting_mode = self.sorting_mode - self.filter.ascending = self.sorting_direction + if state: + self.browsing_history.push(state) + + self.main_window.searchField.setText(self.browsing_history.current.query or "") # inform user about running search self.main_window.statusbar.showMessage(Translations["status.library_search_query"]) @@ -1760,7 +1806,7 @@ class QtDriver(DriverMixin, QObject): # search the library start_time = time.time() - results = self.lib.search_library(self.filter) + results = self.lib.search_library(self.browsing_history.current, self.settings.page_size) logger.info("items to render", count=len(results)) end_time = time.time() @@ -1778,9 +1824,9 @@ class QtDriver(DriverMixin, QObject): self.update_thumbs() # update pagination - self.pages_count = math.ceil(results.total_count / self.filter.page_size) + self.pages_count = math.ceil(results.total_count / self.settings.page_size) self.main_window.pagination.update_buttons( - self.pages_count, self.filter.page_index, emit=False + self.pages_count, self.browsing_history.current.page_index, emit=False ) def remove_recent_library(self, item_key: str): @@ -1915,14 +1961,14 @@ class QtDriver(DriverMixin, QObject): if open_status.json_migration_req: self.migration_modal = JsonMigrationModal(path) self.migration_modal.migration_finished.connect( - lambda: self.init_library(path, self.lib.open_library(path)) + lambda: self._init_library(path, self.lib.open_library(path)) ) self.main_window.landing_widget.set_status_label("") self.migration_modal.paged_panel.show() else: - self.init_library(path, open_status) + self._init_library(path, open_status) - def init_library(self, path: Path, open_status: LibraryStatus): + def _init_library(self, path: Path, open_status: LibraryStatus): if not open_status.success: self.show_error_message( error_name=open_status.message @@ -1933,7 +1979,7 @@ class QtDriver(DriverMixin, QObject): self.init_workers() - self.filter.page_size = self.settings.page_size + self.__reset_navigation() # TODO - make this call optional if self.lib.entries_count < 10000: @@ -1973,7 +2019,7 @@ class QtDriver(DriverMixin, QObject): self.preview_panel.update_widgets() # page (re)rendering, extract eventually - self.filter_items() + self.update_browsing_state() self.main_window.toggle_landing_page(enabled=False) return open_status diff --git a/src/tagstudio/qt/widgets/fields.py b/src/tagstudio/qt/widgets/fields.py index c787110f..d2678b55 100644 --- a/src/tagstudio/qt/widgets/fields.py +++ b/src/tagstudio/qt/widgets/fields.py @@ -4,8 +4,9 @@ import math +from collections.abc import Callable from pathlib import Path -from typing import Callable, override +from typing import override from warnings import catch_warnings import structlog diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index 42a82926..52719ea4 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -205,11 +205,11 @@ class ItemThumb(FlowWidget): self.thumb_button = ThumbButton(self.thumb_container, thumb_size) self.renderer = ThumbRenderer(self.lib) self.renderer.updated.connect( - lambda timestamp, image, size, filename, ext: ( + lambda timestamp, image, size, filename: ( self.update_thumb(timestamp, image=image), self.update_size(timestamp, size=size), self.set_filename_text(filename), - self.set_extension(ext), # type: ignore + self.set_extension(filename), ) ) self.thumb_button.setFlat(True) @@ -365,13 +365,13 @@ class ItemThumb(FlowWidget): self.item_type_badge.setHidden(False) self.mode = mode - def set_extension(self, ext: str) -> None: + def set_extension(self, filename: Path) -> None: + ext = filename.suffix if ext and ext.startswith(".") is False: ext = "." + ext media_types: set[MediaType] = MediaCategories.get_types(ext) if ( - ext - and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) + not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES) @@ -386,7 +386,7 @@ class ItemThumb(FlowWidget): ] ): self.ext_badge.setHidden(False) - self.ext_badge.setText(ext.upper()[1:]) + self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper()) if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types: self.count_badge.setHidden(False) else: diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 807b6699..9d4eaf7b 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -416,9 +416,9 @@ class MediaPlayer(QGraphicsView): self.scene().removeItem(self.video_preview) def stop(self) -> None: - """Clear the filepath and stop the player.""" + """Clear the filepath, stop the player and release the source.""" self.filepath = None - self.player.stop() + self.player.setSource(QUrl()) def play(self, filepath: Path) -> None: """Set the source of the QMediaPlayer and play.""" diff --git a/src/tagstudio/qt/widgets/migration_modal.py b/src/tagstudio/qt/widgets/migration_modal.py index 068334fd..975c738b 100644 --- a/src/tagstudio/qt/widgets/migration_modal.py +++ b/src/tagstudio/qt/widgets/migration_modal.py @@ -616,7 +616,7 @@ class JsonMigrationModal(QObject): logger.info( "[Field Comparison]", - fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]), + fields="\n".join([str(x) for x in zip(json_fields, sql_fields, strict=False)]), ) self.field_parity = True diff --git a/src/tagstudio/qt/widgets/panel.py b/src/tagstudio/qt/widgets/panel.py index cdd59b2c..db923bab 100755 --- a/src/tagstudio/qt/widgets/panel.py +++ b/src/tagstudio/qt/widgets/panel.py @@ -3,7 +3,8 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable, override +from collections.abc import Callable +from typing import override import structlog from PySide6 import QtCore, QtGui diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index 76dc4860..f93b5432 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -109,8 +109,9 @@ class FieldContainers(QWidget): """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - self.cached_entries = [self.lib.get_entry_full(entry_id)] - entry = self.cached_entries[0] + entry = self.lib.get_entry_full(entry_id) + assert entry is not None + self.cached_entries = [entry] self.update_granular(entry.tags, entry.fields, update_badges) def update_granular( @@ -177,6 +178,7 @@ class FieldContainers(QWidget): """ tag_obj = self.lib.get_tag(tag_id) # Get full object if p_ids is None: + assert tag_obj is not None p_ids = tag_obj.parent_ids for p_id in p_ids: @@ -186,6 +188,7 @@ class FieldContainers(QWidget): if tag_id not in cluster_map[p_id]: cluster_map[p_id].add(tag_id) p_tag = self.lib.get_tag(p_id) # Get full object + assert p_tag is not None if p_tag.parent_ids: add_to_cluster( tag_id, @@ -201,7 +204,7 @@ class FieldContainers(QWidget): logger.info("[FieldContainers] Cluster Map", cluster_map=cluster_map) # Initialize all categories from parents. - tags_ = {self.lib.get_tag(x) for x in exhausted} + tags_ = {t for tid in exhausted if (t := self.lib.get_tag(tid)) is not None} for tag in tags_: if tag.is_category: cats[tag] = set() @@ -218,20 +221,28 @@ class FieldContainers(QWidget): ) if final_tags := cluster_map.get(key.id, set()).union([key.id]): - cats[key] = {self.lib.get_tag(x) for x in final_tags if x in base_tag_ids} - added_ids = added_ids.union({x for x in final_tags if x in base_tag_ids}) + cats[key] = { + t + for tid in final_tags + if tid in base_tag_ids and (t := self.lib.get_tag(tid)) is not None + } + added_ids = added_ids.union({tid for tid in final_tags if tid in base_tag_ids}) # Add remaining tags to None key (general case). - cats[None] = {self.lib.get_tag(x) for x in base_tag_ids if x not in added_ids} + cats[None] = { + t + for tid in base_tag_ids + if tid not in added_ids and (t := self.lib.get_tag(tid)) is not None + } logger.info( - f"[FieldContainers] [{key}] Key cluster: None, general case!", - general_tags=cats[key], + "[FieldContainers] Key cluster: None, general case!", + general_tags=cats[None], added=added_ids, base_tag_ids=base_tag_ids, ) # Remove unused categories - empty: list[Tag] = [] + empty: list[Tag | None] = [] for k, v in list(cats.items()): if not v: empty.append(k) @@ -304,7 +315,7 @@ class FieldContainers(QWidget): # Normalize line endings in any text content. if not is_mixed: - assert isinstance(field.value, (str, type(None))) + assert isinstance(field.value, str | type(None)) text = field.value or "" else: text = "Mixed Data" @@ -326,7 +337,7 @@ class FieldContainers(QWidget): ) if "pytest" in sys.modules: # for better testability - container.modal = modal + container.modal = modal # pyright: ignore[reportAttributeAccessIssue] container.set_edit_callback(modal.show) container.set_remove_callback( @@ -344,7 +355,7 @@ class FieldContainers(QWidget): container.set_inline(False) # Normalize line endings in any text content. if not is_mixed: - assert isinstance(field.value, (str, type(None))) + assert isinstance(field.value, str | type(None)) text = (field.value or "").replace("\r", "\n") else: text = "Mixed Data" @@ -375,23 +386,35 @@ class FieldContainers(QWidget): ) elif field.type.type == FieldTypeEnum.DATETIME: + logger.info("[FieldContainers][write_container] Datetime Field", field=field) if not is_mixed: + container.set_title(field.type.name) + container.set_inline(False) try: - container.set_title(field.type.name) - container.set_inline(False) - # TODO: Localize this and/or add preferences. - date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S") title = f"{field.type.name} (Date)" - inner_widget = TextWidget(title, date.strftime("%D - %r")) - container.set_inner_widget(inner_widget) - except Exception: - container.set_title(field.type.name) - container.set_inline(False) + text = self.driver.settings.format_datetime( + dt.strptime(field.value or "", "%Y-%m-%d %H:%M:%S") + ) + except ValueError: title = f"{field.type.name} (Date) (Unknown Format)" - inner_widget = TextWidget(title, str(field.value)) - container.set_inner_widget(inner_widget) + text = str(field.value) - container.set_edit_callback() + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) + + modal = PanelModal( # TODO Replace with proper date picker including timezone etc. + EditTextLine(field.value), + title=f"Edit {field.type.name} in 'YYYY-MM-DD HH:MM:SS' format", + window_title=f"Edit {field.type.name}", + save_callback=( + lambda content: ( + self.update_field(field, content), # type: ignore + self.update_from_entry(self.cached_entries[0].id), + ) + ), + ) + + container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), @@ -491,7 +514,7 @@ class FieldContainers(QWidget): """Update a field in all selected Entries, given a field object.""" assert isinstance( field, - (TextField, DatetimeField), + TextField | DatetimeField, ), f"instance: {type(field)}" entry_ids = [e.id for e in self.cached_entries] diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index d894af18..694ef91b 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -102,18 +102,19 @@ class FileAttributes(QWidget): def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" if filepath and filepath.is_file(): - created: dt = None + created: dt if platform.system() == "Windows" or platform.system() == "Darwin": created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] else: created = dt.fromtimestamp(filepath.stat().st_ctime) modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) self.date_created_label.setText( - f"{Translations['file.date_created']}: {dt.strftime(created, '%a, %x, %X')}" + f"{Translations['file.date_created']}:" + + f" {self.driver.settings.format_datetime(created)}" ) self.date_modified_label.setText( f"{Translations['file.date_modified']}: " - f"{dt.strftime(modified, '%a, %x, %X')}" + f"{self.driver.settings.format_datetime(modified)}" ) self.date_created_label.setHidden(False) self.date_modified_label.setHidden(False) @@ -130,7 +131,7 @@ class FileAttributes(QWidget): self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): + def update_stats(self, filepath: Path | None = None, stats: dict | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: stats = {} @@ -144,11 +145,13 @@ class FileAttributes(QWidget): self.dimensions_label.setText("") self.dimensions_label.setHidden(True) else: + ext = filepath.suffix.lower() self.library_path = self.library.library_dir display_path = filepath if self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: display_path = filepath elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_RELATIVE_PATHS: + assert self.library_path is not None display_path = Path(filepath).relative_to(self.library_path) elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FILENAMES_ONLY: display_path = Path(filepath.name) @@ -163,11 +166,11 @@ class FileAttributes(QWidget): for i, part in enumerate(display_path.parts): part_ = part.strip(os.path.sep) if i != len(display_path.parts) - 1: - file_str += f"{"\u200b".join(part_)}{separator}" + file_str += f"{'\u200b'.join(part_)}{separator}" else: if file_str != "": file_str += "
" - file_str += f"{"\u200b".join(part_)}" + file_str += f"{'\u200b'.join(part_)}" self.file_label.setText(file_str) self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) self.opener = FileOpenerHelper(filepath) @@ -186,8 +189,7 @@ class FileAttributes(QWidget): height_px_text = stats.get("height", "") duration_text = stats.get("duration", "") font_family = stats.get("font_family", "") - if ext: - ext_display = ext.upper()[1:] + ext_display = ext.upper()[1:] or filepath.stem.upper() if filepath: try: file_size = format_size(filepath.stat().st_size) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 7440da82..53b94a22 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -4,8 +4,8 @@ import io import time -import typing from pathlib import Path +from typing import TYPE_CHECKING, override from warnings import catch_warnings import cv2 @@ -24,12 +24,11 @@ from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.media_player import MediaPlayer from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) @@ -39,7 +38,7 @@ Image.MAX_IMAGE_PIXELS = None class PreviewThumb(QWidget): """The Preview Panel Widget.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.is_connected = False @@ -54,6 +53,7 @@ class PreviewThumb(QWidget): self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) self.image_layout.setContentsMargins(0, 0, 0, 0) + self.opener: FileOpenerHelper | None = None self.open_file_action = QAction(Translations["file.open_file"], self) self.open_explorer_action = QAction(open_file_str(), self) self.delete_action = QAction( @@ -133,17 +133,17 @@ class PreviewThumb(QWidget): def _has_video_changed(self, video: bool) -> None: self.update_image_size((self.size().width(), self.size().height())) - def _stacked_page_setup(self, page: QWidget, widget: QWidget): + def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout = QHBoxLayout(page) layout.addWidget(widget) layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) layout.setContentsMargins(0, 0, 0, 0) page.setLayout(layout) - def set_image_ratio(self, ratio: float): + def set_image_ratio(self, ratio: float) -> None: self.image_ratio = ratio - def update_image_size(self, size: tuple[int, int], ratio: float | None = None): + def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: if ratio: self.set_image_ratio(ratio) @@ -204,7 +204,7 @@ class PreviewThumb(QWidget): self.size().height(), ) - def switch_preview(self, preview: str): + def switch_preview(self, preview: str) -> None: if preview in ["audio", "video"]: self.media_player.show() self.image_layout.setCurrentWidget(self.media_player_page) @@ -229,7 +229,7 @@ class PreviewThumb(QWidget): self.gif_buffer.close() self.preview_gif.hide() - def _display_fallback_image(self, filepath: Path, ext: str) -> dict: + def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: """Renders the given file as an image, no matter its media type. Useful for fallback scenarios. @@ -242,11 +242,12 @@ class PreviewThumb(QWidget): self.devicePixelRatio(), update_on_ratio_change=True, ) - return self._update_image(filepath, ext) + return self._update_image(filepath) - def _update_image(self, filepath: Path, ext: str) -> dict: + def _update_image(self, filepath: Path) -> dict[str, int]: """Update the static image preview from a filepath.""" - stats: dict = {} + stats: dict[str, int] = {} + ext = filepath.suffix.lower() self.switch_preview("image") image: Image.Image | None = None @@ -287,9 +288,9 @@ class PreviewThumb(QWidget): return stats - def _update_animation(self, filepath: Path, ext: str) -> dict: + def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: """Update the animated image preview from a filepath.""" - stats: dict = {} + stats: dict[str, int] = {} # Ensure that any movie and buffer from previous animations are cleared. if self.preview_gif.movie(): @@ -351,8 +352,8 @@ class PreviewThumb(QWidget): image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def _update_media(self, filepath: Path, type: MediaType) -> dict: - stats: dict = {} + def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + stats: dict[str, int] = {} self.media_player.play(filepath) @@ -380,9 +381,10 @@ class PreviewThumb(QWidget): stats["duration"] = self.media_player.player.duration() * 1000 return stats - def update_preview(self, filepath: Path, ext: str) -> dict: + def update_preview(self, filepath: Path) -> dict[str, int]: """Render a single file preview.""" - stats: dict = {} + ext = filepath.suffix.lower() + stats: dict[str, int] = {} # Video if MediaCategories.is_ext_in_category( @@ -394,7 +396,7 @@ class PreviewThumb(QWidget): elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): - self._update_image(filepath, ext) + self._update_image(filepath) stats = self._update_media(filepath, MediaType.AUDIO) self.thumb_renderer.render( time.time(), @@ -413,7 +415,7 @@ class PreviewThumb(QWidget): # Other Types (Including Images) else: # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath, ext) + stats = self._update_image(filepath) self.thumb_renderer.render( time.time(), @@ -448,17 +450,11 @@ class PreviewThumb(QWidget): return stats - def hide_preview(self): + def hide_preview(self) -> None: """Completely hide the file preview.""" self.switch_preview("") - def stop_file_use(self): - """Stops the use of the currently previewed file. Used to release file permissions.""" - logger.info("[PreviewThumb] Stopping file use in video playback...") - # This swaps the video out for a placeholder so the previous video's file - # is no longer in use by this object. - self.media_player.play(ResourceManager.get_path("placeholder_mp4")) - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + @override + def resizeEvent(self, event: QResizeEvent) -> None: self.update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py index dffb12f6..ef4676e8 100644 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ b/src/tagstudio/qt/widgets/preview_panel.py @@ -145,11 +145,10 @@ class PreviewPanel(QWidget): entry: Entry = self.lib.get_entry(self.driver.selected[0]) entry_id = self.driver.selected[0] filepath: Path = self.lib.library_dir / entry.path - ext: str = filepath.suffix.lower() if update_preview: - stats: dict = self.thumb.update_preview(filepath, ext) - self.file_attrs.update_stats(filepath, ext, stats) + stats: dict = self.thumb.update_preview(filepath) + self.file_attrs.update_stats(filepath, stats) self.file_attrs.update_date_label(filepath) self.fields.update_from_entry(entry_id) self.update_add_tag_button(entry_id) diff --git a/src/tagstudio/qt/widgets/progress.py b/src/tagstudio/qt/widgets/progress.py index dcd1ed16..205aed8a 100644 --- a/src/tagstudio/qt/widgets/progress.py +++ b/src/tagstudio/qt/widgets/progress.py @@ -3,7 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable +from collections.abc import Callable from PySide6.QtCore import Qt, QThreadPool from PySide6.QtWidgets import QProgressDialog, QVBoxLayout, QWidget diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index 68ac0fc2..dbeea052 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -8,7 +8,7 @@ import typing import structlog from PySide6.QtCore import Signal -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.flowlayout import FlowLayout from tagstudio.qt.modals.build_tag import BuildTagPanel @@ -67,9 +67,7 @@ class TagBoxWidget(FieldWidget): tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items( - FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size) - ), + self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) diff --git a/src/tagstudio/qt/widgets/text_line_edit.py b/src/tagstudio/qt/widgets/text_line_edit.py index 9822abea..1c719fab 100644 --- a/src/tagstudio/qt/widgets/text_line_edit.py +++ b/src/tagstudio/qt/widgets/text_line_edit.py @@ -3,7 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable +from collections.abc import Callable from PySide6.QtWidgets import QLineEdit, QVBoxLayout diff --git a/src/tagstudio/qt/widgets/thumb_renderer.py b/src/tagstudio/qt/widgets/thumb_renderer.py index f0116c68..e4ec1e8d 100644 --- a/src/tagstudio/qt/widgets/thumb_renderer.py +++ b/src/tagstudio/qt/widgets/thumb_renderer.py @@ -11,6 +11,7 @@ import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path +from typing import cast from warnings import catch_warnings import cv2 @@ -88,7 +89,7 @@ class ThumbRenderer(QObject): rm: ResourceManager = ResourceManager() cache: CacheManager = CacheManager() - updated = Signal(float, QPixmap, QSize, Path, str) + updated = Signal(float, QPixmap, QSize, Path) updated_ratio = Signal(float) cached_img_res: int = 256 # TODO: Pull this from config @@ -754,8 +755,8 @@ class ThumbRenderer(QObject): data = np.asarray(raw.getchannel(0)) m, n = data.shape[:2] - col: np.ndarray = data.any(0) - row: np.ndarray = data.any(1) + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) cropped_data = np.asarray(raw)[ row.argmax() : m - row[::-1].argmax(), col.argmax() : n - col[::-1].argmax(), @@ -802,7 +803,7 @@ class ThumbRenderer(QObject): bg = Image.new("RGBA", (size, size), color="#00000000") draw = ImageDraw.Draw(bg) lines_of_padding = 2 - y_offset = 0 + y_offset = 0.0 for font_size in scaled_sizes: font = ImageFont.truetype(filepath, size=font_size) @@ -1299,7 +1300,6 @@ class ThumbRenderer(QObject): math.ceil(image.size[1] / pixel_ratio), ), filepath, - filepath.suffix.lower(), ) else: self.updated.emit( @@ -1307,7 +1307,6 @@ class ThumbRenderer(QObject): QPixmap(), QSize(*base_size), filepath, - filepath.suffix.lower(), ) def _render( diff --git a/src/tagstudio/resources/qt/videos/placeholder.mp4 b/src/tagstudio/resources/qt/videos/placeholder.mp4 deleted file mode 100644 index 1e22e4c7..00000000 Binary files a/src/tagstudio/resources/qt/videos/placeholder.mp4 and /dev/null differ diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 2d0b029b..281f69c7 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -247,6 +247,12 @@ "settings.theme.label": "Theme:", "settings.theme.light": "Light", "settings.theme.system": "System", + "settings.dateformat.label": "Date Format", + "settings.dateformat.system": "System", + "settings.dateformat.english": "English", + "settings.dateformat.international": "International", + "settings.hourformat.label": "24-Hour Time", + "settings.zeropadding.label": "Date Zero-Padding", "settings.title": "Settings", "sorting.direction.ascending": "Ascending", "sorting.direction.descending": "Descending", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 7eccc3c0..aa1fc05e 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -236,6 +236,12 @@ "settings.restart_required": "Por favor, reinicia TagStudio para que se los cambios surtan efecto.", "settings.show_filenames_in_grid": "Mostrar el nombre de archivo en la cuadrícula", "settings.show_recent_libraries": "Mostrar bibliotecas recientes", + "settings.dateformat.label": "Formato fecha", + "settings.dateformat.system": "Sistema", + "settings.dateformat.english": "Inglés", + "settings.dateformat.international": "Internacional", + "settings.hourformat.label": "Formato 24-horas", + "settings.zeropadding.label": "Rellenar ceros en fechas", "settings.title": "Ajustes", "sorting.direction.ascending": "Ascendiente", "sorting.direction.descending": "Descendiente", diff --git a/tests/fixtures/sample.epub b/tests/fixtures/sample.epub deleted file mode 100644 index b625b67b..00000000 Binary files a/tests/fixtures/sample.epub and /dev/null differ diff --git a/tests/fixtures/sample.ods b/tests/fixtures/sample.ods deleted file mode 100644 index ecc97b12..00000000 Binary files a/tests/fixtures/sample.ods and /dev/null differ diff --git a/tests/fixtures/sample.odt b/tests/fixtures/sample.odt deleted file mode 100644 index 4cb6f2f1..00000000 Binary files a/tests/fixtures/sample.odt and /dev/null differ diff --git a/tests/fixtures/sample.pdf b/tests/fixtures/sample.pdf deleted file mode 100644 index 0293578a..00000000 Binary files a/tests/fixtures/sample.pdf and /dev/null differ diff --git a/tests/fixtures/sample.svg b/tests/fixtures/sample.svg deleted file mode 100644 index 99c924a8..00000000 --- a/tests/fixtures/sample.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/tests/macros/test_missing_files.py b/tests/macros/test_missing_files.py index bfbe0667..ba795db1 100644 --- a/tests/macros/test_missing_files.py +++ b/tests/macros/test_missing_files.py @@ -3,7 +3,7 @@ from tempfile import TemporaryDirectory import pytest -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.utils.missing_files import MissingRegistry @@ -28,5 +28,5 @@ def test_refresh_missing_files(library: Library): assert list(registry.fix_unlinked_entries()) == [0, 1] # `bar.md` should be relinked to new correct path - results = library.search_library(FilterState.from_path("bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results[0].path == Path("bar.md") diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png deleted file mode 100644 index 2b5a2581..00000000 Binary files a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png and /dev/null differ diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png deleted file mode 100644 index 5e749f2d..00000000 Binary files a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png and /dev/null differ diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png deleted file mode 100644 index ac2158e7..00000000 Binary files a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png and /dev/null differ diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png deleted file mode 100644 index 0ba9ea61..00000000 Binary files a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png and /dev/null differ diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png deleted file mode 100644 index ebd90431..00000000 Binary files a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png and /dev/null differ diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 84ef20f1..c903b7f6 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -79,11 +79,11 @@ def test_file_path_display( for i, part in enumerate(display_path.parts): part_ = part.strip(os.path.sep) if i != len(display_path.parts) - 1: - file_str += f"{"\u200b".join(part_)}{separator}" + file_str += f"{'\u200b'.join(part_)}{separator}" else: if file_str != "": file_str += "
" - file_str += f"{"\u200b".join(part_)}" + file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly assert panel.file_attrs.file_label.text() == file_str @@ -135,7 +135,7 @@ def test_title_update( qt_driver.folders_to_tags_action = QAction(menu_bar) # Trigger the update - qt_driver.init_library(library_dir, open_status) + qt_driver._init_library(library_dir, open_status) # Assert the title is updated correctly qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) diff --git a/tests/qt/test_global_settings.py b/tests/qt/test_global_settings.py index cc24f1ab..8325c154 100644 --- a/tests/qt/test_global_settings.py +++ b/tests/qt/test_global_settings.py @@ -5,7 +5,7 @@ from tagstudio.core.global_settings import GlobalSettings, Theme def test_read_settings(library_dir: Path): settings_path = library_dir / "settings.toml" - with open(settings_path, "a") as settings_file: + with open(settings_path, "w") as settings_file: settings_file.write(""" language = "de" open_last_loaded_on_startup = true @@ -14,6 +14,9 @@ def test_read_settings(library_dir: Path): page_size = 1337 show_filepath = 0 dark_mode = 2 + date_format = "%x" + hour_format = true + zero_padding = true """) settings = GlobalSettings.read_settings(settings_path) @@ -24,3 +27,6 @@ def test_read_settings(library_dir: Path): assert settings.page_size == 1337 assert settings.show_filepath == 0 assert settings.theme == Theme.SYSTEM + assert settings.date_format == "%x" + assert settings.hour_format + assert settings.zero_padding diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py index 6081450b..2dcc363a 100644 --- a/tests/qt/test_qt_driver.py +++ b/tests/qt/test_qt_driver.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.json.library import ItemType from tagstudio.qt.widgets.item_thumb import ItemThumb @@ -66,7 +66,7 @@ if TYPE_CHECKING: # assert qt_driver.selected == [0, 1, 2] -def test_library_state_update(qt_driver: "QtDriver"): +def test_browsing_state_update(qt_driver: "QtDriver"): # Given for entry in qt_driver.lib.get_entries(with_joins=True): thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100)) @@ -74,27 +74,25 @@ def test_library_state_update(qt_driver: "QtDriver"): qt_driver.frame_content.append(entry) # no filter, both items are returned - qt_driver.filter_items() + qt_driver.update_browsing_state() assert len(qt_driver.frame_content) == 2 # filter by tag - state = FilterState.from_tag_name("foo", page_size=10) - qt_driver.filter_items(state) - assert qt_driver.filter.page_size == 10 + state = BrowsingState.from_tag_name("foo") + qt_driver.update_browsing_state(state) assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "foo" # When state is not changed, previous one is still applied - qt_driver.filter_items() - assert qt_driver.filter.page_size == 10 + qt_driver.update_browsing_state() assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "foo" # When state property is changed, previous one is overwritten - state = FilterState.from_path("*bar.md", page_size=qt_driver.settings.page_size) - qt_driver.filter_items(state) + state = BrowsingState.from_path("*bar.md") + qt_driver.update_browsing_state(state) assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "bar" diff --git a/tests/qt/test_thumb_renderer.py b/tests/qt/test_thumb_renderer.py deleted file mode 100644 index 721d0ffc..00000000 --- a/tests/qt/test_thumb_renderer.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import io -from functools import partial -from pathlib import Path - -import pytest -from PIL import Image -from syrupy.extensions.image import PNGImageSnapshotExtension - -from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer - - -@pytest.mark.parametrize( - ["fixture_file", "thumbnailer"], - [ - ( - "sample.odt", - ThumbRenderer._open_doc_thumb, - ), - ( - "sample.ods", - ThumbRenderer._open_doc_thumb, - ), - ( - "sample.epub", - ThumbRenderer._epub_cover, - ), - ( - "sample.pdf", - partial(ThumbRenderer._pdf_thumb, size=200), - ), - ( - "sample.svg", - partial(ThumbRenderer._image_vector_thumb, size=200), - ), - ], -) -def test_preview_render(cwd, fixture_file, thumbnailer, snapshot): - file_path: Path = cwd / "fixtures" / fixture_file - img: Image.Image = thumbnailer(file_path) - - img_bytes = io.BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) diff --git a/tests/test_library.py b/tests/test_library.py index 498db3d0..f609a4ec 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,7 +4,7 @@ from tempfile import TemporaryDirectory import pytest from tagstudio.core.enums import DefaultEnum, LibraryPrefs -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.fields import TextField, _FieldID from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -123,7 +123,8 @@ def test_library_search(library: Library, generate_tag, entry_full): tag = list(entry_full.tags)[0] results = library.search_library( - FilterState.from_tag_name(tag.name, page_size=500), + BrowsingState.from_tag_name(tag.name), + page_size=500, ) assert results.total_count == 1 @@ -152,7 +153,7 @@ def test_entries_count(library: Library): new_ids = library.add_entries(entries) assert len(new_ids) == 10 - results = library.search_library(FilterState.show_all(page_size=5)) + results = library.search_library(BrowsingState.show_all(), page_size=5) assert results.total_count == 12 assert len(results) == 5 @@ -199,9 +200,7 @@ def test_search_filter_extensions(library: Library, is_exclude: bool): library.set_prefs(LibraryPrefs.EXTENSION_LIST, ["md"]) # When - results = library.search_library( - FilterState.show_all(page_size=500), - ) + results = library.search_library(BrowsingState.show_all(), page_size=500) # Then assert results.total_count == 1 @@ -221,7 +220,8 @@ def test_search_library_case_insensitive(library: Library): # When results = library.search_library( - FilterState.from_tag_name(tag.name.upper(), page_size=500), + BrowsingState.from_tag_name(tag.name.upper()), + page_size=500, ) # Then @@ -443,100 +443,102 @@ def test_library_prefs_multiple_identical_vals(): def test_path_search_ilike(library: Library): - results = library.search_library(FilterState.from_path("bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_like(library: Library): - results = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) + results = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) assert results.total_count == 0 assert len(results.items) == 0 def test_path_search_default_with_sep(library: Library): - results = library.search_library(FilterState.from_path("one/two", page_size=500)) + results = library.search_library(BrowsingState.from_path("one/two"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_after(library: Library): - results = library.search_library(FilterState.from_path("foo*", page_size=500)) + results = library.search_library(BrowsingState.from_path("foo*"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_in_front(library: Library): - results = library.search_library(FilterState.from_path("*bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("*bar.md"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_both_sides(library: Library): - results = library.search_library(FilterState.from_path("*one/two*", page_size=500)) + results = library.search_library(BrowsingState.from_path("*one/two*"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 +# TODO: deduplicate this code with pytest parametrisation or a for loop def test_path_search_ilike_glob_equality(library: Library): - results_ilike = library.search_library(FilterState.from_path("one/two", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*one/two*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("one/two"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*one/two*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None +# TODO: isn't this the exact same as the one before? def test_path_search_like_glob_equality(library: Library): - results_ilike = library.search_library(FilterState.from_path("ONE/two", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*ONE/two*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("ONE/two"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*ONE/two*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*BAR.MD*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*BAR.MD*"), page_size=500) assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] results_ilike, results_glob = None, None @pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)]) def test_filetype_search(library: Library, filetype, num_of_filetype): - results = library.search_library(FilterState.from_filetype(filetype, page_size=500)) + results = library.search_library(BrowsingState.from_filetype(filetype), page_size=500) assert len(results.items) == num_of_filetype @pytest.mark.parametrize(["filetype", "num_of_filetype"], [("png", 2), ("apng", 1), ("ng", 0)]) def test_filetype_return_one_filetype(file_mediatypes_library: Library, filetype, num_of_filetype): results = file_mediatypes_library.search_library( - FilterState.from_filetype(filetype, page_size=500) + BrowsingState.from_filetype(filetype), page_size=500 ) assert len(results.items) == num_of_filetype @pytest.mark.parametrize(["mediatype", "num_of_mediatype"], [("plaintext", 2), ("image", 0)]) def test_mediatype_search(library: Library, mediatype, num_of_mediatype): - results = library.search_library(FilterState.from_mediatype(mediatype, page_size=500)) + results = library.search_library(BrowsingState.from_mediatype(mediatype), page_size=500) assert len(results.items) == num_of_mediatype diff --git a/tests/test_search.py b/tests/test_search.py index f8828819..bdd94834 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,12 +1,12 @@ import pytest -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.query_lang.util import ParsingError def verify_count(lib: Library, query: str, count: int): - results = lib.search_library(FilterState.from_search_query(query, page_size=500)) + results = lib.search_library(BrowsingState.from_search_query(query), page_size=500) assert results.total_count == count assert len(results.items) == count @@ -136,4 +136,4 @@ def test_parent_tags(search_library: Library, query: str, count: int): ) def test_syntax(search_library: Library, invalid_query: str): with pytest.raises(ParsingError) as e_info: # noqa: F841 - search_library.search_library(FilterState.from_search_query(invalid_query, page_size=500)) + search_library.search_library(BrowsingState.from_search_query(invalid_query), page_size=500)