Merge branch 'Alpha-v9.5.3'

This commit is contained in:
Travis Abendshien
2025-06-04 01:35:25 -07:00
53 changed files with 603 additions and 416 deletions

View File

@@ -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
View File

@@ -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": {

View File

@@ -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; };

View File

@@ -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

View 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

View File

@@ -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"

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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",

View File

@@ -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]:

View File

@@ -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__

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -122,9 +122,5 @@
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"placeholder_mp4": {
"path": "qt/videos/placeholder.mp4",
"mode": "rb"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)),
)
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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",

View File

@@ -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",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)