Compare commits

..

15 Commits

Author SHA1 Message Date
Travis Abendshien
a0bb5f44e0 chore: merge main into pyright-alchemy 2025-09-06 03:25:23 -07:00
Travis Abendshien
7a8d34e190 feat(ui): add thumb cache size setting to settings panel (#1088)
* feat: add thumb cache size setting to settings panel

* refactor: change names in cache_manager.py to be less ambiguous, more descriptive

* refactor: store cache size in MiB instead of bytes
2025-09-05 16:04:06 -07:00
Travis Abendshien
3374f6b07f fix: add option to use old Windows 'start' command (#1084) 2025-09-05 13:44:52 -07:00
Travis Abendshien
eecb4d3e38 fix: account for leading slash pattern in wcmatch (#1092) 2025-09-05 13:38:02 -07:00
Travis Abendshien
583d107cb8 fix: reorder renderer types to fix early false positives (#1093) 2025-09-05 12:44:51 -07:00
Travis Abendshien
2db8bed304 translations: add Czech, Portuguese (Portugal), and Romanian in UI 2025-09-04 15:18:07 -07:00
Travis Abendshien
01680cab34 fix: update SQL_FILENAME to import from new constant (#1094) 2025-09-04 13:30:45 -07:00
Travis Abendshien
bbb17285e7 chore: merge main into pyright-alchemy 2025-09-03 16:09:58 -07:00
Travis Abendshien
ccd7ce136e Revert "fix: reorder renderer types to fix early false positives"
This reverts commit 25f85bf443.
2025-09-03 02:26:20 -07:00
Travis Abendshien
25f85bf443 fix: reorder renderer types to fix early false positives 2025-09-03 02:23:14 -07:00
Travis Abendshien
46f7edf6e8 chore: fix tests 2025-08-29 16:26:09 -07:00
Travis Abendshien
745fea6b85 refactor: addresss type hints in query_lang 2025-08-29 16:22:31 -07:00
Travis Abendshien
668ac23a86 chore: add json library files to pyright ignore 2025-08-29 16:22:31 -07:00
Travis Abendshien
8e8f416246 refactor: fix type hints in db.py 2025-08-29 16:22:31 -07:00
Travis Abendshien
218aa9e0d1 refactor: merge cyclicly imported files into library.py 2025-08-29 16:22:28 -07:00
57 changed files with 1671 additions and 1604 deletions

View File

@@ -85,7 +85,11 @@ ignore_errors = true
qt_api = "pyside6"
[tool.pyright]
ignore = ["src/tagstudio/qt/helpers/vendored/pydub/", ".venv/**"]
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/qt/helpers/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
reportAny = false
reportIgnoreCommentWithoutRule = false

View File

@@ -12,7 +12,6 @@ class SettingItems(str, enum.Enum):
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
class ShowFilepathOption(int, enum.Enum):

View File

@@ -13,21 +13,16 @@ from pydantic import BaseModel, Field
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
logger = structlog.get_logger(__name__)
DEFAULT_GLOBAL_SETTINGS_PATH = (
Path.home() / "Appdata" / "Roaming" / "TagStudio" / "settings.toml"
if platform.system() == "Windows"
else Path.home() / ".config" / "TagStudio" / "settings.toml"
)
logger = structlog.get_logger(__name__)
class TomlEnumEncoder(toml.TomlEncoder):
@override
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
if isinstance(v, Enum):
return super().dump_value(v.value)
return super().dump_value(v)
DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
class Theme(IntEnum):
@@ -45,6 +40,14 @@ class Splash(StrEnum):
NINETY_FIVE = "95"
class TomlEnumEncoder(toml.TomlEncoder):
@override
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
if isinstance(v, Enum):
return super().dump_value(v.value)
return super().dump_value(v)
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
# properties to be overwritten with environment variables. As TagStudio is not currently using
# environment variables, this was not based on that, but that may be useful in the future.
@@ -52,6 +55,7 @@ class GlobalSettings(BaseModel):
language: str = Field(default="en")
open_last_loaded_on_startup: bool = Field(default=True)
generate_thumbs: bool = Field(default=True)
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
autoplay: bool = Field(default=True)
loop: bool = Field(default=True)
show_filenames_in_grid: bool = Field(default=True)
@@ -60,6 +64,7 @@ class GlobalSettings(BaseModel):
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
theme: Theme = Field(default=Theme.SYSTEM)
splash: Splash = Field(default=Splash.DEFAULT)
windows_start_command: bool = Field(default=False)
date_format: str = Field(default="%x")
hour_format: bool = Field(default=True)

View File

@@ -23,3 +23,14 @@ WITH RECURSIVE ChildTags AS (
)
SELECT * FROM ChildTags;
""")
TAG_CHILDREN_ID_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT tag_id FROM ChildTags;
""")

View File

@@ -4,6 +4,7 @@
from pathlib import Path
from typing import override
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
@@ -19,12 +20,14 @@ class PathType(TypeDecorator):
impl = String
cache_ok = True
def process_bind_param(self, value: Path, dialect: Dialect):
@override
def process_bind_param(self, value: Path | None, dialect: Dialect):
if value is not None:
return Path(value).as_posix()
return None
def process_result_value(self, value: str, dialect: Dialect):
@override
def process_result_value(self, value: str | None, dialect: Dialect):
if value is not None:
return Path(value)
return None

View File

@@ -1,572 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from tagstudio.core.library.alchemy.models import Namespace, TagColorGroup
logger = structlog.get_logger(__name__)
def namespaces() -> list[Namespace]:
tagstudio_standard = Namespace("tagstudio-standard", "TagStudio Standard")
tagstudio_pastels = Namespace("tagstudio-pastels", "TagStudio Pastels")
tagstudio_shades = Namespace("tagstudio-shades", "TagStudio Shades")
tagstudio_earth_tones = Namespace("tagstudio-earth-tones", "TagStudio Earth Tones")
tagstudio_grayscale = Namespace("tagstudio-grayscale", "TagStudio Grayscale")
tagstudio_neon = Namespace("tagstudio-neon", "TagStudio Neon")
return [
tagstudio_standard,
tagstudio_pastels,
tagstudio_shades,
tagstudio_earth_tones,
tagstudio_grayscale,
tagstudio_neon,
]
def json_to_sql_color(json_color: str) -> tuple[str | None, str | None]:
"""Convert a color string from a <=9.4 JSON library to a 9.5+ (namespace, slug) tuple."""
json_color_ = json_color.lower()
match json_color_:
case "black":
return ("tagstudio-grayscale", "black")
case "dark gray":
return ("tagstudio-grayscale", "dark-gray")
case "gray":
return ("tagstudio-grayscale", "gray")
case "light gray":
return ("tagstudio-grayscale", "light-gray")
case "white":
return ("tagstudio-grayscale", "white")
case "light pink":
return ("tagstudio-pastels", "light-pink")
case "pink":
return ("tagstudio-standard", "pink")
case "magenta":
return ("tagstudio-standard", "magenta")
case "red":
return ("tagstudio-standard", "red")
case "red orange":
return ("tagstudio-standard", "red-orange")
case "salmon":
return ("tagstudio-pastels", "salmon")
case "orange":
return ("tagstudio-standard", "orange")
case "yellow orange":
return ("tagstudio-standard", "amber")
case "yellow":
return ("tagstudio-standard", "yellow")
case "mint":
return ("tagstudio-pastels", "mint")
case "lime":
return ("tagstudio-standard", "lime")
case "light green":
return ("tagstudio-pastels", "light-green")
case "green":
return ("tagstudio-standard", "green")
case "teal":
return ("tagstudio-standard", "teal")
case "cyan":
return ("tagstudio-standard", "cyan")
case "light blue":
return ("tagstudio-pastels", "light-blue")
case "blue":
return ("tagstudio-standard", "blue")
case "blue violet":
return ("tagstudio-shades", "navy")
case "violet":
return ("tagstudio-standard", "indigo")
case "purple":
return ("tagstudio-standard", "purple")
case "peach":
return ("tagstudio-earth-tones", "peach")
case "brown":
return ("tagstudio-earth-tones", "brown")
case "lavender":
return ("tagstudio-pastels", "lavender")
case "blonde":
return ("tagstudio-earth-tones", "blonde")
case "auburn":
return ("tagstudio-shades", "auburn")
case "light brown":
return ("tagstudio-earth-tones", "light-brown")
case "dark brown":
return ("tagstudio-earth-tones", "dark-brown")
case "cool gray":
return ("tagstudio-earth-tones", "cool-gray")
case "warm gray":
return ("tagstudio-earth-tones", "warm-gray")
case "olive":
return ("tagstudio-shades", "olive")
case "berry":
return ("tagstudio-shades", "berry")
case _:
return (None, None)
def standard() -> list[TagColorGroup]:
red = TagColorGroup(
slug="red",
namespace="tagstudio-standard",
name="Red",
primary="#E22C3C",
)
red_orange = TagColorGroup(
slug="red-orange",
namespace="tagstudio-standard",
name="Red Orange",
primary="#E83726",
)
orange = TagColorGroup(
slug="orange",
namespace="tagstudio-standard",
name="Orange",
primary="#ED6022",
)
amber = TagColorGroup(
slug="amber",
namespace="tagstudio-standard",
name="Amber",
primary="#FA9A2C",
)
yellow = TagColorGroup(
slug="yellow",
namespace="tagstudio-standard",
name="Yellow",
primary="#FFD63D",
)
lime = TagColorGroup(
slug="lime",
namespace="tagstudio-standard",
name="Lime",
primary="#92E649",
)
green = TagColorGroup(
slug="green",
namespace="tagstudio-standard",
name="Green",
primary="#45D649",
)
teal = TagColorGroup(
slug="teal",
namespace="tagstudio-standard",
name="Teal",
primary="#22D589",
)
cyan = TagColorGroup(
slug="cyan",
namespace="tagstudio-standard",
name="Cyan",
primary="#3DDBDB",
)
blue = TagColorGroup(
slug="blue",
namespace="tagstudio-standard",
name="Blue",
primary="#3B87F0",
)
indigo = TagColorGroup(
slug="indigo",
namespace="tagstudio-standard",
name="Indigo",
primary="#874FF5",
)
purple = TagColorGroup(
slug="purple",
namespace="tagstudio-standard",
name="Purple",
primary="#BB4FF0",
)
magenta = TagColorGroup(
slug="magenta",
namespace="tagstudio-standard",
name="Magenta",
primary="#F64680",
)
pink = TagColorGroup(
slug="pink",
namespace="tagstudio-standard",
name="Pink",
primary="#FF62AF",
)
return [
red,
red_orange,
orange,
amber,
yellow,
lime,
green,
teal,
cyan,
blue,
indigo,
purple,
pink,
magenta,
]
def pastels() -> list[TagColorGroup]:
coral = TagColorGroup(
slug="coral",
namespace="tagstudio-pastels",
name="Coral",
primary="#F2525F",
)
salmon = TagColorGroup(
slug="salmon",
namespace="tagstudio-pastels",
name="Salmon",
primary="#F66348",
)
light_orange = TagColorGroup(
slug="light-orange",
namespace="tagstudio-pastels",
name="Light Orange",
primary="#FF9450",
)
light_amber = TagColorGroup(
slug="light-amber",
namespace="tagstudio-pastels",
name="Light Amber",
primary="#FFBA57",
)
light_yellow = TagColorGroup(
slug="light-yellow",
namespace="tagstudio-pastels",
name="Light Yellow",
primary="#FFE173",
)
light_lime = TagColorGroup(
slug="light-lime",
namespace="tagstudio-pastels",
name="Light Lime",
primary="#C9FF7A",
)
light_green = TagColorGroup(
slug="light-green",
namespace="tagstudio-pastels",
name="Light Green",
primary="#81FF76",
)
mint = TagColorGroup(
slug="mint",
namespace="tagstudio-pastels",
name="Mint",
primary="#68FFB4",
)
sky_blue = TagColorGroup(
slug="sky-blue",
namespace="tagstudio-pastels",
name="Sky Blue",
primary="#8EFFF4",
)
light_blue = TagColorGroup(
slug="light-blue",
namespace="tagstudio-pastels",
name="Light Blue",
primary="#64C6FF",
)
lavender = TagColorGroup(
slug="lavender",
namespace="tagstudio-pastels",
name="Lavender",
primary="#908AF6",
)
lilac = TagColorGroup(
slug="lilac",
namespace="tagstudio-pastels",
name="Lilac",
primary="#DF95FF",
)
light_pink = TagColorGroup(
slug="light-pink",
namespace="tagstudio-pastels",
name="Light Pink",
primary="#FF87BA",
)
return [
coral,
salmon,
light_orange,
light_amber,
light_yellow,
light_lime,
light_green,
mint,
sky_blue,
light_blue,
lavender,
lilac,
light_pink,
]
def shades() -> list[TagColorGroup]:
burgundy = TagColorGroup(
slug="burgundy",
namespace="tagstudio-shades",
name="Burgundy",
primary="#6E1C24",
)
auburn = TagColorGroup(
slug="auburn",
namespace="tagstudio-shades",
name="Auburn",
primary="#A13220",
)
olive = TagColorGroup(
slug="olive",
namespace="tagstudio-shades",
name="Olive",
primary="#4C652E",
)
dark_teal = TagColorGroup(
slug="dark-teal",
namespace="tagstudio-shades",
name="Dark Teal",
primary="#1F5E47",
)
navy = TagColorGroup(
slug="navy",
namespace="tagstudio-shades",
name="Navy",
primary="#104B98",
)
dark_lavender = TagColorGroup(
slug="dark_lavender",
namespace="tagstudio-shades",
name="Dark Lavender",
primary="#3D3B6C",
)
berry = TagColorGroup(
slug="berry",
namespace="tagstudio-shades",
name="Berry",
primary="#9F2AA7",
)
return [burgundy, auburn, olive, dark_teal, navy, dark_lavender, berry]
def earth_tones() -> list[TagColorGroup]:
dark_brown = TagColorGroup(
slug="dark-brown",
namespace="tagstudio-earth-tones",
name="Dark Brown",
primary="#4C2315",
)
brown = TagColorGroup(
slug="brown",
namespace="tagstudio-earth-tones",
name="Brown",
primary="#823216",
)
light_brown = TagColorGroup(
slug="light-brown",
namespace="tagstudio-earth-tones",
name="Light Brown",
primary="#BE5B2D",
)
blonde = TagColorGroup(
slug="blonde",
namespace="tagstudio-earth-tones",
name="Blonde",
primary="#EFC664",
)
peach = TagColorGroup(
slug="peach",
namespace="tagstudio-earth-tones",
name="Peach",
primary="#F1C69C",
)
warm_gray = TagColorGroup(
slug="warm-gray",
namespace="tagstudio-earth-tones",
name="Warm Gray",
primary="#625550",
)
cool_gray = TagColorGroup(
slug="cool-gray",
namespace="tagstudio-earth-tones",
name="Cool Gray",
primary="#515768",
)
return [dark_brown, brown, light_brown, blonde, peach, warm_gray, cool_gray]
def grayscale() -> list[TagColorGroup]:
black = TagColorGroup(
slug="black",
namespace="tagstudio-grayscale",
name="Black",
primary="#111018",
)
dark_gray = TagColorGroup(
slug="dark-gray",
namespace="tagstudio-grayscale",
name="Dark Gray",
primary="#242424",
)
gray = TagColorGroup(
slug="gray",
namespace="tagstudio-grayscale",
name="Gray",
primary="#53525A",
)
light_gray = TagColorGroup(
slug="light-gray",
namespace="tagstudio-grayscale",
name="Light Gray",
primary="#AAAAAA",
)
white = TagColorGroup(
slug="white",
namespace="tagstudio-grayscale",
name="White",
primary="#F2F1F8",
)
return [black, dark_gray, gray, light_gray, white]
def neon() -> list[TagColorGroup]:
neon_red = TagColorGroup(
slug="neon-red",
namespace="tagstudio-neon",
name="Neon Red",
primary="#180607",
secondary="#E22C3C",
color_border=True,
)
neon_red_orange = TagColorGroup(
slug="neon-red-orange",
namespace="tagstudio-neon",
name="Neon Red Orange",
primary="#220905",
secondary="#E83726",
color_border=True,
)
neon_orange = TagColorGroup(
slug="neon-orange",
namespace="tagstudio-neon",
name="Neon Orange",
primary="#1F0D05",
secondary="#ED6022",
color_border=True,
)
neon_amber = TagColorGroup(
slug="neon-amber",
namespace="tagstudio-neon",
name="Neon Amber",
primary="#251507",
secondary="#FA9A2C",
color_border=True,
)
neon_yellow = TagColorGroup(
slug="neon-yellow",
namespace="tagstudio-neon",
name="Neon Yellow",
primary="#2B1C0B",
secondary="#FFD63D",
color_border=True,
)
neon_lime = TagColorGroup(
slug="neon-lime",
namespace="tagstudio-neon",
name="Neon Lime",
primary="#1B220C",
secondary="#92E649",
color_border=True,
)
neon_green = TagColorGroup(
slug="neon-green",
namespace="tagstudio-neon",
name="Neon Green",
primary="#091610",
secondary="#45D649",
color_border=True,
)
neon_teal = TagColorGroup(
slug="neon-teal",
namespace="tagstudio-neon",
name="Neon Teal",
primary="#09191D",
secondary="#22D589",
color_border=True,
)
neon_cyan = TagColorGroup(
slug="neon-cyan",
namespace="tagstudio-neon",
name="Neon Cyan",
primary="#0B191C",
secondary="#3DDBDB",
color_border=True,
)
neon_blue = TagColorGroup(
slug="neon-blue",
namespace="tagstudio-neon",
name="Neon Blue",
primary="#09101C",
secondary="#3B87F0",
color_border=True,
)
neon_indigo = TagColorGroup(
slug="neon-indigo",
namespace="tagstudio-neon",
name="Neon Indigo",
primary="#150B24",
secondary="#874FF5",
color_border=True,
)
neon_purple = TagColorGroup(
slug="neon-purple",
namespace="tagstudio-neon",
name="Neon Purple",
primary="#1E0B26",
secondary="#BB4FF0",
color_border=True,
)
neon_magenta = TagColorGroup(
slug="neon-magenta",
namespace="tagstudio-neon",
name="Neon Magenta",
primary="#220A13",
secondary="#F64680",
color_border=True,
)
neon_pink = TagColorGroup(
slug="neon-pink",
namespace="tagstudio-neon",
name="Neon Pink",
primary="#210E15",
secondary="#FF62AF",
color_border=True,
)
neon_white = TagColorGroup(
slug="neon-white",
namespace="tagstudio-neon",
name="Neon White",
primary="#131315",
secondary="#F2F1F8",
color_border=True,
)
return [
neon_red,
neon_red_orange,
neon_orange,
neon_amber,
neon_yellow,
neon_lime,
neon_green,
neon_teal,
neon_cyan,
neon_blue,
neon_indigo,
neon_purple,
neon_pink,
neon_magenta,
neon_white,
]

View File

@@ -1,140 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry, ValueType
class BaseField(Base):
__abstract__ = True
@declared_attr
def id(self) -> Mapped[int]:
return mapped_column(primary_key=True, autoincrement=True)
@declared_attr
def type_key(self) -> Mapped[str]:
return mapped_column(ForeignKey("value_type.key"))
@declared_attr
def type(self) -> Mapped[ValueType]:
return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore
@declared_attr
def entry_id(self) -> Mapped[int]:
return mapped_column(ForeignKey("entries.id"))
@declared_attr
def entry(self) -> Mapped[Entry]:
return relationship(foreign_keys=[self.entry_id]) # type: ignore
@declared_attr
def position(self) -> Mapped[int]:
return mapped_column(default=0)
def __hash__(self):
return hash(self.__key())
def __key(self):
raise NotImplementedError
value: Any
class BooleanField(BaseField):
__tablename__ = "boolean_fields"
value: Mapped[bool]
def __key(self):
return (self.type, self.value)
def __eq__(self, value) -> bool:
if isinstance(value, BooleanField):
return self.__key() == value.__key()
raise NotImplementedError
class TextField(BaseField):
__tablename__ = "text_fields"
value: Mapped[str | None]
def __key(self) -> tuple:
return self.type, self.value
def __eq__(self, value) -> bool:
if isinstance(value, TextField):
return self.__key() == value.__key()
elif isinstance(value, DatetimeField):
return False
raise NotImplementedError
class DatetimeField(BaseField):
__tablename__ = "datetime_fields"
value: Mapped[str | None]
def __key(self):
return (self.type, self.value)
def __eq__(self, value) -> bool:
if isinstance(value, DatetimeField):
return self.__key() == value.__key()
raise NotImplementedError
@dataclass
class DefaultField:
id: int
name: str
type: FieldTypeEnum
is_default: bool = field(default=False)
class _FieldID(Enum):
"""Only for bootstrapping content of DB table."""
TITLE = DefaultField(id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True)
AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE)
ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE)
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX)
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE)
DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME)
DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME)
DATE_MODIFIED = DefaultField(id=12, name="Date Modified", type=FieldTypeEnum.DATETIME)
DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME)
DATE_PUBLISHED = DefaultField(id=14, name="Date Published", type=FieldTypeEnum.DATETIME)
# ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox)
# FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox)
BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE)
COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE)
SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE)
MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE)
SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE)
DATE_UPLOADED = DefaultField(id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME)
DATE_RELEASED = DefaultField(id=23, name="Date Released", type=FieldTypeEnum.DATETIME)
VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE)
ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE)
MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE)
PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE)
GUEST_ARTIST = DefaultField(id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE)
COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE)
COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE)

