mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-10 20:02:29 +00:00
Merge branch 'Alpha-v9.5.3'
This commit is contained in:
8
.github/workflows/ruff.yaml
vendored
8
.github/workflows/ruff.yaml
vendored
@@ -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
|
||||
|
||||
12
flake.lock
generated
12
flake.lock
generated
@@ -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": {
|
||||
|
||||
33
flake.nix
33
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; };
|
||||
|
||||
@@ -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
|
||||
|
||||
81
nix/package/shiboken6-fix-include-qt-headers.patch
Normal file
81
nix/package/shiboken6-fix-include-qt-headers.patch
Normal file
@@ -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 <QtCore/QStandardPaths>
|
||||
#include <QtCore/QStringList>
|
||||
#include <QtCore/QVersionNumber>
|
||||
+#include <QtCore/QRegularExpression>
|
||||
|
||||
#include <clang-c/Index.h>
|
||||
|
||||
@@ -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()
|
||||
// <type_traits> 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"<span style='color:{red}'>{Translations["generic.missing"]}</span>"
|
||||
missing = f"<span style='color:{red}'>{Translations['generic.missing']}</span>"
|
||||
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
|
||||
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"]}<br><br>{status}")
|
||||
self.setText(f"{Translations['ffmpeg.missing.description']}<br><br>{status}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -122,9 +122,5 @@
|
||||
"thumb_loading": {
|
||||
"path": "qt/images/thumb_loading.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"placeholder_mp4": {
|
||||
"path": "qt/videos/placeholder.mp4",
|
||||
"mode": "rb"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<i>Mixed Data</i>"
|
||||
@@ -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 = "<i>Mixed Data</i>"
|
||||
@@ -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]
|
||||
|
||||
@@ -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"<b>{Translations['file.date_created']}:</b> {dt.strftime(created, '%a, %x, %X')}"
|
||||
f"<b>{Translations['file.date_created']}:</b>"
|
||||
+ f" {self.driver.settings.format_datetime(created)}"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>{Translations['file.date_modified']}:</b> "
|
||||
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}</b>"
|
||||
file_str += f"{'\u200b'.join(part_)}{separator}</b>"
|
||||
else:
|
||||
if file_str != "":
|
||||
file_str += "<br>"
|
||||
file_str += f"<b>{"\u200b".join(part_)}</b>"
|
||||
file_str += f"<b>{'\u200b'.join(part_)}</b>"
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
tests/fixtures/sample.epub
vendored
BIN
tests/fixtures/sample.epub
vendored
Binary file not shown.
BIN
tests/fixtures/sample.ods
vendored
BIN
tests/fixtures/sample.ods
vendored
Binary file not shown.
BIN
tests/fixtures/sample.odt
vendored
BIN
tests/fixtures/sample.odt
vendored
Binary file not shown.
BIN
tests/fixtures/sample.pdf
vendored
BIN
tests/fixtures/sample.pdf
vendored
Binary file not shown.
8
tests/fixtures/sample.svg
vendored
8
tests/fixtures/sample.svg
vendored
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 739 739" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M392.721,715.585C422.869,745.733 471.822,745.733 501.97,715.585C521.252,696.304 698.205,519.351 715.585,501.97C745.733,471.822 745.733,422.869 715.585,392.721C702.356,379.491 380.066,58.201 366.836,44.972C353.638,31.774 336.836,24.354 319.586,22.707C305.002,21.315 93.263,0.703 81.113,0.097C60.067,-0.954 38.675,6.558 22.616,22.616C7.307,37.926 -0.233,58.083 0.005,78.166C0.158,90.957 21.834,301.853 22.429,315.577C23.238,334.217 30.753,352.617 44.972,366.836C64.714,386.578 372.979,695.844 392.721,715.585ZM221.206,462.624C199.538,440.957 109.62,350.947 109.62,350.947C95.375,336.702 95.375,315.292 109.62,301.047L301.047,109.62C315.292,95.375 336.702,95.375 350.947,109.62C350.947,109.62 557.592,316.079 616.139,374.813C679.714,438.589 568.691,438.77 585.773,495.797C588.986,506.52 600.846,531.699 594.432,548.233C579.351,587.118 529.349,575.43 521.799,548.233C515.39,525.148 528.92,514.925 524.759,499.813C517.88,474.829 491.657,481.134 483.903,499.813C458.989,559.836 503.029,591.37 493.423,628.195C479.788,680.463 410.137,673.342 400.675,628.195C392.608,589.708 425.265,574.286 421.93,520.583C419.956,488.804 394.552,464.882 377.567,488.023C360.792,510.879 402.256,542.428 373.64,575.065C341.566,611.644 283.344,579.898 301.047,528.499C311.597,497.869 326.263,466.649 311.901,462.624C282.374,454.351 267.942,509.361 221.206,462.624ZM180.995,84.554C205.968,109.528 205.968,150.079 180.995,175.052C156.022,200.026 115.471,200.026 90.497,175.052C65.524,150.079 65.524,109.528 90.497,84.554C115.471,59.581 156.022,59.581 180.995,84.554Z"/>
|
||||
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
|
||||
<path d="M733.962,560.164C740.987,553.139 740.987,541.733 733.962,534.708L482.173,282.92C475.148,275.895 463.742,275.895 456.717,282.92L431.261,308.376C424.237,315.4 424.237,326.807 431.261,333.831L683.05,585.62C690.075,592.645 701.481,592.645 708.506,585.62L733.962,560.164ZM439.639,559.207C446.664,552.182 446.664,540.776 439.639,533.751L335.491,429.602C328.466,422.578 317.059,422.578 310.035,429.602L284.579,455.058C277.554,462.083 277.554,473.489 284.579,480.514L388.728,584.663C395.752,591.688 407.159,591.688 414.184,584.663L439.639,559.207ZM584.306,556.624C591.331,549.599 591.331,538.192 584.306,531.168L409.115,355.977C402.091,348.953 390.684,348.953 383.66,355.977L358.204,381.433C351.179,388.458 351.179,399.864 358.204,406.889L533.394,582.079C540.419,589.104 551.825,589.104 558.85,582.079L584.306,556.624ZM298.425,246.543C311.081,259.198 311.081,279.747 298.425,292.402C285.77,305.058 265.221,305.058 252.566,292.402C239.911,279.747 239.911,259.198 252.566,246.543C265.221,233.888 285.77,233.888 298.425,246.543Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -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")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -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}</b>"
|
||||
file_str += f"{'\u200b'.join(part_)}{separator}</b>"
|
||||
else:
|
||||
if file_str != "":
|
||||
file_str += "<br>"
|
||||
file_str += f"<b>{"\u200b".join(part_)}</b>"
|
||||
file_str += f"<b>{'\u200b'.join(part_)}</b>"
|
||||
|
||||
# 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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user