diff --git a/pyproject.toml b/pyproject.toml index 31210019b..bf88057f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 26ce1c832..2032d613a 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -23,3 +23,14 @@ ) 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; +""") diff --git a/src/tagstudio/core/library/alchemy/db.py b/src/tagstudio/core/library/alchemy/db.py index 78a766f12..026678ddf 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -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 diff --git a/src/tagstudio/core/library/alchemy/default_color_groups.py b/src/tagstudio/core/library/alchemy/default_color_groups.py deleted file mode 100644 index 9193fc09d..000000000 --- a/src/tagstudio/core/library/alchemy/default_color_groups.py +++ /dev/null @@ -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, - ] diff --git a/src/tagstudio/core/library/alchemy/fields.py b/src/tagstudio/core/library/alchemy/fields.py deleted file mode 100644 index c4675dc47..000000000 --- a/src/tagstudio/core/library/alchemy/fields.py +++ /dev/null @@ -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) diff --git a/src/tagstudio/core/library/alchemy/joins.py b/src/tagstudio/core/library/alchemy/joins.py deleted file mode 100644 index d01e67642..000000000 --- a/src/tagstudio/core/library/alchemy/joins.py +++ /dev/null @@ -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) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index fc56f9194..2115513d9 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -2,6 +2,11 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +# NOTE: This file contains nessisary use of deprecated first-party code until that +# code is removed in a future version. +# pyright: reportDeprecated=false + +from __future__ import annotations import re import shutil @@ -9,20 +14,28 @@ import unicodedata from collections.abc import Iterable, Iterator from dataclasses import dataclass +from dataclasses import field as dc_field from datetime import UTC, datetime +from datetime import datetime as dt +from enum import Enum from os import makedirs from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from uuid import uuid4 from warnings import catch_warnings import sqlalchemy import structlog -from humanfriendly import format_timespan +from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType] from sqlalchemy import ( + JSON, URL, + ColumnElement, ColumnExpressionArgument, Engine, + ForeignKey, + ForeignKeyConstraint, + Integer, NullPool, ScalarResult, and_, @@ -30,6 +43,8 @@ create_engine, delete, desc, + distinct, + event, exists, func, inspect, @@ -40,13 +55,19 @@ ) from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import ( + Mapped, Session, contains_eager, + declared_attr, joinedload, make_transient, + mapped_column, noload, + relationship, selectinload, ) +from sqlalchemy.sql.operators import ilike_op +from typing_extensions import deprecated from tagstudio.core.constants import ( BACKUP_FOLDER_NAME, @@ -61,7 +82,6 @@ TS_FOLDER_NAME, ) from tagstudio.core.enums import LibraryPrefs -from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.constants import ( DB_VERSION, DB_VERSION_CURRENT_KEY, @@ -69,40 +89,35 @@ DB_VERSION_LEGACY_KEY, JSON_FILENAME, SQL_FILENAME, + TAG_CHILDREN_ID_QUERY, TAG_CHILDREN_QUERY, ) -from tagstudio.core.library.alchemy.db import make_tables +from tagstudio.core.library.alchemy.db import Base, PathType, make_tables from tagstudio.core.library.alchemy.enums import ( MAX_SQL_VARIABLES, BrowsingState, FieldTypeEnum, SortingModeEnum, ) -from tagstudio.core.library.alchemy.fields import ( - BaseField, - DatetimeField, - TextField, - _FieldID, -) -from tagstudio.core.library.alchemy.joins import TagEntry, TagParent -from tagstudio.core.library.alchemy.models import ( - Entry, - Folder, - Namespace, - Preferences, - Tag, - TagAlias, - TagColorGroup, - ValueType, - Version, -) -from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder +from tagstudio.core.library.helpers.migration import json_to_sql_color from tagstudio.core.library.json.library import Library as JsonLibrary +from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories +from tagstudio.core.query_lang.ast import ( + AST, + ANDList, + BaseVisitor, + Constraint, + ConstraintType, + Not, + ORList, + Property, +) from tagstudio.core.utils.types import unwrap from tagstudio.qt.translations import Translations if TYPE_CHECKING: from sqlalchemy import Select + from sqlalchemy.orm import InstanceState logger = structlog.get_logger(__name__) @@ -133,40 +148,6 @@ def slugify(input_string: str, allow_reserved: bool = False) -> str: return slug -def get_default_tags() -> tuple[Tag, ...]: - meta_tag = Tag( - id=TAG_META, - name="Meta Tags", - aliases={TagAlias(name="Meta"), TagAlias(name="Meta Tag")}, - is_category=True, - ) - archive_tag = Tag( - id=TAG_ARCHIVED, - name="Archived", - aliases={TagAlias(name="Archive")}, - parent_tags={meta_tag}, - color_slug="red", - color_namespace="tagstudio-standard", - ) - favorite_tag = Tag( - id=TAG_FAVORITE, - name="Favorite", - aliases={ - TagAlias(name="Favorited"), - TagAlias(name="Favorites"), - }, - parent_tags={meta_tag}, - color_slug="yellow", - color_namespace="tagstudio-standard", - ) - - return archive_tag, favorite_tag, meta_tag - - -# The difference in the number of default JSON tags vs default tags in the current version. -DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE]) - - @dataclass(frozen=True) class SearchResult: """Wrapper for search results. @@ -210,9 +191,9 @@ class Library: """Class for the Library object, and all CRUD operations made upon it.""" library_dir: Path | None = None - storage_path: Path | str | None + storage_path: Path | str | None = None engine: Engine | None = None - folder: Folder | None + folder: Folder | None = None included_files: set[Path] = set() def __init__(self) -> None: @@ -224,7 +205,7 @@ def __init__(self) -> None: def close(self): if self.engine: self.engine.dispose() - self.library_dir: Path | None = None + self.library_dir = None self.storage_path = None self.folder = None self.included_files = set() @@ -242,7 +223,7 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): # Tags for tag in json_lib.tags: - color_namespace, color_slug = default_color_groups.json_to_sql_color(tag.color) + color_namespace, color_slug = json_to_sql_color(tag.color) disambiguation_id: int | None = None if tag.subtag_ids and tag.subtag_ids[0] != tag.id: disambiguation_id = tag.subtag_ids[0] @@ -275,7 +256,7 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): # Only add new (user-created) aliases to the default tags. # This prevents pre-existing built-in aliases from being added as duplicates. if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1): - for dt in get_default_tags(): + for dt in self.get_default_tags: if dt.id == tag.id and alias not in dt.alias_strings: self.add_alias(name=alias, tag_id=tag.id) else: @@ -300,8 +281,8 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): ] ) for entry in json_lib.entries: - for field in entry.fields: - for k, v in field.items(): + for field in entry.fields: # pyright: ignore[reportUnknownVariableType] + for k, v in field.items(): # pyright: ignore[reportUnknownVariableType] # Old tag fields get added as tags if k in LEGACY_TAG_FIELD_IDS: self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v) @@ -319,8 +300,8 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): end_time = time.time() logger.info(f"Library Converted! ({format_timespan(end_time - start_time)})") - def get_field_name_from_id(self, field_id: int) -> _FieldID: - for f in _FieldID: + def get_field_name_from_id(self, field_id: int) -> FieldID | None: + for f in FieldID: if field_id == f.value.id: return f return None @@ -416,7 +397,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # Add default tag color namespaces. if is_new: - namespaces = default_color_groups.namespaces() + namespaces = DefaultColorGroups.color_namespaces() try: session.add_all(namespaces) session.commit() @@ -426,12 +407,12 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # Add default tag colors. if is_new: - tag_colors: list[TagColorGroup] = default_color_groups.standard() - tag_colors += default_color_groups.pastels() - tag_colors += default_color_groups.shades() - tag_colors += default_color_groups.grayscale() - tag_colors += default_color_groups.earth_tones() - tag_colors += default_color_groups.neon() + tag_colors: list[TagColorGroup] = DefaultColorGroups.standard() + tag_colors += DefaultColorGroups.pastels() + tag_colors += DefaultColorGroups.shades() + tag_colors += DefaultColorGroups.grayscale() + tag_colors += DefaultColorGroups.earth_tones() + tag_colors += DefaultColorGroups.neon() if is_new: try: session.add_all(tag_colors) @@ -442,7 +423,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: # Add default tags. if is_new: - tags = get_default_tags() + tags = self.get_default_tags try: session.add_all(tags) session.commit() @@ -482,7 +463,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: except IntegrityError: session.rollback() - for field in _FieldID: + for field in FieldID: try: session.add( ValueType( @@ -562,7 +543,7 @@ def __apply_repairs_for_db6(self, session: Session): # Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key. desc_stmd = ( update(ValueType) - .where(ValueType.key == _FieldID.DESCRIPTION.name) + .where(ValueType.key == FieldID.DESCRIPTION.name) .values(type=FieldTypeEnum.TEXT_BOX.name) ) session.execute(desc_stmd) @@ -598,12 +579,12 @@ def __apply_db8_schema_changes(self, session: Session): def __apply_db8_default_data(self, session: Session): """Apply default data changes introduced in DB_VERSION 8.""" - tag_colors: list[TagColorGroup] = default_color_groups.standard() - tag_colors += default_color_groups.pastels() - tag_colors += default_color_groups.shades() - tag_colors += default_color_groups.grayscale() - tag_colors += default_color_groups.earth_tones() - # tag_colors += default_color_groups.neon() # NOTE: Neon is handled separately + tag_colors: list[TagColorGroup] = DefaultColorGroups.standard() + tag_colors += DefaultColorGroups.pastels() + tag_colors += DefaultColorGroups.shades() + tag_colors += DefaultColorGroups.grayscale() + tag_colors += DefaultColorGroups.earth_tones() + # tag_colors += neon() # NOTE: Neon is handled separately # Add any new default colors introduced in DB_VERSION 8 for color in tag_colors: @@ -618,7 +599,7 @@ def __apply_db8_default_data(self, session: Session): session.rollback() # Update Neon colors to use the the color_border property - for color in default_color_groups.neon(): + for color in DefaultColorGroups.neon(): try: neon_stmt = ( update(TagColorGroup) @@ -720,12 +701,6 @@ def default_fields(self) -> list[BaseField]: ) return [x.as_field for x in types] - def delete_item(self, item): - logger.info("deleting item", item=item) - with Session(self.engine) as session: - session.delete(item) - session.commit() - def get_entry(self, entry_id: int) -> Entry | None: """Load entry without joins.""" with Session(self.engine) as session: @@ -864,7 +839,9 @@ def get_tag_entries( @property def entries_count(self) -> int: with Session(self.engine) as session: - return session.scalar(select(func.count(Entry.id))) + count = session.scalar(select(func.count(Entry.id))) + assert count is not None + return count def all_entries(self, with_joins: bool = False) -> Iterator[Entry]: """Load entries without joins.""" @@ -906,7 +883,7 @@ def tags(self) -> list[Tag]: return list(tags_list) - def verify_ts_folder(self, library_dir: Path) -> bool: + def verify_ts_folder(self, library_dir: Path | None) -> bool: """Verify/create folders required by TagStudio. Returns: @@ -960,7 +937,7 @@ def has_path_entry(self, path: Path) -> bool: with Session(self.engine) as session: return session.query(exists().where(Entry.path == path)).scalar() - def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]: + def get_paths(self, limit: int = -1) -> list[str]: path_strings: list[str] = [] with Session(self.engine) as session: if limit > 0: @@ -1020,7 +997,7 @@ def search_library( ids = [] count = 0 for row in rows: - id, count = row._tuple() + id, count = row._tuple() # pyright: ignore[reportPrivateUsage] ids.append(id) end_time = time.time() logger.info(f"SQL Execution finished ({format_timespan(end_time - start_time)})") @@ -1118,7 +1095,8 @@ def remove_tag(self, tag: Tag): tags_query = select(Tag).options( selectinload(Tag.parent_tags), selectinload(Tag.aliases) ) - tag = session.scalar(tags_query.where(Tag.id == tag.id)) + tag_: Tag | None = session.scalar(tags_query.where(Tag.id == tag.id)) + assert tag_ is not None aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)) for alias in aliases or []: @@ -1130,17 +1108,17 @@ def remove_tag(self, tag: Tag): disam_stmt = ( update(Tag) - .where(Tag.disambiguation_id == tag.id) + .where(Tag.disambiguation_id == tag_.id) .values(disambiguation_id=None) ) session.execute(disam_stmt) session.flush() - session.delete(tag) + session.delete(tag_) session.commit() - session.expunge(tag) + session.expunge(tag_) - return tag + return tag_ except IntegrityError as e: logger.error(e) @@ -1230,7 +1208,7 @@ def update_entry_field( .where( and_( FieldClass.position == field.position, - FieldClass.type == field.type, + FieldClass.type == field.type, # type: ignore FieldClass.entry_id.in_(entry_ids), ) ) @@ -1248,6 +1226,7 @@ def field_types(self) -> dict[str, ValueType]: def get_value_type(self, field_key: str) -> ValueType: with Session(self.engine) as session: field = session.scalar(select(ValueType).where(ValueType.key == field_key)) + assert field session.expunge(field) return field @@ -1256,7 +1235,7 @@ def add_field_to_entry( entry_id: int, *, field: ValueType | None = None, - field_id: _FieldID | str | None = None, + field_id: FieldID | str | None = None, value: str | datetime | None = None, ) -> bool: logger.info( @@ -1270,8 +1249,9 @@ def add_field_to_entry( assert bool(field) != (field_id is not None) if not field: - if isinstance(field_id, _FieldID): + if isinstance(field_id, FieldID): field_id = field_id.name + assert field_id is not None field = self.get_value_type(field_id) field_model: TextField | DatetimeField @@ -1619,9 +1599,9 @@ def get_tag_hierarchy(self, tag_ids: Iterable[int]) -> dict[int, Tag]: # When calling session.add with this tag instance sqlalchemy will # attempt to create TagParents that already exist. - state = inspect(tag) - # Prevent sqlalchemy from thinking any fields are different from what's commited - # commited_state contains original values for fields that have changed. + state: InstanceState[Tag] = inspect(tag) + # Prevent sqlalchemy from thinking any fields are different from what's committed + # committed_state contains original values for fields that have changed. # empty when no fields have changed state.committed_state.clear() @@ -1735,7 +1715,13 @@ def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColor else: self.add_color(new_color_group) - def update_aliases(self, tag, alias_ids, alias_names, session): + def update_aliases( + self, + tag: Tag, + alias_ids: list[int] | set[int], + alias_names: list[str] | set[str], + session: Session, + ): prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all() for alias in prev_aliases: @@ -1749,7 +1735,7 @@ def update_aliases(self, tag, alias_ids, alias_names, session): alias = TagAlias(alias_name, tag.id) session.add(alias) - def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session): + def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session: Session): if tag.id in parent_ids: parent_ids.remove(tag.id) @@ -1826,22 +1812,26 @@ def set_version(self, key: str, value: int) -> None: select(Preferences).where(Preferences.key == DB_VERSION_LEGACY_KEY) ) assert pref is not None - pref.value = value # pyright: ignore + pref.value = value # type: ignore # pyright: ignore session.add(pref) session.commit() except (IntegrityError, AssertionError) as e: logger.error("[Library][ERROR] Couldn't add default tag color namespaces", error=e) session.rollback() - def prefs(self, key: str | LibraryPrefs): + # TODO: Remove this once the 'preferences' table is removed. + @deprecated("Use `get_version() for version and `ts_ignore` system for extension exclusion.") + def prefs(self, key: str | LibraryPrefs): # pyright: ignore[reportUnknownParameterType] # load given item from Preferences table with Session(self.engine) as session: if isinstance(key, LibraryPrefs): - return session.scalar(select(Preferences).where(Preferences.key == key.name)).value + return session.scalar(select(Preferences).where(Preferences.key == key.name)).value # pyright: ignore else: - return session.scalar(select(Preferences).where(Preferences.key == key)).value + return session.scalar(select(Preferences).where(Preferences.key == key)).value # pyright: ignore - def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None: + # TODO: Remove this once the 'preferences' table is removed. + @deprecated("Use `set_version() for version and `ts_ignore` system for extension exclusion.") + def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None: # pyright: ignore[reportExplicitAny] # set given item in Preferences table with Session(self.engine) as session: # load existing preference and update value @@ -1873,7 +1863,7 @@ def mirror_entry_fields(self, *entries: Entry) -> None: # assign the field to all entries for entry in entries: - for field_key, field in fields.items(): + for field_key, field in fields.items(): # pyright: ignore[reportUnknownVariableType] if field_key not in existing_fields: self.add_field_to_entry( entry_id=entry.id, @@ -1942,3 +1932,1133 @@ def get_namespace_name(self, namespace: str) -> str: session.expunge(result) return "" if not result else result.name + + @property + def get_default_tags(self) -> tuple[Tag, ...]: + meta_tag = Tag( + id=TAG_META, + name="Meta Tags", + aliases={TagAlias(name="Meta"), TagAlias(name="Meta Tag")}, + is_category=True, + ) + archive_tag = Tag( + id=TAG_ARCHIVED, + name="Archived", + aliases={TagAlias(name="Archive")}, + parent_tags={meta_tag}, + color_slug="red", + color_namespace="tagstudio-standard", + ) + favorite_tag = Tag( + id=TAG_FAVORITE, + name="Favorite", + aliases={ + TagAlias(name="Favorited"), + TagAlias(name="Favorites"), + }, + parent_tags={meta_tag}, + color_slug="yellow", + color_namespace="tagstudio-standard", + ) + + return archive_tag, favorite_tag, meta_tag + + @property + def default_tag_diff(self) -> int: + """The difference in the number of default JSON tags vs current default SQL tags.""" + return len(self.get_default_tags) - len([TAG_ARCHIVED, TAG_FAVORITE]) + + +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) + + +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 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: + super().__init__() + 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 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__() + + @override + def __str__(self) -> str: + return f"" + + @override + def __repr__(self) -> str: + return self.__str__() + + @override + def __hash__(self) -> int: + return hash(self.id) + + def __lt__(self, other: Tag) -> bool: + return self.name < other.name + + def __le__(self, other: Tag) -> bool: + return self.name <= other.name + + def __gt__(self, other: Tag) -> bool: + return self.name > other.name + + def __ge__(self, other: Tag) -> bool: + return self.name >= other.name + + +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] # pyright: ignore[reportUninitializedInstanceVariable] + position: Mapped[int] # pyright: ignore[reportUninitializedInstanceVariable] + + # 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): # pyright: ignore + """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) + + +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 # pyright: ignore[reportArgumentType] + + @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 # pyright: ignore[reportArgumentType] + + @declared_attr + def position(self) -> Mapped[int]: + return mapped_column(default=0) + + @override + def __hash__(self): + return hash(self.__key()) + + def __key(self): # pyright: ignore[reportUnknownParameterType] + raise NotImplementedError + + value: Any # pyright: ignore + + +class BooleanField(BaseField): + __tablename__ = "boolean_fields" + + value: Mapped[bool] + + def __key(self): + return (self.type, self.value) + + @override + def __eq__(self, value: object) -> 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[ValueType, str | None]: + return self.type, self.value + + @override + def __eq__(self, value: object) -> 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) + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, DatetimeField): + return self.__key() == value.__key() + raise NotImplementedError + + +@dataclass +class DefaultField: + id: int + name: str + type: FieldTypeEnum + is_default: bool = dc_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) + + +# 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) + + +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 DefaultColorGroups: + @staticmethod + def color_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, + ] + + @staticmethod + 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, + ] + + @staticmethod + 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, + ] + + @staticmethod + 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] + + @staticmethod + 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] + + @staticmethod + 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] + + @staticmethod + 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, + ] + + +class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]): + def __init__(self, lib: Library) -> None: + super().__init__() + self.lib = lib + + @override + def visit_or_list(self, node: ORList) -> ColumnElement[bool]: # type: ignore + 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) + + @override + def visit_and_list(self, node: ANDList) -> ColumnElement[bool]: # type: ignore + 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) + + @override + def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: # type: ignore + """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") + + @override + def visit_property(self, node: Property) -> ColumnElement[bool]: # type: ignore + raise NotImplementedError("This should never be reached!") + + @override + def visit_not(self, node: Not) -> ColumnElement[bool]: # type: ignore + 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: list[int] = [] + 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 + case _: + pass + + 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() + ) diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py deleted file mode 100644 index 4d103be20..000000000 --- a/src/tagstudio/core/library/alchemy/models.py +++ /dev/null @@ -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"" - - 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) diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py deleted file mode 100644 index d7f63e9c9..000000000 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ /dev/null @@ -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() - ) diff --git a/src/tagstudio/core/library/helpers/migration.py b/src/tagstudio/core/library/helpers/migration.py new file mode 100644 index 000000000..7c2d382fa --- /dev/null +++ b/src/tagstudio/core/library/helpers/migration.py @@ -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) diff --git a/src/tagstudio/core/query_lang/ast.py b/src/tagstudio/core/query_lang/ast.py index 102203ed7..0323bf26d 100644 --- a/src/tagstudio/core/query_lang/ast.py +++ b/src/tagstudio/core/query_lang/ast.py @@ -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 @@ def from_string(text: str) -> Union["ConstraintType", None]: 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__() diff --git a/src/tagstudio/core/query_lang/parser.py b/src/tagstudio/core/query_lang/parser.py index 8566d5dbb..ff17465d7 100644 --- a/src/tagstudio/core/query_lang/parser.py +++ b/src/tagstudio/core/query_lang/parser.py @@ -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 @@ def parse(self) -> AST: 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 @@ def __or_list(self) -> AST: 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 @@ def __skip_and(self) -> None: 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 @@ def __term(self) -> AST: 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 @@ def __constraint(self) -> Constraint: 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 @@ def __property(self) -> Property: 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: diff --git a/src/tagstudio/core/query_lang/tokenizer.py b/src/tagstudio/core/query_lang/tokenizer.py index 4970a5feb..a279fbf69 100644 --- a/src/tagstudio/core/query_lang/tokenizer.py +++ b/src/tagstudio/core/query_lang/tokenizer.py @@ -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 @@ def from_type(type: TokenType, pos: int) -> "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 diff --git a/src/tagstudio/core/query_lang/util.py b/src/tagstudio/core/query_lang/util.py index 8deaecf22..95e53dbea 100644 --- a/src/tagstudio/core/query_lang/util.py +++ b/src/tagstudio/core/query_lang/util.py @@ -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 diff --git a/src/tagstudio/core/ts_core.py b/src/tagstudio/core/ts_core.py index 97ead78cb..f3c5630be 100644 --- a/src/tagstudio/core/ts_core.py +++ b/src/tagstudio/core/ts_core.py @@ -8,9 +8,7 @@ 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 @@ def get_gdl_sidecar(cls, filepath: Path, source: str = "") -> dict: 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) diff --git a/src/tagstudio/core/utils/dupe_files.py b/src/tagstudio/core/utils/dupe_files.py index 9d3b2af49..a700e0802 100644 --- a/src/tagstudio/core/utils/dupe_files.py +++ b/src/tagstudio/core/utils/dupe_files.py @@ -5,8 +5,7 @@ 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() diff --git a/src/tagstudio/core/utils/ignored_registry.py b/src/tagstudio/core/utils/ignored_registry.py index a864e248e..7b875a3e7 100644 --- a/src/tagstudio/core/utils/ignored_registry.py +++ b/src/tagstudio/core/utils/ignored_registry.py @@ -9,8 +9,7 @@ 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 diff --git a/src/tagstudio/core/utils/refresh_dir.py b/src/tagstudio/core/utils/refresh_dir.py index f32e822d2..2e38127a9 100644 --- a/src/tagstudio/core/utils/refresh_dir.py +++ b/src/tagstudio/core/utils/refresh_dir.py @@ -13,8 +13,7 @@ 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 diff --git a/src/tagstudio/core/utils/unlinked_registry.py b/src/tagstudio/core/utils/unlinked_registry.py index 20bab6242..27299b90f 100644 --- a/src/tagstudio/core/utils/unlinked_registry.py +++ b/src/tagstudio/core/utils/unlinked_registry.py @@ -5,8 +5,7 @@ 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 diff --git a/src/tagstudio/qt/controller/components/tag_box_controller.py b/src/tagstudio/qt/controller/components/tag_box_controller.py index 1af5422bb..0adf8b046 100644 --- a/src/tagstudio/qt/controller/components/tag_box_controller.py +++ b/src/tagstudio/qt/controller/components/tag_box_controller.py @@ -9,7 +9,7 @@ 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 diff --git a/src/tagstudio/qt/controller/widgets/ignore_modal_controller.py b/src/tagstudio/qt/controller/widgets/ignore_modal_controller.py index 5f70e60c9..07d359743 100644 --- a/src/tagstudio/qt/controller/widgets/ignore_modal_controller.py +++ b/src/tagstudio/qt/controller/widgets/ignore_modal_controller.py @@ -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 @@ def save(self): 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) diff --git a/src/tagstudio/qt/modals/build_color.py b/src/tagstudio/qt/modals/build_color.py index 67d86c86f..b0bae03f3 100644 --- a/src/tagstudio/qt/modals/build_color.py +++ b/src/tagstudio/qt/modals/build_color.py @@ -22,8 +22,7 @@ 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 diff --git a/src/tagstudio/qt/modals/build_namespace.py b/src/tagstudio/qt/modals/build_namespace.py index a358a7d98..e50154f94 100644 --- a/src/tagstudio/qt/modals/build_namespace.py +++ b/src/tagstudio/qt/modals/build_namespace.py @@ -11,8 +11,12 @@ 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 diff --git a/src/tagstudio/qt/modals/build_tag.py b/src/tagstudio/qt/modals/build_tag.py index 9dd077e41..d6e1138fd 100644 --- a/src/tagstudio/qt/modals/build_tag.py +++ b/src/tagstudio/qt/modals/build_tag.py @@ -26,8 +26,7 @@ ) 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 diff --git a/src/tagstudio/qt/modals/folders_to_tags.py b/src/tagstudio/qt/modals/folders_to_tags.py index 6c4a9d934..39f769b18 100644 --- a/src/tagstudio/qt/modals/folders_to_tags.py +++ b/src/tagstudio/qt/modals/folders_to_tags.py @@ -23,8 +23,7 @@ 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 diff --git a/src/tagstudio/qt/modals/tag_color_selection.py b/src/tagstudio/qt/modals/tag_color_selection.py index 70472c93c..648b92fe3 100644 --- a/src/tagstudio/qt/modals/tag_color_selection.py +++ b/src/tagstudio/qt/modals/tag_color_selection.py @@ -19,8 +19,7 @@ ) 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 diff --git a/src/tagstudio/qt/modals/tag_database.py b/src/tagstudio/qt/modals/tag_database.py index 9c8a29240..ba4c041a1 100644 --- a/src/tagstudio/qt/modals/tag_database.py +++ b/src/tagstudio/qt/modals/tag_database.py @@ -7,8 +7,7 @@ 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 diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index 4cc2b2f45..931c02755 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -25,8 +25,7 @@ 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 diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index a6062b8bd..4895041ff 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -60,9 +60,7 @@ 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 @@ -1129,7 +1127,7 @@ def run_macro(self, name: MacroID, entry_id: int): 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: diff --git a/src/tagstudio/qt/view/components/tag_box_view.py b/src/tagstudio/qt/view/components/tag_box_view.py index f6183615c..cb7969f0b 100644 --- a/src/tagstudio/qt/view/components/tag_box_view.py +++ b/src/tagstudio/qt/view/components/tag_box_view.py @@ -7,8 +7,7 @@ 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 diff --git a/src/tagstudio/qt/view/widgets/ignore_modal_view.py b/src/tagstudio/qt/view/widgets/ignore_modal_view.py index 62afcf527..091f25075 100644 --- a/src/tagstudio/qt/view/widgets/ignore_modal_view.py +++ b/src/tagstudio/qt/view/widgets/ignore_modal_view.py @@ -13,8 +13,7 @@ ) 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 diff --git a/src/tagstudio/qt/view/widgets/preview_panel_view.py b/src/tagstudio/qt/view/widgets/preview_panel_view.py index 6bc961c39..50847223b 100644 --- a/src/tagstudio/qt/view/widgets/preview_panel_view.py +++ b/src/tagstudio/qt/view/widgets/preview_panel_view.py @@ -16,8 +16,7 @@ ) 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 diff --git a/src/tagstudio/qt/widgets/color_box.py b/src/tagstudio/qt/widgets/color_box.py index cbf39ef2c..6cfdba894 100644 --- a/src/tagstudio/qt/widgets/color_box.py +++ b/src/tagstudio/qt/widgets/color_box.py @@ -12,7 +12,7 @@ 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 diff --git a/src/tagstudio/qt/widgets/migration_modal.py b/src/tagstudio/qt/widgets/migration_modal.py index 3de94ae18..6fb126ce8 100644 --- a/src/tagstudio/qt/widgets/migration_modal.py +++ b/src/tagstudio/qt/widgets/migration_modal.py @@ -30,11 +30,10 @@ TS_FOLDER_NAME, ) from tagstudio.core.enums import LibraryPrefs -from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.constants import SQL_FILENAME -from tagstudio.core.library.alchemy.joins import TagParent +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 @@ -780,7 +779,7 @@ def check_color_parity(self) -> bool: 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]", diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index 4bd7d03c5..13859696a 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -24,14 +24,15 @@ 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 diff --git a/src/tagstudio/qt/widgets/tag.py b/src/tagstudio/qt/widgets/tag.py index cdce68a67..4b9faf736 100644 --- a/src/tagstudio/qt/widgets/tag.py +++ b/src/tagstudio/qt/widgets/tag.py @@ -12,7 +12,7 @@ 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 diff --git a/src/tagstudio/qt/widgets/tag_color_label.py b/src/tagstudio/qt/widgets/tag_color_label.py index 05cdacafe..41b05d358 100644 --- a/src/tagstudio/qt/widgets/tag_color_label.py +++ b/src/tagstudio/qt/widgets/tag_color_label.py @@ -10,7 +10,7 @@ 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 ( diff --git a/src/tagstudio/qt/widgets/tag_color_preview.py b/src/tagstudio/qt/widgets/tag_color_preview.py index 2e0fb5842..9afa84584 100644 --- a/src/tagstudio/qt/widgets/tag_color_preview.py +++ b/src/tagstudio/qt/widgets/tag_color_preview.py @@ -11,7 +11,7 @@ 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 ( diff --git a/tests/conftest.py b/tests/conftest.py index a2c97c101..860cdd6b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,8 +16,7 @@ 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 diff --git a/tests/macros/test_dupe_entries.py b/tests/macros/test_dupe_entries.py index ecaca5cb8..e67972ae6 100644 --- a/tests/macros/test_dupe_entries.py +++ b/tests/macros/test_dupe_entries.py @@ -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 diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 3c92b7d10..943174469 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -7,8 +7,7 @@ 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 diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3090437ed..0a7bd5bd6 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -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 diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index d9bbcf30a..8c77a5ac7 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -16,8 +16,7 @@ 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 diff --git a/tests/qt/test_preview_panel.py b/tests/qt/test_preview_panel.py index 38c0bf2b8..814add092 100644 --- a/tests/qt/test_preview_panel.py +++ b/tests/qt/test_preview_panel.py @@ -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 diff --git a/tests/qt/test_tag_panel.py b/tests/qt/test_tag_panel.py index 6b79fb44e..96617773c 100644 --- a/tests/qt/test_tag_panel.py +++ b/tests/qt/test_tag_panel.py @@ -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 diff --git a/tests/test_library.py b/tests/test_library.py index 26f34f23e..dc1800659 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -12,12 +12,13 @@ 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])