View File

@@ -1,23 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tagstudio.core.library.alchemy.db import Base
class TagParent(Base):
__tablename__ = "tag_parents"
parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
class TagEntry(Base):
__tablename__ = "tag_entries"
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,331 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from datetime import datetime as dt
from pathlib import Path
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import deprecated
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.fields import (
BaseField,
BooleanField,
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
class Namespace(Base):
__tablename__ = "namespaces"
namespace: Mapped[str] = mapped_column(primary_key=True, nullable=False)
name: Mapped[str] = mapped_column(nullable=False)
def __init__(
self,
namespace: str,
name: str,
):
self.namespace = namespace
self.name = name
super().__init__()
class TagAlias(Base):
__tablename__ = "tag_aliases"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
tag: Mapped["Tag"] = relationship(back_populates="aliases")
def __init__(self, name: str, tag_id: int | None = None):
self.name = name
if tag_id is not None:
self.tag_id = tag_id
super().__init__()
class TagColorGroup(Base):
__tablename__ = "tag_colors"
slug: Mapped[str] = mapped_column(primary_key=True, nullable=False)
namespace: Mapped[str] = mapped_column(
ForeignKey("namespaces.namespace"), primary_key=True, nullable=False
)
name: Mapped[str] = mapped_column()
primary: Mapped[str] = mapped_column(nullable=False)
secondary: Mapped[str | None]
color_border: Mapped[bool] = mapped_column(nullable=False, default=False)
# TODO: Determine if slug and namespace can be optional and generated/added here if needed.
def __init__(
self,
slug: str,
namespace: str,
name: str,
primary: str,
secondary: str | None = None,
color_border: bool = False,
):
self.slug = slug
self.namespace = namespace
self.name = name
self.primary = primary
if secondary:
self.secondary = secondary
self.color_border = color_border
super().__init__()
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str]
shorthand: Mapped[str | None]
color_namespace: Mapped[str | None] = mapped_column()
color_slug: Mapped[str | None] = mapped_column()
color: Mapped[TagColorGroup | None] = relationship(lazy="joined")
is_category: Mapped[bool]
icon: Mapped[str | None]
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
parent_tags: Mapped[set["Tag"]] = relationship(
secondary=TagParent.__tablename__,
primaryjoin="Tag.id == TagParent.child_id",
secondaryjoin="Tag.id == TagParent.parent_id",
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
__table_args__ = (
ForeignKeyConstraint(
[color_namespace, color_slug], [TagColorGroup.namespace, TagColorGroup.slug]
),
{"sqlite_autoincrement": True},
)
@property
def parent_ids(self) -> list[int]:
return [tag.id for tag in self.parent_tags]
@property
def alias_strings(self) -> list[str]:
return [alias.name for alias in self.aliases]
@property
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
def __init__(
self,
name: str,
id: int | None = None,
shorthand: str | None = None,
aliases: set[TagAlias] | None = None,
parent_tags: set["Tag"] | None = None,
icon: str | None = None,
color_namespace: str | None = None,
color_slug: str | None = None,
disambiguation_id: int | None = None,
is_category: bool = False,
):
self.name = name
self.aliases = aliases or set()
self.parent_tags = parent_tags or set()
self.color_namespace = color_namespace
self.color_slug = color_slug
self.icon = icon
self.shorthand = shorthand
self.disambiguation_id = disambiguation_id
self.is_category = is_category
self.id = id # pyright: ignore[reportAttributeAccessIssue]
super().__init__()
def __str__(self) -> str:
return f"<Tag ID: {self.id} Name: {self.name}>"
def __repr__(self) -> str:
return self.__str__()
def __hash__(self) -> int:
return hash(self.id)
def __lt__(self, other) -> bool:
return self.name < other.name
def __le__(self, other) -> bool:
return self.name <= other.name
def __gt__(self, other) -> bool:
return self.name > other.name
def __ge__(self, other) -> bool:
return self.name >= other.name
class Folder(Base):
__tablename__ = "folders"
# TODO - implement this
id: Mapped[int] = mapped_column(primary_key=True)
path: Mapped[Path] = mapped_column(PathType, unique=True)
uuid: Mapped[str] = mapped_column(unique=True)
class Entry(Base):
__tablename__ = "entries"
id: Mapped[int] = mapped_column(primary_key=True)
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
folder: Mapped[Folder] = relationship("Folder")
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
text_fields: Mapped[list[TextField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
datetime_fields: Mapped[list[DatetimeField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
fields.extend(self.text_fields)
fields.extend(self.datetime_fields)
fields = sorted(fields, key=lambda field: field.type.position)
return fields
@property
def is_favorite(self) -> bool:
return any(tag.id == TAG_FAVORITE for tag in self.tags)
@property
def is_archived(self) -> bool:
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
def __init__(
self,
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
) -> None:
self.path = path
self.folder = folder
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
# The date the file associated with this entry was created.
# st_birthtime on Windows and Mac, st_ctime on Linux.
self.date_created = date_created
# The date the file associated with this entry was last modified: st_mtime.
self.date_modified = date_modified
# The date this entry was added to the library.
self.date_added = date_added
for field in fields:
if isinstance(field, TextField):
self.text_fields.append(field)
elif isinstance(field, DatetimeField):
self.datetime_fields.append(field)
else:
raise ValueError(f"Invalid field type: {field}")
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
def remove_tag(self, tag: Tag) -> None:
"""Removes a Tag from the Entry."""
self.tags.remove(tag)
class ValueType(Base):
"""Define Field Types in the Library.
Example:
key: content_tags (this field is slugified `name`)
name: Content Tags (this field is human readable name)
kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox)
is_default: Should the field be present in new Entry?
order: position of the field widget in the Entry form
"""
__tablename__ = "value_type"
key: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE)
is_default: Mapped[bool]
position: Mapped[int]
# add relations to other tables
text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type")
datetime_fields: Mapped[list[DatetimeField]] = relationship(
"DatetimeField", back_populates="type"
)
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
@property
def as_field(self) -> BaseField:
FieldClass = { # noqa: N806
FieldTypeEnum.TEXT_LINE: TextField,
FieldTypeEnum.TEXT_BOX: TextField,
FieldTypeEnum.DATETIME: DatetimeField,
FieldTypeEnum.BOOLEAN: BooleanField,
}
return FieldClass[self.type](
type_key=self.key,
position=self.position,
)
@event.listens_for(ValueType, "before_insert")
def slugify_field_key(mapper, connection, target):
"""Slugify the field key before inserting into the database."""
if not target.key:
from tagstudio.core.library.alchemy.library import slugify
target.key = slugify(target.tag)
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.")
class Preferences(Base):
__tablename__ = "preferences"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[dict] = mapped_column(JSON, nullable=False)
class Version(Base):
__tablename__ = "versions"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(nullable=False, default=0)

View File

@@ -1,195 +0,0 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import re
from typing import TYPE_CHECKING
import structlog
from sqlalchemy import ColumnElement, and_, distinct, func, or_, select, text
from sqlalchemy.orm import Session
from sqlalchemy.sql.operators import ilike_op
from tagstudio.core.library.alchemy.joins import TagEntry
from tagstudio.core.library.alchemy.models import Entry, Tag, TagAlias
from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
from tagstudio.core.query_lang.ast import (
AST,
ANDList,
BaseVisitor,
Constraint,
ConstraintType,
Not,
ORList,
Property,
)
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
else:
Library = None # don't import library because of circular imports
logger = structlog.get_logger(__name__)
TAG_CHILDREN_ID_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT tag_id FROM ChildTags;
""")
def get_filetype_equivalency_list(item: str) -> list[str] | set[str]:
for s in FILETYPE_EQUIVALENTS:
if item in s:
return s
return [item]
class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
def __init__(self, lib: Library) -> None:
super().__init__()
self.lib = lib
def visit_or_list(self, node: ORList) -> ColumnElement[bool]:
tag_ids, bool_expressions = self.__separate_tags(node.elements, only_single=False)
if len(tag_ids) > 0:
bool_expressions.append(self.__entry_has_any_tags(tag_ids))
return or_(*bool_expressions)
def visit_and_list(self, node: ANDList) -> ColumnElement[bool]:
tag_ids, bool_expressions = self.__separate_tags(node.terms, only_single=True)
if len(tag_ids) > 0:
bool_expressions.append(self.__entry_has_all_tags(tag_ids))
return and_(*bool_expressions)
def visit_constraint(self, node: Constraint) -> ColumnElement[bool]:
"""Returns a Boolean Expression that is true, if the Entry satisfies the constraint."""
if len(node.properties) != 0:
raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG
if node.type == ConstraintType.Tag:
return self.__entry_has_any_tags(self.__get_tag_ids(node.value))
elif node.type == ConstraintType.TagID:
return self.__entry_has_any_tags([int(node.value)])
elif node.type == ConstraintType.Path:
ilike = False
glob = False
# Smartcase check
if node.value == node.value.lower():
ilike = True
if node.value.startswith("*") or node.value.endswith("*"):
glob = True
if ilike and glob:
logger.info("ConstraintType.Path", ilike=True, glob=True)
return func.lower(Entry.path).op("GLOB")(f"{node.value.lower()}")
elif ilike:
logger.info("ConstraintType.Path", ilike=True, glob=False)
return ilike_op(Entry.path, f"%{node.value}%")
elif glob:
logger.info("ConstraintType.Path", ilike=False, glob=True)
return Entry.path.op("GLOB")(node.value)
else:
logger.info(
"ConstraintType.Path", ilike=False, glob=False, re=re.escape(node.value)
)
return Entry.path.regexp_match(re.escape(node.value))
elif node.type == ConstraintType.MediaType:
extensions: set[str] = set[str]()
for media_cat in MediaCategories.ALL_CATEGORIES:
if node.value == media_cat.name:
extensions = extensions | media_cat.extensions
break
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
elif node.type == ConstraintType.FileType:
return or_(
*[Entry.suffix.ilike(ft) for ft in get_filetype_equivalency_list(node.value)]
)
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
if node.value.lower() == "untagged":
return ~Entry.id.in_(select(Entry.id).join(TagEntry))
# raise exception if Constraint stays unhandled
raise NotImplementedError("This type of constraint is not implemented yet")
def visit_property(self, node: Property) -> ColumnElement[bool]:
raise NotImplementedError("This should never be reached!")
def visit_not(self, node: Not) -> ColumnElement[bool]:
return ~self.visit(node.child)
def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[int]:
"""Given a tag name find the ids of all tags that this name could refer to."""
with Session(self.lib.engine) as session:
tag_ids = list(
session.scalars(
select(Tag.id)
.where(or_(Tag.name.ilike(tag_name), Tag.shorthand.ilike(tag_name)))
.union(select(TagAlias.tag_id).where(TagAlias.name.ilike(tag_name)))
)
)
if len(tag_ids) > 1:
logger.debug(
f'Tag Constraint "{tag_name}" is ambiguous, {len(tag_ids)} matching tags found',
tag_ids=tag_ids,
include_children=include_children,
)
if not include_children:
return tag_ids
outp = []
for tag_id in tag_ids:
outp.extend(list(session.scalars(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id})))
return outp
def __separate_tags(
self, terms: list[AST], only_single: bool = True
) -> tuple[list[int], list[ColumnElement[bool]]]:
tag_ids: list[int] = []
bool_expressions: list[ColumnElement[bool]] = []
for term in terms:
if isinstance(term, Constraint) and len(term.properties) == 0:
match term.type:
case ConstraintType.TagID:
try:
tag_ids.append(int(term.value))
except ValueError:
logger.error(
"[SQLBoolExpressionBuilder] Could not cast value to an int Tag ID",
value=term.value,
)
continue
case ConstraintType.Tag:
ids = self.__get_tag_ids(term.value)
if not only_single:
tag_ids.extend(ids)
continue
elif len(ids) == 1:
tag_ids.append(ids[0])
continue
bool_expressions.append(self.visit(term))
return tag_ids, bool_expressions
def __entry_has_all_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:
"""Returns Binary Expression that is true if the Entry has all provided tag ids."""
# Relational Division Query
return Entry.id.in_(
select(TagEntry.entry_id)
.where(TagEntry.tag_id.in_(tag_ids))
.group_by(TagEntry.entry_id)
.having(func.count(distinct(TagEntry.tag_id)) == len(tag_ids))
)
def __entry_has_any_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:
"""Returns Binary Expression that is true if the Entry has any of the provided tag ids."""
return Entry.id.in_(
select(TagEntry.entry_id).where(TagEntry.tag_id.in_(tag_ids)).distinct()
)

View File

@@ -0,0 +1,83 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def json_to_sql_color(json_color: str) -> tuple[str | None, str | None]:
"""Convert a color string from a <=9.4 JSON library to a 9.5+ (namespace, slug) tuple."""
json_color_ = json_color.lower()
match json_color_:
case "black":
return ("tagstudio-grayscale", "black")
case "dark gray":
return ("tagstudio-grayscale", "dark-gray")
case "gray":
return ("tagstudio-grayscale", "gray")
case "light gray":
return ("tagstudio-grayscale", "light-gray")
case "white":
return ("tagstudio-grayscale", "white")
case "light pink":
return ("tagstudio-pastels", "light-pink")
case "pink":
return ("tagstudio-standard", "pink")
case "magenta":
return ("tagstudio-standard", "magenta")
case "red":
return ("tagstudio-standard", "red")
case "red orange":
return ("tagstudio-standard", "red-orange")
case "salmon":
return ("tagstudio-pastels", "salmon")
case "orange":
return ("tagstudio-standard", "orange")
case "yellow orange":
return ("tagstudio-standard", "amber")
case "yellow":
return ("tagstudio-standard", "yellow")
case "mint":
return ("tagstudio-pastels", "mint")
case "lime":
return ("tagstudio-standard", "lime")
case "light green":
return ("tagstudio-pastels", "light-green")
case "green":
return ("tagstudio-standard", "green")
case "teal":
return ("tagstudio-standard", "teal")
case "cyan":
return ("tagstudio-standard", "cyan")
case "light blue":
return ("tagstudio-pastels", "light-blue")
case "blue":
return ("tagstudio-standard", "blue")
case "blue violet":
return ("tagstudio-shades", "navy")
case "violet":
return ("tagstudio-standard", "indigo")
case "purple":
return ("tagstudio-standard", "purple")
case "peach":
return ("tagstudio-earth-tones", "peach")
case "brown":
return ("tagstudio-earth-tones", "brown")
case "lavender":
return ("tagstudio-pastels", "lavender")
case "blonde":
return ("tagstudio-earth-tones", "blonde")
case "auburn":
return ("tagstudio-shades", "auburn")
case "light brown":
return ("tagstudio-earth-tones", "light-brown")
case "dark brown":
return ("tagstudio-earth-tones", "dark-brown")
case "cool gray":
return ("tagstudio-earth-tones", "cool-gray")
case "warm gray":
return ("tagstudio-earth-tones", "warm-gray")
case "olive":
return ("tagstudio-shades", "olive")
case "berry":
return ("tagstudio-shades", "berry")
case _:
return (None, None)

View File

@@ -44,7 +44,9 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
ignore_patterns (list[str]): The .gitignore-like patterns to convert.
"""
glob_patterns: list[str] = deepcopy(ignore_patterns)
glob_patterns_remove: list[str] = []
additional_patterns: list[str] = []
root_patterns: list[str] = []
# Mimic implicit .gitignore syntax behavior for the SQLite GLOB function.
for pattern in glob_patterns:
@@ -66,6 +68,16 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
gp = gp.removeprefix("**/").removeprefix("*/")
additional_patterns.append(exclusion_char + gp)
elif gp.startswith("/"):
# Matches "/file" case for .gitignore behavior where it should only match
# a file or folder int the root directory, and nowhere else.
glob_patterns_remove.append(gp)
gp = gp.lstrip("/")
root_patterns.append(exclusion_char + gp)
for gp in glob_patterns_remove:
glob_patterns.remove(gp)
glob_patterns = glob_patterns + additional_patterns
# Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior.
@@ -75,6 +87,7 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**")
glob_patterns = glob_patterns + root_patterns
glob_patterns = list(set(glob_patterns))
logger.info("[Ignore]", glob_patterns=glob_patterns)

View File

@@ -1,6 +1,11 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from abc import ABC, abstractmethod
from enum import Enum
from typing import Generic, TypeVar, Union
from typing import Generic, TypeVar, override
class ConstraintType(Enum):
@@ -12,7 +17,7 @@ class ConstraintType(Enum):
Special = 5
@staticmethod
def from_string(text: str) -> Union["ConstraintType", None]:
def from_string(text: str) -> "ConstraintType | None":
return {
"tag": ConstraintType.Tag,
"tag_id": ConstraintType.TagID,
@@ -24,14 +29,16 @@ class ConstraintType(Enum):
class AST:
parent: Union["AST", None] = None
parent: "AST | None" = None
@override
def __str__(self):
class_name = self.__class__.__name__
fields = vars(self) # Get all instance variables as a dictionary
field_str = ", ".join(f"{key}={value}" for key, value in fields.items())
return f"{class_name}({field_str})"
@override
def __repr__(self) -> str:
return self.__str__()

View File

@@ -1,3 +1,8 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from tagstudio.core.query_lang.ast import (
AST,
ANDList,
@@ -27,7 +32,7 @@ class Parser:
if self.next_token.type == TokenType.EOF:
return ORList([])
out = self.__or_list()
if self.next_token.type != TokenType.EOF:
if self.next_token.type != TokenType.EOF: # pyright: ignore[reportUnnecessaryComparison]
raise ParsingError(self.next_token.start, self.next_token.end, "Syntax Error")
return out
@@ -41,7 +46,7 @@ class Parser:
return ORList(terms) if len(terms) > 1 else terms[0]
def __is_next_or(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR"
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR" # pyright: ignore
def __and_list(self) -> AST:
elements = [self.__term()]
@@ -67,7 +72,7 @@ class Parser:
raise self.__syntax_error("Unexpected AND")
def __is_next_and(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND"
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND" # pyright: ignore
def __term(self) -> AST:
if self.__is_next_not():
@@ -85,11 +90,14 @@ class Parser:
return self.__constraint()
def __is_next_not(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT"
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT" # pyright: ignore
def __constraint(self) -> Constraint:
if self.next_token.type == TokenType.CONSTRAINTTYPE:
self.last_constraint_type = self.__eat(TokenType.CONSTRAINTTYPE).value
constraint = self.__eat(TokenType.CONSTRAINTTYPE).value
if not isinstance(constraint, ConstraintType):
raise self.__syntax_error()
self.last_constraint_type = constraint
value = self.__literal()
@@ -98,7 +106,7 @@ class Parser:
self.__eat(TokenType.SBRACKETO)
properties.append(self.__property())
while self.next_token.type == TokenType.COMMA:
while self.next_token.type == TokenType.COMMA: # pyright: ignore[reportUnnecessaryComparison]
self.__eat(TokenType.COMMA)
properties.append(self.__property())
@@ -110,11 +118,16 @@ class Parser:
key = self.__eat(TokenType.ULITERAL).value
self.__eat(TokenType.EQUALS)
value = self.__literal()
if not isinstance(key, str):
raise self.__syntax_error()
return Property(key, value)
def __literal(self) -> str:
if self.next_token.type in [TokenType.QLITERAL, TokenType.ULITERAL]:
return self.__eat(self.next_token.type).value
literal = self.__eat(self.next_token.type).value
if not isinstance(literal, str):
raise self.__syntax_error()
return literal
raise self.__syntax_error()
def __eat(self, type: TokenType) -> Token:

View File

@@ -1,5 +1,10 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from enum import Enum
from typing import Any
from typing import override
from tagstudio.core.query_lang.ast import ConstraintType
from tagstudio.core.query_lang.util import ParsingError
@@ -21,12 +26,14 @@ class TokenType(Enum):
class Token:
type: TokenType
value: Any
value: str | ConstraintType | None
start: int
end: int
def __init__(self, type: TokenType, value: Any, start: int, end: int) -> None:
def __init__(
self, type: TokenType, value: str | ConstraintType | None, start: int, end: int
) -> None:
self.type = type
self.value = value
self.start = start
@@ -40,9 +47,11 @@ class Token:
def EOF(pos: int) -> "Token": # noqa: N802
return Token.from_type(TokenType.EOF, pos)
@override
def __str__(self) -> str:
return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover
@override
def __repr__(self) -> str:
return self.__str__() # pragma: nocover

View File

@@ -1,15 +1,26 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import override
class ParsingError(BaseException):
start: int
end: int
msg: str
def __init__(self, start: int, end: int, msg: str = "Syntax Error") -> None:
super().__init__()
self.start = start
self.end = end
self.msg = msg
@override
def __str__(self) -> str:
return f"Syntax Error {self.start}->{self.end}: {self.msg}" # pragma: nocover
@override
def __repr__(self) -> str:
return self.__str__() # pragma: nocover

View File

@@ -8,9 +8,7 @@ import json
from pathlib import Path
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.alchemy.fields import _FieldID
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library
from tagstudio.core.utils.unlinked_registry import logger
@@ -43,27 +41,27 @@ class TagStudioCore:
return {}
if source == "twitter":
info[_FieldID.DESCRIPTION] = json_dump["content"].strip()
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
info[FieldID.DESCRIPTION] = json_dump["content"].strip()
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "instagram":
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "artstation":
info[_FieldID.TITLE] = json_dump["title"].strip()
info[_FieldID.ARTIST] = json_dump["user"]["full_name"].strip()
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
info[_FieldID.TAGS] = json_dump["tags"]
info[FieldID.TITLE] = json_dump["title"].strip()
info[FieldID.ARTIST] = json_dump["user"]["full_name"].strip()
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.TAGS] = json_dump["tags"]
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "newgrounds":
# info["title"] = json_dump["title"]
# info["artist"] = json_dump["artist"]
# info["description"] = json_dump["description"]
info[_FieldID.TAGS] = json_dump["tags"]
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
info[_FieldID.ARTIST] = json_dump["user"].strip()
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
info[_FieldID.SOURCE] = json_dump["post_url"].strip()
info[FieldID.TAGS] = json_dump["tags"]
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
info[FieldID.ARTIST] = json_dump["user"].strip()
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.SOURCE] = json_dump["post_url"].strip()
except Exception:
logger.exception("Error handling sidecar file.", path=_filepath)

View File

@@ -5,8 +5,7 @@ from pathlib import Path
import structlog
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
logger = structlog.get_logger()

View File

@@ -9,8 +9,7 @@ from pathlib import Path
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.utils.types import unwrap

View File

@@ -13,8 +13,7 @@ from time import time
import structlog
from wcmatch import pathlib
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.qt.helpers.silent_popen import silent_run # pyright: ignore
@@ -162,31 +161,34 @@ class RefreshDirTracker:
logger.info("[Refresh]: Falling back to wcmatch for scanning")
for f in pathlib.Path(str(library_dir)).glob(
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
try:
for f in pathlib.Path(str(library_dir)).glob(
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
# Ignore if the file is a directory
if f.is_dir():
continue
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
self.library.included_files.add(f)
# Ignore if the file is a directory
if f.is_dir():
continue
relative_path = f.relative_to(library_dir)
dir_file_count += 1
self.library.included_files.add(f)
relative_path = f.relative_to(library_dir)
if not self.library.has_path_entry(relative_path):
self.files_not_in_library.append(relative_path)
if not self.library.has_path_entry(relative_path):
self.files_not_in_library.append(relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
end_time_total = time()
yield dir_file_count

View File

@@ -5,8 +5,7 @@ from pathlib import Path
import structlog
from wcmatch import pathlib
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore
from tagstudio.core.utils.types import unwrap

View File

@@ -12,58 +12,66 @@ import structlog
from PIL import Image
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.core.global_settings import DEFAULT_THUMB_CACHE_SIZE
logger = structlog.get_logger(__name__)
class CacheEntry:
class CacheFolder:
def __init__(self, path: Path, size: int):
self.path: Path = path
self.size: int = size
class CacheManager:
DEFAULT_MAX_SIZE = 500_000_000
DEFAULT_MAX_FOLDER_SIZE = 10_000_000
MAX_FOLDER_SIZE = 10 # Number in MiB
STAT_MULTIPLIER = 1_000_000 # Multiplier to apply to file stats (bytes) to get user units (MiB)
def __init__(
self,
library_dir: Path,
max_size: int = DEFAULT_MAX_SIZE,
max_folder_size: int = DEFAULT_MAX_FOLDER_SIZE,
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
):
self._lock = RLock()
self.cache_folder = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
self.max_folder_size = max_folder_size
self.max_size = max(max_size, max_folder_size)
"""A class for managing frontend caches, such as for file thumbnails.
self.folders: list[CacheEntry] = []
Args:
library_dir(Path): The path of the folder containing the .TagStudio library folder.
max_size: (int | float) The maximum size of the cache, in MiB.
"""
self._lock = RLock()
self.cache_path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
self.max_size: int = max(
math.floor(max_size * CacheManager.STAT_MULTIPLIER),
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
)
self.folders: list[CacheFolder] = []
self.current_size = 0
if self.cache_folder.exists():
for folder in self.cache_folder.iterdir():
if self.cache_path.exists():
for folder in self.cache_path.iterdir():
if not folder.is_dir():
continue
folder_size = 0
for file in folder.iterdir():
folder_size += file.stat().st_size
self.folders.append(CacheEntry(folder, folder_size))
self.folders.append(CacheFolder(folder, folder_size))
self.current_size += folder_size
def _set_mru(self, index: int):
"""Move entry at index so it's considered the most recently used."""
def _set_most_recent_folder(self, index: int):
"""Move CacheFolder at index so it's considered the most recently used folder."""
with self._lock as _lock:
if index == (len(self.folders) - 1):
return
entry = self.folders.pop(index)
self.folders.append(entry)
cache_folder = self.folders.pop(index)
self.folders.append(cache_folder)
def _mru(self) -> Iterable[int]:
def _get_most_recent_folder(self) -> Iterable[int]:
"""Get each folders index sorted most recently used first."""
with self._lock as _lock:
return reversed(range(len(self.folders)))
def _lru(self) -> Iterable[int]:
"""Get each folders index sorted least recently used first."""
def _least_recent_folder(self) -> Iterable[int]:
"""Get each folder's index sorted least recently used first."""
with self._lock as _lock:
return range(len(self.folders))
@@ -78,14 +86,14 @@ class CacheManager:
self.folders = folders
logger.info("[CacheManager] Cleared cache!")
def _remove_folder(self, entry: CacheEntry) -> bool:
def _remove_folder(self, cache_folder: CacheFolder) -> bool:
with self._lock as _lock:
self.current_size -= entry.size
if not entry.path.is_dir():
self.current_size -= cache_folder.size
if not cache_folder.path.is_dir():
return True
is_empty = True
for file in entry.path.iterdir():
for file in cache_folder.path.iterdir():
assert file.is_file() and file.suffix == ".webp"
try:
file.unlink(missing_ok=True)
@@ -94,61 +102,61 @@ class CacheManager:
logger.warn("[CacheManager] Failed to remove file", file=file, error=e)
if is_empty:
entry.path.rmdir()
cache_folder.path.rmdir()
return True
else:
size = 0
for file in entry.path.iterdir():
for file in cache_folder.path.iterdir():
size += file.stat().st_size
entry.size = size
cache_folder.size = size
self.current_size += size
return False
def get_file_path(self, file_name: Path) -> Path | None:
with self._lock as _lock:
for i in self._mru():
entry = self.folders[i]
file_path = entry.path / file_name
for i in self._get_most_recent_folder():
cache_folder = self.folders[i]
file_path = cache_folder.path / file_name
if file_path.exists():
self._set_mru(i)
self._set_most_recent_folder(i)
return file_path
return None
def save_image(self, image: Image.Image, file_name: Path, mode: str = "RGBA"):
"""Save an image to the cache."""
with self._lock as _lock:
entry = self._get_current_folder()
file_path = entry.path / file_name
cache_folder: CacheFolder = self._get_current_folder()
file_path = cache_folder.path / file_name
image.save(file_path, mode=mode)
size = file_path.stat().st_size
entry.size += size
cache_folder.size += size
self.current_size += size
self._cull_folders()
def _create_folder(self) -> CacheEntry:
def _create_folder(self) -> CacheFolder:
with self._lock as _lock:
folder = self.cache_folder / Path(str(math.floor(dt.timestamp(dt.now()))))
folder = self.cache_path / Path(str(math.floor(dt.timestamp(dt.now()))))
try:
folder.mkdir(parents=True)
except FileExistsError:
for entry in self.folders:
if entry.path == folder:
return entry
entry = CacheEntry(folder, 0)
self.folders.append(entry)
return entry
for cache_folder in self.folders:
if cache_folder.path == folder:
return cache_folder
cache_folder = CacheFolder(folder, 0)
self.folders.append(cache_folder)
return cache_folder
def _get_current_folder(self) -> CacheEntry:
def _get_current_folder(self) -> CacheFolder:
with self._lock as _lock:
if len(self.folders) == 0:
return self._create_folder()
for i in self._mru():
entry = self.folders[i]
if entry.size < self.max_folder_size:
self._set_mru(i)
return entry
for i in self._get_most_recent_folder():
cache_folder: CacheFolder = self.folders[i]
if cache_folder.size < CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER:
self._set_most_recent_folder(i)
return cache_folder
return self._create_folder()
@@ -159,10 +167,12 @@ class CacheManager:
return
removed: list[int] = []
for i in self._lru():
entry = self.folders[i]
logger.info("[CacheManager] Removing folder due to size limit", folder=entry.path)
if self._remove_folder(entry):
for i in self._least_recent_folder():
cache_folder: CacheFolder = self.folders[i]
logger.info(
"[CacheManager] Removing folder due to size limit", folder=cache_folder.path
)
if self._remove_folder(cache_folder):
removed.append(i)
if self.current_size < self.max_size:
break

View File

@@ -9,7 +9,7 @@ from PySide6.QtCore import Signal
from tagstudio.core.enums import TagClickActionOption
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.view.components.tag_box_view import TagBoxWidgetView

View File

@@ -4,14 +4,14 @@
from pathlib import Path
from typing import override
import structlog
from PySide6 import QtGui
from PySide6.QtCore import Signal
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.library.ignore import Ignore
from tagstudio.qt.helpers import file_opener
from tagstudio.qt.view.widgets.ignore_modal_view import IgnoreModalView
@@ -45,6 +45,7 @@ class IgnoreModal(IgnoreModalView):
lines = [f"{line}\n" for line in lines]
Ignore.write_ignore_file(self.lib.library_dir, lines)
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
@override
def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore
self.__load_file()
return super().showEvent(event)

View File

@@ -144,7 +144,9 @@ class PreviewThumb(PreviewThumbView):
return self.__get_image_stats(filepath)
def _open_file_action_callback(self):
open_file(self.__current_file)
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
def _open_explorer_action_callback(self):
open_file(self.__current_file, file_manager=True)
@@ -154,4 +156,6 @@ class PreviewThumb(PreviewThumbView):
self.__driver.delete_files_callback(self.__current_file)
def _button_wrapper_callback(self):
open_file(self.__current_file)
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)

View File

@@ -20,13 +20,15 @@ from tagstudio.qt.helpers.silent_popen import silent_Popen
logger = structlog.get_logger(__name__)
def open_file(path: str | Path, file_manager: bool = False):
def open_file(path: str | Path, file_manager: bool = False, windows_start_command: bool = False):
"""Open a file in the default application or file explorer.
Args:
path (str): The path to the file to open.
file_manager (bool, optional): Whether to open the file in the file manager
(e.g. Finder on macOS). Defaults to False.
windows_start_command (bool): Flag to determine if the older 'start' command should be used
on Windows for opening files. This fixes issues on some systems in niche cases.
"""
path = Path(path)
logger.info("Opening file", path=path)
@@ -51,14 +53,26 @@ def open_file(path: str | Path, file_manager: bool = False):
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command = f'"{normpath}"'
silent_Popen(
command,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
if windows_start_command:
command_name = "start"
# First parameter is for title, NOT filepath
command_args = ["", normpath]
subprocess.Popen(
[command_name] + command_args,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command = f'"{normpath}"'
silent_Popen(
command,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
if sys.platform == "darwin":
command_name = "open"

View File

@@ -22,8 +22,7 @@ from PySide6.QtWidgets import (
from tagstudio.core import palette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library, slugify
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.library.alchemy.library import Library, TagColorGroup, slugify
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget

View File

@@ -11,8 +11,12 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
from tagstudio.core.library.alchemy.models import Namespace
from tagstudio.core.library.alchemy.library import (
Library,
Namespace,
ReservedNamespaceError,
slugify,
)
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget

View File

@@ -26,8 +26,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
from tagstudio.core.library.alchemy.library import Library, Tag, TagColorGroup
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from tagstudio.qt.modals.tag_color_selection import TagColorSelection
from tagstudio.qt.modals.tag_search import TagSearchModal, TagSearchPanel

View File

@@ -23,8 +23,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.flowlayout import FlowLayout

View File

@@ -5,11 +5,14 @@
from typing import TYPE_CHECKING, Any
import structlog
from PySide6.QtCore import Qt
from PySide6.QtGui import QDoubleValidator
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QTabWidget,
@@ -18,13 +21,20 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.core.global_settings import Splash, Theme
from tagstudio.core.global_settings import (
DEFAULT_THUMB_CACHE_SIZE,
MIN_THUMB_CACHE_SIZE,
Splash,
Theme,
)
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
if TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class SettingsPanel(PanelWidget):
driver: "QtDriver"
@@ -142,6 +152,25 @@ class SettingsPanel(PanelWidget):
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
# Thumbnail Cache Size
self.thumb_cache_size_container = QWidget()
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
self.thumb_cache_size_layout.setSpacing(6)
self.thumb_cache_size = QLineEdit()
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
self.validator = QDoubleValidator(MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2) # High limit
self.thumb_cache_size.setValidator(self.validator)
self.thumb_cache_size.setText(
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
)
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
self.thumb_cache_size_layout.setStretch(1, 2)
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
form_layout.addRow(
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
)
# Autoplay
self.autoplay_checkbox = QCheckBox()
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
@@ -252,6 +281,10 @@ class SettingsPanel(PanelWidget):
"language": self.__get_language(),
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
"generate_thumbs": self.generate_thumbs.isChecked(),
"thumb_cache_size": max(
float(self.thumb_cache_size.text()) or DEFAULT_THUMB_CACHE_SIZE,
MIN_THUMB_CACHE_SIZE,
),
"autoplay": self.autoplay_checkbox.isChecked(),
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
"page_size": int(self.page_size_line_edit.text()),
@@ -271,6 +304,7 @@ class SettingsPanel(PanelWidget):
driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"]
driver.settings.autoplay = settings["autoplay"]
driver.settings.generate_thumbs = settings["generate_thumbs"]
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
driver.settings.page_size = settings["page_size"]
driver.settings.show_filepath = settings["show_filepath"]

View File

@@ -19,8 +19,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.library.alchemy.library import Library, TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.translations import Translations

View File

@@ -7,8 +7,7 @@ import structlog
from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.modals.tag_search import TagSearchPanel
from tagstudio.qt.translations import Translations

View File

@@ -25,8 +25,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
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.library.alchemy.library import Library, Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget

View File

@@ -5,6 +5,8 @@
"""A pagination widget created for TagStudio."""
from typing import override
from PIL import Image, ImageQt
from PySide6.QtCore import QObject, QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
@@ -285,9 +287,10 @@ class Pagination(QWidget, QObject):
class Validator(QIntValidator):
def __init__(self, bottom: int, top: int, parent=None) -> None:
super().__init__(bottom, top, parent)
def __init__(self, bottom: int, top: int) -> None:
super().__init__(bottom, top)
@override
def fixup(self, input: str) -> str:
input = input.strip("0")
return super().fixup(str(self.top()) if input else "1")

View File

@@ -13,10 +13,9 @@ logger = structlog.get_logger(__name__)
DEFAULT_TRANSLATION = "en"
LANGUAGES = {
# "Cantonese (Traditional)": "yue_Hant", # Empty
"Chinese (Simplified)": "zh_Hans",
"Chinese (Traditional)": "zh_Hant",
# "Czech": "cs", # Minimal
"Czech": "cs",
# "Danish": "da", # Minimal
"Dutch": "nl",
"English": "en",
@@ -29,7 +28,8 @@ LANGUAGES = {
"Norwegian Bokmål": "nb_NO",
"Polish": "pl",
"Portuguese (Brazil)": "pt_BR",
# "Portuguese (Portugal)": "pt", # Empty
"Portuguese (Portugal)": "pt",
"Romanian": "ro",
"Russian": "ru",
"Spanish": "es",
"Swedish": "sv",

View File

@@ -49,16 +49,18 @@ import tagstudio.qt.resources_rc # noqa: F401
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
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.global_settings import (
DEFAULT_GLOBAL_SETTINGS_PATH,
GlobalSettings,
Theme,
)
from tagstudio.core.library.alchemy.enums import (
BrowsingState,
FieldTypeEnum,
ItemType,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.fields import _FieldID
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library, LibraryStatus
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
@@ -1125,7 +1127,7 @@ class QtDriver(DriverMixin, QObject):
elif name == MacroID.BUILD_URL:
url = TagStudioCore.build_url(entry, source)
if url is not None:
self.lib.add_field_to_entry(entry.id, field_id=_FieldID.SOURCE, value=url)
self.lib.add_field_to_entry(entry.id, field_id=FieldID.SOURCE, value=url)
elif name == MacroID.MATCH:
TagStudioCore.match_conditions(self.lib, entry.id)
elif name == MacroID.CLEAN_URL:
@@ -1693,15 +1695,9 @@ class QtDriver(DriverMixin, QObject):
open_status = LibraryStatus(
success=False, library_path=path, message=type(e).__name__, msg_description=str(e)
)
max_size: int = self.cached_values.value(
SettingItems.THUMB_CACHE_SIZE_LIMIT,
defaultValue=CacheManager.DEFAULT_MAX_SIZE,
type=int,
) # type: ignore
self.cache_manager = CacheManager(path, max_size=max_size)
self.cache_manager = CacheManager(path, max_size=self.settings.thumb_cache_size)
logger.info(
f"[Config] Thumbnail cache size limit: {format_size(max_size)}",
f"[Config] Thumbnail Cache Size: {format_size(self.settings.thumb_cache_size)}",
)
# Migration is required

View File

@@ -7,8 +7,7 @@ from typing import TYPE_CHECKING
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.widgets.fields import FieldWidget
from tagstudio.qt.widgets.tag import TagWidget

View File

@@ -13,8 +13,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.panel import PanelWidget

View File

@@ -16,8 +16,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb

View File

@@ -12,7 +12,7 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.flowlayout import FlowLayout
from tagstudio.qt.modals.build_color import BuildColorPanel

View File

@@ -30,10 +30,10 @@ from tagstudio.core.constants import (
TS_FOLDER_NAME,
)
from tagstudio.core.enums import LibraryPrefs
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.constants import SQL_FILENAME
from tagstudio.core.library.alchemy.library import Entry, TagAlias, TagParent
from tagstudio.core.library.alchemy.library import Library as SqliteLibrary
from tagstudio.core.library.alchemy.models import Entry, TagAlias
from tagstudio.core.library.helpers.migration import json_to_sql_color
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.library.json.library import Tag as JsonTag
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
@@ -492,7 +492,7 @@ class JsonMigrationModal(QObject):
def finish_migration(self):
"""Finish the migration upon user approval."""
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SQL_FILENAME
if self.temp_path.exists():
self.temp_path.rename(final_name)
@@ -779,7 +779,7 @@ class JsonMigrationModal(QObject):
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_color = (tag.color_namespace, tag.color_slug)
json_color = default_color_groups.json_to_sql_color(self.json_lib.get_tag(tag_id).color)
json_color = json_to_sql_color(self.json_lib.get_tag(tag_id).color)
logger.info(
"[Color Parity]",

View File

@@ -24,14 +24,15 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.library import (
BaseField,
DatetimeField,
FieldTypeEnum,
Entry,
Library,
Tag,
TextField,
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.components.tag_box_controller import TagBoxWidget
from tagstudio.qt.translations import Translations

View File

@@ -12,7 +12,7 @@ from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Tag
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.translations import Translations

View File

@@ -10,7 +10,7 @@ from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QAction, QColor, QEnterEvent
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.qt.helpers.escape_text import escape_text
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.tag import (

View File

@@ -11,7 +11,7 @@ from PySide6.QtGui import QColor
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import TagColorGroup
from tagstudio.core.library.alchemy.library import TagColorGroup
from tagstudio.core.palette import ColorType, get_tag_color
from tagstudio.qt.translations import Translations
from tagstudio.qt.widgets.tag import (

View File

@@ -1524,8 +1524,23 @@ class ThumbRenderer(QObject):
if _filepath and _filepath.is_file():
try:
ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower()
# Images =======================================================
# Ebooks =======================================================
if MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = self._epub_cover(_filepath)
# Krita ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = self._krita_thumb(_filepath)
# VTF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = self._vtf_thumb(_filepath)
# Images =======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
):
# Raw Images -----------------------------------------------
@@ -1552,11 +1567,6 @@ class ThumbRenderer(QObject):
# PowerPoint Slideshow
elif ext in {".pptx"}:
image = self._powerpoint_thumb(_filepath)
# Krita ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = self._krita_thumb(_filepath)
# OpenDocument/OpenOffice ======================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
@@ -1590,11 +1600,6 @@ class ThumbRenderer(QObject):
savable_media_type = False
if image is not None:
image = self._apply_overlay_color(image, UiColor.GREEN)
# Ebooks =======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = self._epub_cover(_filepath)
# Blender ======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
@@ -1605,11 +1610,6 @@ class ThumbRenderer(QObject):
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size)
# VTF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = self._vtf_thumb(_filepath)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError

View File

@@ -287,6 +287,7 @@
"settings.theme.label": "Theme:",
"settings.theme.light": "Light",
"settings.theme.system": "System",
"settings.thumb_cache_size.label": "Thumbnail Cache Size",
"settings.title": "Settings",
"settings.zeropadding.label": "Date Zero-Padding",
"sorting.direction.ascending": "Ascending",

View File

@@ -16,8 +16,7 @@ CWD = Path(__file__).parent
sys.path.insert(0, str(CWD.parent))
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.library.alchemy.library import Entry, Library, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -4,8 +4,7 @@
from pathlib import Path
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.core.utils.dupe_files import DupeRegistry
from tagstudio.core.utils.types import unwrap

View File

@@ -7,8 +7,7 @@ from collections.abc import Callable
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias
from tagstudio.core.library.alchemy.library import Library, Tag, TagAlias
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.modals.build_tag import BuildTagPanel, CustomTableItem
from tagstudio.qt.translations import Translations

View File

@@ -3,8 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.library.alchemy.library import Entry, Library, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -16,8 +16,7 @@ from PySide6.QtWidgets import QMenu, QMenuBar
from pytestqt.qtbot import QtBot
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library, LibraryStatus
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
from tagstudio.qt.modals.settings_panel import SettingsPanel

View File

@@ -3,8 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.library import Entry, Library
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -5,8 +5,7 @@
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.core.library.alchemy.library import Library, Tag
from tagstudio.qt.modals.build_tag import BuildTagPanel
from tagstudio.qt.ts_qt import QtDriver

View File

@@ -12,12 +12,13 @@ import structlog
from tagstudio.core.enums import DefaultEnum, LibraryPrefs
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.fields import (
from tagstudio.core.library.alchemy.library import (
Entry,
FieldID,
Library,
Tag,
TextField,
_FieldID, # pyright: ignore[reportPrivateUsage]
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger()
@@ -270,7 +271,7 @@ def test_mirror_entry_fields(library: Library, entry_full: Entry):
path=Path("xxx"),
fields=[
TextField(
type_key=_FieldID.NOTES.name,
type_key=FieldID.NOTES.name,
value="notes",
position=0,
)
@@ -292,8 +293,8 @@ def test_mirror_entry_fields(library: Library, entry_full: Entry):
# make sure fields are there after getting it from the library again
assert len(entry.fields) == 2
assert {x.type_key for x in entry.fields} == {
_FieldID.TITLE.name,
_FieldID.NOTES.name,
FieldID.TITLE.name,
FieldID.NOTES.name,
}
@@ -308,14 +309,14 @@ def test_merge_entries(library: Library):
folder=folder,
path=Path("a"),
fields=[
TextField(type_key=_FieldID.AUTHOR.name, value="Author McAuthorson", position=0),
TextField(type_key=_FieldID.DESCRIPTION.name, value="test description", position=2),
TextField(type_key=FieldID.AUTHOR.name, value="Author McAuthorson", position=0),
TextField(type_key=FieldID.DESCRIPTION.name, value="test description", position=2),
],
)
b = Entry(
folder=folder,
path=Path("b"),
fields=[TextField(type_key=_FieldID.NOTES.name, value="test note", position=1)],
fields=[TextField(type_key=FieldID.NOTES.name, value="test note", position=1)],
)
ids = library.add_entries([a, b])