mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
15 Commits
v9.5.4
...
pyright-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0bb5f44e0 | ||
|
|
7a8d34e190 | ||
|
|
3374f6b07f | ||
|
|
eecb4d3e38 | ||
|
|
583d107cb8 | ||
|
|
2db8bed304 | ||
|
|
01680cab34 | ||
|
|
bbb17285e7 | ||
|
|
ccd7ce136e | ||
|
|
25f85bf443 | ||
|
|
46f7edf6e8 | ||
|
|
745fea6b85 | ||
|
|
668ac23a86 | ||
|
|
8e8f416246 | ||
|
|
218aa9e0d1 |
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@ class SettingItems(str, enum.Enum):
|
||||
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit"
|
||||
|
||||
|
||||
class ShowFilepathOption(int, enum.Enum):
|
||||
|
||||
@@ -13,21 +13,16 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
DEFAULT_GLOBAL_SETTINGS_PATH = (
|
||||
Path.home() / "Appdata" / "Roaming" / "TagStudio" / "settings.toml"
|
||||
if platform.system() == "Windows"
|
||||
else Path.home() / ".config" / "TagStudio" / "settings.toml"
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TomlEnumEncoder(toml.TomlEncoder):
|
||||
@override
|
||||
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
|
||||
if isinstance(v, Enum):
|
||||
return super().dump_value(v.value)
|
||||
return super().dump_value(v)
|
||||
DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
|
||||
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
|
||||
|
||||
|
||||
class Theme(IntEnum):
|
||||
@@ -45,6 +40,14 @@ class Splash(StrEnum):
|
||||
NINETY_FIVE = "95"
|
||||
|
||||
|
||||
class TomlEnumEncoder(toml.TomlEncoder):
|
||||
@override
|
||||
def dump_value(self, v): # pyright: ignore[reportMissingParameterType]
|
||||
if isinstance(v, Enum):
|
||||
return super().dump_value(v.value)
|
||||
return super().dump_value(v)
|
||||
|
||||
|
||||
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
|
||||
# properties to be overwritten with environment variables. As TagStudio is not currently using
|
||||
# environment variables, this was not based on that, but that may be useful in the future.
|
||||
@@ -52,6 +55,7 @@ class GlobalSettings(BaseModel):
|
||||
language: str = Field(default="en")
|
||||
open_last_loaded_on_startup: bool = Field(default=True)
|
||||
generate_thumbs: bool = Field(default=True)
|
||||
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
|
||||
autoplay: bool = Field(default=True)
|
||||
loop: bool = Field(default=True)
|
||||
show_filenames_in_grid: bool = Field(default=True)
|
||||
@@ -60,6 +64,7 @@ class GlobalSettings(BaseModel):
|
||||
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
|
||||
theme: Theme = Field(default=Theme.SYSTEM)
|
||||
splash: Splash = Field(default=Splash.DEFAULT)
|
||||
windows_start_command: bool = Field(default=False)
|
||||
|
||||
date_format: str = Field(default="%x")
|
||||
hour_format: bool = Field(default=True)
|
||||
|
||||
@@ -23,3 +23,14 @@ WITH RECURSIVE ChildTags AS (
|
||||
)
|
||||
SELECT * FROM ChildTags;
|
||||
""")
|
||||
|
||||
TAG_CHILDREN_ID_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS tag_id
|
||||
UNION
|
||||
SELECT tp.child_id AS tag_id
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
|
||||
)
|
||||
SELECT tag_id FROM ChildTags;
|
||||
""")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
@@ -1,23 +0,0 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from tagstudio.core.library.alchemy.db import Base
|
||||
|
||||
|
||||
class TagParent(Base):
|
||||
__tablename__ = "tag_parents"
|
||||
|
||||
parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
|
||||
|
||||
class TagEntry(Base):
|
||||
__tablename__ = "tag_entries"
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from tagstudio.core.library.alchemy.db import Base, PathType
|
||||
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BooleanField,
|
||||
DatetimeField,
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
|
||||
|
||||
class Namespace(Base):
|
||||
__tablename__ = "namespaces"
|
||||
|
||||
namespace: Mapped[str] = mapped_column(primary_key=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespace: str,
|
||||
name: str,
|
||||
):
|
||||
self.namespace = namespace
|
||||
self.name = name
|
||||
super().__init__()
|
||||
|
||||
|
||||
class TagAlias(Base):
|
||||
__tablename__ = "tag_aliases"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
|
||||
tag: Mapped["Tag"] = relationship(back_populates="aliases")
|
||||
|
||||
def __init__(self, name: str, tag_id: int | None = None):
|
||||
self.name = name
|
||||
|
||||
if tag_id is not None:
|
||||
self.tag_id = tag_id
|
||||
|
||||
super().__init__()
|
||||
|
||||
|
||||
class TagColorGroup(Base):
|
||||
__tablename__ = "tag_colors"
|
||||
|
||||
slug: Mapped[str] = mapped_column(primary_key=True, nullable=False)
|
||||
namespace: Mapped[str] = mapped_column(
|
||||
ForeignKey("namespaces.namespace"), primary_key=True, nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column()
|
||||
primary: Mapped[str] = mapped_column(nullable=False)
|
||||
secondary: Mapped[str | None]
|
||||
color_border: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
# TODO: Determine if slug and namespace can be optional and generated/added here if needed.
|
||||
def __init__(
|
||||
self,
|
||||
slug: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
primary: str,
|
||||
secondary: str | None = None,
|
||||
color_border: bool = False,
|
||||
):
|
||||
self.slug = slug
|
||||
self.namespace = namespace
|
||||
self.name = name
|
||||
self.primary = primary
|
||||
if secondary:
|
||||
self.secondary = secondary
|
||||
self.color_border = color_border
|
||||
super().__init__()
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str]
|
||||
shorthand: Mapped[str | None]
|
||||
color_namespace: Mapped[str | None] = mapped_column()
|
||||
color_slug: Mapped[str | None] = mapped_column()
|
||||
color: Mapped[TagColorGroup | None] = relationship(lazy="joined")
|
||||
is_category: Mapped[bool]
|
||||
icon: Mapped[str | None]
|
||||
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
|
||||
parent_tags: Mapped[set["Tag"]] = relationship(
|
||||
secondary=TagParent.__tablename__,
|
||||
primaryjoin="Tag.id == TagParent.child_id",
|
||||
secondaryjoin="Tag.id == TagParent.parent_id",
|
||||
back_populates="parent_tags",
|
||||
)
|
||||
disambiguation_id: Mapped[int | None]
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
[color_namespace, color_slug], [TagColorGroup.namespace, TagColorGroup.slug]
|
||||
),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
@property
|
||||
def parent_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.parent_tags]
|
||||
|
||||
@property
|
||||
def alias_strings(self) -> list[str]:
|
||||
return [alias.name for alias in self.aliases]
|
||||
|
||||
@property
|
||||
def alias_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.aliases]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
id: int | None = None,
|
||||
shorthand: str | None = None,
|
||||
aliases: set[TagAlias] | None = None,
|
||||
parent_tags: set["Tag"] | None = None,
|
||||
icon: str | None = None,
|
||||
color_namespace: str | None = None,
|
||||
color_slug: str | None = None,
|
||||
disambiguation_id: int | None = None,
|
||||
is_category: bool = False,
|
||||
):
|
||||
self.name = name
|
||||
self.aliases = aliases or set()
|
||||
self.parent_tags = parent_tags or set()
|
||||
self.color_namespace = color_namespace
|
||||
self.color_slug = color_slug
|
||||
self.icon = icon
|
||||
self.shorthand = shorthand
|
||||
self.disambiguation_id = disambiguation_id
|
||||
self.is_category = is_category
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
super().__init__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<Tag ID: {self.id} Name: {self.name}>"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.name < other.name
|
||||
|
||||
def __le__(self, other) -> bool:
|
||||
return self.name <= other.name
|
||||
|
||||
def __gt__(self, other) -> bool:
|
||||
return self.name > other.name
|
||||
|
||||
def __ge__(self, other) -> bool:
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
# TODO - implement this
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
uuid: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
date_created: Mapped[dt | None]
|
||||
date_modified: Mapped[dt | None]
|
||||
date_added: Mapped[dt | None]
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
|
||||
|
||||
text_fields: Mapped[list[TextField]] = relationship(
|
||||
back_populates="entry",
|
||||
cascade="all, delete",
|
||||
)
|
||||
datetime_fields: Mapped[list[DatetimeField]] = relationship(
|
||||
back_populates="entry",
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[BaseField]:
|
||||
fields: list[BaseField] = []
|
||||
fields.extend(self.text_fields)
|
||||
fields.extend(self.datetime_fields)
|
||||
fields = sorted(fields, key=lambda field: field.type.position)
|
||||
return fields
|
||||
|
||||
@property
|
||||
def is_favorite(self) -> bool:
|
||||
return any(tag.id == TAG_FAVORITE for tag in self.tags)
|
||||
|
||||
@property
|
||||
def is_archived(self) -> bool:
|
||||
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt | None = None,
|
||||
date_modified: dt | None = None,
|
||||
date_added: dt | None = None,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
# The date the file associated with this entry was created.
|
||||
# st_birthtime on Windows and Mac, st_ctime on Linux.
|
||||
self.date_created = date_created
|
||||
# The date the file associated with this entry was last modified: st_mtime.
|
||||
self.date_modified = date_modified
|
||||
# The date this entry was added to the library.
|
||||
self.date_added = date_added
|
||||
|
||||
for field in fields:
|
||||
if isinstance(field, TextField):
|
||||
self.text_fields.append(field)
|
||||
elif isinstance(field, DatetimeField):
|
||||
self.datetime_fields.append(field)
|
||||
else:
|
||||
raise ValueError(f"Invalid field type: {field}")
|
||||
|
||||
def has_tag(self, tag: Tag) -> bool:
|
||||
return tag in self.tags
|
||||
|
||||
def remove_tag(self, tag: Tag) -> None:
|
||||
"""Removes a Tag from the Entry."""
|
||||
self.tags.remove(tag)
|
||||
|
||||
|
||||
class ValueType(Base):
|
||||
"""Define Field Types in the Library.
|
||||
|
||||
Example:
|
||||
key: content_tags (this field is slugified `name`)
|
||||
name: Content Tags (this field is human readable name)
|
||||
kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox)
|
||||
is_default: Should the field be present in new Entry?
|
||||
order: position of the field widget in the Entry form
|
||||
|
||||
"""
|
||||
|
||||
__tablename__ = "value_type"
|
||||
|
||||
key: Mapped[str] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE)
|
||||
is_default: Mapped[bool]
|
||||
position: Mapped[int]
|
||||
|
||||
# add relations to other tables
|
||||
text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type")
|
||||
datetime_fields: Mapped[list[DatetimeField]] = relationship(
|
||||
"DatetimeField", back_populates="type"
|
||||
)
|
||||
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
|
||||
|
||||
@property
|
||||
def as_field(self) -> BaseField:
|
||||
FieldClass = { # noqa: N806
|
||||
FieldTypeEnum.TEXT_LINE: TextField,
|
||||
FieldTypeEnum.TEXT_BOX: TextField,
|
||||
FieldTypeEnum.DATETIME: DatetimeField,
|
||||
FieldTypeEnum.BOOLEAN: BooleanField,
|
||||
}
|
||||
|
||||
return FieldClass[self.type](
|
||||
type_key=self.key,
|
||||
position=self.position,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(ValueType, "before_insert")
|
||||
def slugify_field_key(mapper, connection, target):
|
||||
"""Slugify the field key before inserting into the database."""
|
||||
if not target.key:
|
||||
from tagstudio.core.library.alchemy.library import slugify
|
||||
|
||||
target.key = slugify(target.tag)
|
||||
|
||||
|
||||
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
|
||||
# and is set to be removed in a future release.
|
||||
@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.")
|
||||
class Preferences(Base):
|
||||
__tablename__ = "preferences"
|
||||
|
||||
key: Mapped[str] = mapped_column(primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
|
||||
class Version(Base):
|
||||
__tablename__ = "versions"
|
||||
|
||||
key: Mapped[str] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
@@ -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()
|
||||
)
|
||||
83
src/tagstudio/core/library/helpers/migration.py
Normal file
83
src/tagstudio/core/library/helpers/migration.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
def json_to_sql_color(json_color: str) -> tuple[str | None, str | None]:
|
||||
"""Convert a color string from a <=9.4 JSON library to a 9.5+ (namespace, slug) tuple."""
|
||||
json_color_ = json_color.lower()
|
||||
match json_color_:
|
||||
case "black":
|
||||
return ("tagstudio-grayscale", "black")
|
||||
case "dark gray":
|
||||
return ("tagstudio-grayscale", "dark-gray")
|
||||
case "gray":
|
||||
return ("tagstudio-grayscale", "gray")
|
||||
case "light gray":
|
||||
return ("tagstudio-grayscale", "light-gray")
|
||||
case "white":
|
||||
return ("tagstudio-grayscale", "white")
|
||||
case "light pink":
|
||||
return ("tagstudio-pastels", "light-pink")
|
||||
case "pink":
|
||||
return ("tagstudio-standard", "pink")
|
||||
case "magenta":
|
||||
return ("tagstudio-standard", "magenta")
|
||||
case "red":
|
||||
return ("tagstudio-standard", "red")
|
||||
case "red orange":
|
||||
return ("tagstudio-standard", "red-orange")
|
||||
case "salmon":
|
||||
return ("tagstudio-pastels", "salmon")
|
||||
case "orange":
|
||||
return ("tagstudio-standard", "orange")
|
||||
case "yellow orange":
|
||||
return ("tagstudio-standard", "amber")
|
||||
case "yellow":
|
||||
return ("tagstudio-standard", "yellow")
|
||||
case "mint":
|
||||
return ("tagstudio-pastels", "mint")
|
||||
case "lime":
|
||||
return ("tagstudio-standard", "lime")
|
||||
case "light green":
|
||||
return ("tagstudio-pastels", "light-green")
|
||||
case "green":
|
||||
return ("tagstudio-standard", "green")
|
||||
case "teal":
|
||||
return ("tagstudio-standard", "teal")
|
||||
case "cyan":
|
||||
return ("tagstudio-standard", "cyan")
|
||||
case "light blue":
|
||||
return ("tagstudio-pastels", "light-blue")
|
||||
case "blue":
|
||||
return ("tagstudio-standard", "blue")
|
||||
case "blue violet":
|
||||
return ("tagstudio-shades", "navy")
|
||||
case "violet":
|
||||
return ("tagstudio-standard", "indigo")
|
||||
case "purple":
|
||||
return ("tagstudio-standard", "purple")
|
||||
case "peach":
|
||||
return ("tagstudio-earth-tones", "peach")
|
||||
case "brown":
|
||||
return ("tagstudio-earth-tones", "brown")
|
||||
case "lavender":
|
||||
return ("tagstudio-pastels", "lavender")
|
||||
case "blonde":
|
||||
return ("tagstudio-earth-tones", "blonde")
|
||||
case "auburn":
|
||||
return ("tagstudio-shades", "auburn")
|
||||
case "light brown":
|
||||
return ("tagstudio-earth-tones", "light-brown")
|
||||
case "dark brown":
|
||||
return ("tagstudio-earth-tones", "dark-brown")
|
||||
case "cool gray":
|
||||
return ("tagstudio-earth-tones", "cool-gray")
|
||||
case "warm gray":
|
||||
return ("tagstudio-earth-tones", "warm-gray")
|
||||
case "olive":
|
||||
return ("tagstudio-shades", "olive")
|
||||
case "berry":
|
||||
return ("tagstudio-shades", "berry")
|
||||
case _:
|
||||
return (None, None)
|
||||
@@ -44,7 +44,9 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
|
||||
ignore_patterns (list[str]): The .gitignore-like patterns to convert.
|
||||
"""
|
||||
glob_patterns: list[str] = deepcopy(ignore_patterns)
|
||||
glob_patterns_remove: list[str] = []
|
||||
additional_patterns: list[str] = []
|
||||
root_patterns: list[str] = []
|
||||
|
||||
# Mimic implicit .gitignore syntax behavior for the SQLite GLOB function.
|
||||
for pattern in glob_patterns:
|
||||
@@ -66,6 +68,16 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
|
||||
gp = gp.removeprefix("**/").removeprefix("*/")
|
||||
additional_patterns.append(exclusion_char + gp)
|
||||
|
||||
elif gp.startswith("/"):
|
||||
# Matches "/file" case for .gitignore behavior where it should only match
|
||||
# a file or folder int the root directory, and nowhere else.
|
||||
glob_patterns_remove.append(gp)
|
||||
gp = gp.lstrip("/")
|
||||
root_patterns.append(exclusion_char + gp)
|
||||
|
||||
for gp in glob_patterns_remove:
|
||||
glob_patterns.remove(gp)
|
||||
|
||||
glob_patterns = glob_patterns + additional_patterns
|
||||
|
||||
# Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior.
|
||||
@@ -75,6 +87,7 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
|
||||
|
||||
glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**")
|
||||
|
||||
glob_patterns = glob_patterns + root_patterns
|
||||
glob_patterns = list(set(glob_patterns))
|
||||
|
||||
logger.info("[Ignore]", glob_patterns=glob_patterns)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Generic, TypeVar, Union
|
||||
from typing import Generic, TypeVar, override
|
||||
|
||||
|
||||
class ConstraintType(Enum):
|
||||
@@ -12,7 +17,7 @@ class ConstraintType(Enum):
|
||||
Special = 5
|
||||
|
||||
@staticmethod
|
||||
def from_string(text: str) -> Union["ConstraintType", None]:
|
||||
def from_string(text: str) -> "ConstraintType | None":
|
||||
return {
|
||||
"tag": ConstraintType.Tag,
|
||||
"tag_id": ConstraintType.TagID,
|
||||
@@ -24,14 +29,16 @@ class ConstraintType(Enum):
|
||||
|
||||
|
||||
class AST:
|
||||
parent: Union["AST", None] = None
|
||||
parent: "AST | None" = None
|
||||
|
||||
@override
|
||||
def __str__(self):
|
||||
class_name = self.__class__.__name__
|
||||
fields = vars(self) # Get all instance variables as a dictionary
|
||||
field_str = ", ".join(f"{key}={value}" for key, value in fields.items())
|
||||
return f"{class_name}({field_str})"
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from tagstudio.core.query_lang.ast import (
|
||||
AST,
|
||||
ANDList,
|
||||
@@ -27,7 +32,7 @@ class Parser:
|
||||
if self.next_token.type == TokenType.EOF:
|
||||
return ORList([])
|
||||
out = self.__or_list()
|
||||
if self.next_token.type != TokenType.EOF:
|
||||
if self.next_token.type != TokenType.EOF: # pyright: ignore[reportUnnecessaryComparison]
|
||||
raise ParsingError(self.next_token.start, self.next_token.end, "Syntax Error")
|
||||
return out
|
||||
|
||||
@@ -41,7 +46,7 @@ class Parser:
|
||||
return ORList(terms) if len(terms) > 1 else terms[0]
|
||||
|
||||
def __is_next_or(self) -> bool:
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR"
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR" # pyright: ignore
|
||||
|
||||
def __and_list(self) -> AST:
|
||||
elements = [self.__term()]
|
||||
@@ -67,7 +72,7 @@ class Parser:
|
||||
raise self.__syntax_error("Unexpected AND")
|
||||
|
||||
def __is_next_and(self) -> bool:
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND"
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND" # pyright: ignore
|
||||
|
||||
def __term(self) -> AST:
|
||||
if self.__is_next_not():
|
||||
@@ -85,11 +90,14 @@ class Parser:
|
||||
return self.__constraint()
|
||||
|
||||
def __is_next_not(self) -> bool:
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT"
|
||||
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT" # pyright: ignore
|
||||
|
||||
def __constraint(self) -> Constraint:
|
||||
if self.next_token.type == TokenType.CONSTRAINTTYPE:
|
||||
self.last_constraint_type = self.__eat(TokenType.CONSTRAINTTYPE).value
|
||||
constraint = self.__eat(TokenType.CONSTRAINTTYPE).value
|
||||
if not isinstance(constraint, ConstraintType):
|
||||
raise self.__syntax_error()
|
||||
self.last_constraint_type = constraint
|
||||
|
||||
value = self.__literal()
|
||||
|
||||
@@ -98,7 +106,7 @@ class Parser:
|
||||
self.__eat(TokenType.SBRACKETO)
|
||||
properties.append(self.__property())
|
||||
|
||||
while self.next_token.type == TokenType.COMMA:
|
||||
while self.next_token.type == TokenType.COMMA: # pyright: ignore[reportUnnecessaryComparison]
|
||||
self.__eat(TokenType.COMMA)
|
||||
properties.append(self.__property())
|
||||
|
||||
@@ -110,11 +118,16 @@ class Parser:
|
||||
key = self.__eat(TokenType.ULITERAL).value
|
||||
self.__eat(TokenType.EQUALS)
|
||||
value = self.__literal()
|
||||
if not isinstance(key, str):
|
||||
raise self.__syntax_error()
|
||||
return Property(key, value)
|
||||
|
||||
def __literal(self) -> str:
|
||||
if self.next_token.type in [TokenType.QLITERAL, TokenType.ULITERAL]:
|
||||
return self.__eat(self.next_token.type).value
|
||||
literal = self.__eat(self.next_token.type).value
|
||||
if not isinstance(literal, str):
|
||||
raise self.__syntax_error()
|
||||
return literal
|
||||
raise self.__syntax_error()
|
||||
|
||||
def __eat(self, type: TokenType) -> Token:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import override
|
||||
|
||||
from tagstudio.core.query_lang.ast import ConstraintType
|
||||
from tagstudio.core.query_lang.util import ParsingError
|
||||
@@ -21,12 +26,14 @@ class TokenType(Enum):
|
||||
|
||||
class Token:
|
||||
type: TokenType
|
||||
value: Any
|
||||
value: str | ConstraintType | None
|
||||
|
||||
start: int
|
||||
end: int
|
||||
|
||||
def __init__(self, type: TokenType, value: Any, start: int, end: int) -> None:
|
||||
def __init__(
|
||||
self, type: TokenType, value: str | ConstraintType | None, start: int, end: int
|
||||
) -> None:
|
||||
self.type = type
|
||||
self.value = value
|
||||
self.start = start
|
||||
@@ -40,9 +47,11 @@ class Token:
|
||||
def EOF(pos: int) -> "Token": # noqa: N802
|
||||
return Token.from_type(TokenType.EOF, pos)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__() # pragma: nocover
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,9 +8,7 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from tagstudio.core.constants import TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy.fields import _FieldID
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library
|
||||
from tagstudio.core.utils.unlinked_registry import logger
|
||||
|
||||
|
||||
@@ -43,27 +41,27 @@ class TagStudioCore:
|
||||
return {}
|
||||
|
||||
if source == "twitter":
|
||||
info[_FieldID.DESCRIPTION] = json_dump["content"].strip()
|
||||
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
info[FieldID.DESCRIPTION] = json_dump["content"].strip()
|
||||
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
elif source == "instagram":
|
||||
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
elif source == "artstation":
|
||||
info[_FieldID.TITLE] = json_dump["title"].strip()
|
||||
info[_FieldID.ARTIST] = json_dump["user"]["full_name"].strip()
|
||||
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[_FieldID.TAGS] = json_dump["tags"]
|
||||
info[FieldID.TITLE] = json_dump["title"].strip()
|
||||
info[FieldID.ARTIST] = json_dump["user"]["full_name"].strip()
|
||||
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[FieldID.TAGS] = json_dump["tags"]
|
||||
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
|
||||
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
elif source == "newgrounds":
|
||||
# info["title"] = json_dump["title"]
|
||||
# info["artist"] = json_dump["artist"]
|
||||
# info["description"] = json_dump["description"]
|
||||
info[_FieldID.TAGS] = json_dump["tags"]
|
||||
info[_FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
info[_FieldID.ARTIST] = json_dump["user"].strip()
|
||||
info[_FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[_FieldID.SOURCE] = json_dump["post_url"].strip()
|
||||
info[FieldID.TAGS] = json_dump["tags"]
|
||||
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
|
||||
info[FieldID.ARTIST] = json_dump["user"].strip()
|
||||
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
|
||||
info[FieldID.SOURCE] = json_dump["post_url"].strip()
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error handling sidecar file.", path=_filepath)
|
||||
|
||||
@@ -5,8 +5,7 @@ from pathlib import Path
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ from time import time
|
||||
import structlog
|
||||
from wcmatch import pathlib
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library
|
||||
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
|
||||
from tagstudio.qt.helpers.silent_popen import silent_run # pyright: ignore
|
||||
|
||||
@@ -162,31 +161,34 @@ class RefreshDirTracker:
|
||||
|
||||
logger.info("[Refresh]: Falling back to wcmatch for scanning")
|
||||
|
||||
for f in pathlib.Path(str(library_dir)).glob(
|
||||
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
|
||||
):
|
||||
end_time_loop = time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time_loop - start_time_loop) > 0.034:
|
||||
yield dir_file_count
|
||||
start_time_loop = time()
|
||||
try:
|
||||
for f in pathlib.Path(str(library_dir)).glob(
|
||||
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
|
||||
):
|
||||
end_time_loop = time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time_loop - start_time_loop) > 0.034:
|
||||
yield dir_file_count
|
||||
start_time_loop = time()
|
||||
|
||||
# Skip if the file/path is already mapped in the Library
|
||||
if f in self.library.included_files:
|
||||
dir_file_count += 1
|
||||
continue
|
||||
|
||||
# Ignore if the file is a directory
|
||||
if f.is_dir():
|
||||
continue
|
||||
|
||||
# Skip if the file/path is already mapped in the Library
|
||||
if f in self.library.included_files:
|
||||
dir_file_count += 1
|
||||
continue
|
||||
self.library.included_files.add(f)
|
||||
|
||||
# Ignore if the file is a directory
|
||||
if f.is_dir():
|
||||
continue
|
||||
relative_path = f.relative_to(library_dir)
|
||||
|
||||
dir_file_count += 1
|
||||
self.library.included_files.add(f)
|
||||
|
||||
relative_path = f.relative_to(library_dir)
|
||||
|
||||
if not self.library.has_path_entry(relative_path):
|
||||
self.files_not_in_library.append(relative_path)
|
||||
if not self.library.has_path_entry(relative_path):
|
||||
self.files_not_in_library.append(relative_path)
|
||||
except ValueError:
|
||||
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
|
||||
|
||||
end_time_total = time()
|
||||
yield dir_file_count
|
||||
|
||||
@@ -5,8 +5,7 @@ from pathlib import Path
|
||||
import structlog
|
||||
from wcmatch import pathlib
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library
|
||||
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
|
||||
@@ -12,58 +12,66 @@ import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.core.global_settings import DEFAULT_THUMB_CACHE_SIZE
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CacheEntry:
|
||||
class CacheFolder:
|
||||
def __init__(self, path: Path, size: int):
|
||||
self.path: Path = path
|
||||
self.size: int = size
|
||||
|
||||
|
||||
class CacheManager:
|
||||
DEFAULT_MAX_SIZE = 500_000_000
|
||||
DEFAULT_MAX_FOLDER_SIZE = 10_000_000
|
||||
MAX_FOLDER_SIZE = 10 # Number in MiB
|
||||
STAT_MULTIPLIER = 1_000_000 # Multiplier to apply to file stats (bytes) to get user units (MiB)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library_dir: Path,
|
||||
max_size: int = DEFAULT_MAX_SIZE,
|
||||
max_folder_size: int = DEFAULT_MAX_FOLDER_SIZE,
|
||||
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
|
||||
):
|
||||
self._lock = RLock()
|
||||
self.cache_folder = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
|
||||
self.max_folder_size = max_folder_size
|
||||
self.max_size = max(max_size, max_folder_size)
|
||||
"""A class for managing frontend caches, such as for file thumbnails.
|
||||
|
||||
self.folders: list[CacheEntry] = []
|
||||
Args:
|
||||
library_dir(Path): The path of the folder containing the .TagStudio library folder.
|
||||
max_size: (int | float) The maximum size of the cache, in MiB.
|
||||
"""
|
||||
self._lock = RLock()
|
||||
self.cache_path = library_dir / TS_FOLDER_NAME / THUMB_CACHE_NAME
|
||||
self.max_size: int = max(
|
||||
math.floor(max_size * CacheManager.STAT_MULTIPLIER),
|
||||
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
|
||||
)
|
||||
|
||||
self.folders: list[CacheFolder] = []
|
||||
self.current_size = 0
|
||||
if self.cache_folder.exists():
|
||||
for folder in self.cache_folder.iterdir():
|
||||
if self.cache_path.exists():
|
||||
for folder in self.cache_path.iterdir():
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
folder_size = 0
|
||||
for file in folder.iterdir():
|
||||
folder_size += file.stat().st_size
|
||||
self.folders.append(CacheEntry(folder, folder_size))
|
||||
self.folders.append(CacheFolder(folder, folder_size))
|
||||
self.current_size += folder_size
|
||||
|
||||
def _set_mru(self, index: int):
|
||||
"""Move entry at index so it's considered the most recently used."""
|
||||
def _set_most_recent_folder(self, index: int):
|
||||
"""Move CacheFolder at index so it's considered the most recently used folder."""
|
||||
with self._lock as _lock:
|
||||
if index == (len(self.folders) - 1):
|
||||
return
|
||||
entry = self.folders.pop(index)
|
||||
self.folders.append(entry)
|
||||
cache_folder = self.folders.pop(index)
|
||||
self.folders.append(cache_folder)
|
||||
|
||||
def _mru(self) -> Iterable[int]:
|
||||
def _get_most_recent_folder(self) -> Iterable[int]:
|
||||
"""Get each folders index sorted most recently used first."""
|
||||
with self._lock as _lock:
|
||||
return reversed(range(len(self.folders)))
|
||||
|
||||
def _lru(self) -> Iterable[int]:
|
||||
"""Get each folders index sorted least recently used first."""
|
||||
def _least_recent_folder(self) -> Iterable[int]:
|
||||
"""Get each folder's index sorted least recently used first."""
|
||||
with self._lock as _lock:
|
||||
return range(len(self.folders))
|
||||
|
||||
@@ -78,14 +86,14 @@ class CacheManager:
|
||||
self.folders = folders
|
||||
logger.info("[CacheManager] Cleared cache!")
|
||||
|
||||
def _remove_folder(self, entry: CacheEntry) -> bool:
|
||||
def _remove_folder(self, cache_folder: CacheFolder) -> bool:
|
||||
with self._lock as _lock:
|
||||
self.current_size -= entry.size
|
||||
if not entry.path.is_dir():
|
||||
self.current_size -= cache_folder.size
|
||||
if not cache_folder.path.is_dir():
|
||||
return True
|
||||
|
||||
is_empty = True
|
||||
for file in entry.path.iterdir():
|
||||
for file in cache_folder.path.iterdir():
|
||||
assert file.is_file() and file.suffix == ".webp"
|
||||
try:
|
||||
file.unlink(missing_ok=True)
|
||||
@@ -94,61 +102,61 @@ class CacheManager:
|
||||
logger.warn("[CacheManager] Failed to remove file", file=file, error=e)
|
||||
|
||||
if is_empty:
|
||||
entry.path.rmdir()
|
||||
cache_folder.path.rmdir()
|
||||
return True
|
||||
else:
|
||||
size = 0
|
||||
for file in entry.path.iterdir():
|
||||
for file in cache_folder.path.iterdir():
|
||||
size += file.stat().st_size
|
||||
entry.size = size
|
||||
cache_folder.size = size
|
||||
self.current_size += size
|
||||
return False
|
||||
|
||||
def get_file_path(self, file_name: Path) -> Path | None:
|
||||
with self._lock as _lock:
|
||||
for i in self._mru():
|
||||
entry = self.folders[i]
|
||||
file_path = entry.path / file_name
|
||||
for i in self._get_most_recent_folder():
|
||||
cache_folder = self.folders[i]
|
||||
file_path = cache_folder.path / file_name
|
||||
if file_path.exists():
|
||||
self._set_mru(i)
|
||||
self._set_most_recent_folder(i)
|
||||
return file_path
|
||||
return None
|
||||
|
||||
def save_image(self, image: Image.Image, file_name: Path, mode: str = "RGBA"):
|
||||
"""Save an image to the cache."""
|
||||
with self._lock as _lock:
|
||||
entry = self._get_current_folder()
|
||||
file_path = entry.path / file_name
|
||||
cache_folder: CacheFolder = self._get_current_folder()
|
||||
file_path = cache_folder.path / file_name
|
||||
image.save(file_path, mode=mode)
|
||||
|
||||
size = file_path.stat().st_size
|
||||
entry.size += size
|
||||
cache_folder.size += size
|
||||
self.current_size += size
|
||||
self._cull_folders()
|
||||
|
||||
def _create_folder(self) -> CacheEntry:
|
||||
def _create_folder(self) -> CacheFolder:
|
||||
with self._lock as _lock:
|
||||
folder = self.cache_folder / Path(str(math.floor(dt.timestamp(dt.now()))))
|
||||
folder = self.cache_path / Path(str(math.floor(dt.timestamp(dt.now()))))
|
||||
try:
|
||||
folder.mkdir(parents=True)
|
||||
except FileExistsError:
|
||||
for entry in self.folders:
|
||||
if entry.path == folder:
|
||||
return entry
|
||||
entry = CacheEntry(folder, 0)
|
||||
self.folders.append(entry)
|
||||
return entry
|
||||
for cache_folder in self.folders:
|
||||
if cache_folder.path == folder:
|
||||
return cache_folder
|
||||
cache_folder = CacheFolder(folder, 0)
|
||||
self.folders.append(cache_folder)
|
||||
return cache_folder
|
||||
|
||||
def _get_current_folder(self) -> CacheEntry:
|
||||
def _get_current_folder(self) -> CacheFolder:
|
||||
with self._lock as _lock:
|
||||
if len(self.folders) == 0:
|
||||
return self._create_folder()
|
||||
|
||||
for i in self._mru():
|
||||
entry = self.folders[i]
|
||||
if entry.size < self.max_folder_size:
|
||||
self._set_mru(i)
|
||||
return entry
|
||||
for i in self._get_most_recent_folder():
|
||||
cache_folder: CacheFolder = self.folders[i]
|
||||
if cache_folder.size < CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER:
|
||||
self._set_most_recent_folder(i)
|
||||
return cache_folder
|
||||
|
||||
return self._create_folder()
|
||||
|
||||
@@ -159,10 +167,12 @@ class CacheManager:
|
||||
return
|
||||
|
||||
removed: list[int] = []
|
||||
for i in self._lru():
|
||||
entry = self.folders[i]
|
||||
logger.info("[CacheManager] Removing folder due to size limit", folder=entry.path)
|
||||
if self._remove_folder(entry):
|
||||
for i in self._least_recent_folder():
|
||||
cache_folder: CacheFolder = self.folders[i]
|
||||
logger.info(
|
||||
"[CacheManager] Removing folder due to size limit", folder=cache_folder.path
|
||||
)
|
||||
if self._remove_folder(cache_folder):
|
||||
removed.append(i)
|
||||
if self.current_size < self.max_size:
|
||||
break
|
||||
|
||||
@@ -9,7 +9,7 @@ from PySide6.QtCore import Signal
|
||||
|
||||
from tagstudio.core.enums import TagClickActionOption
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.modals.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.view.components.tag_box_view import TagBoxWidgetView
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtGui
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.qt.helpers import file_opener
|
||||
from tagstudio.qt.view.widgets.ignore_modal_view import IgnoreModalView
|
||||
@@ -45,6 +45,7 @@ class IgnoreModal(IgnoreModalView):
|
||||
lines = [f"{line}\n" for line in lines]
|
||||
Ignore.write_ignore_file(self.lib.library_dir, lines)
|
||||
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None: # noqa N802
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore
|
||||
self.__load_file()
|
||||
return super().showEvent(event)
|
||||
|
||||
@@ -144,7 +144,9 @@ class PreviewThumb(PreviewThumbView):
|
||||
return self.__get_image_stats(filepath)
|
||||
|
||||
def _open_file_action_callback(self):
|
||||
open_file(self.__current_file)
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
@@ -154,4 +156,6 @@ class PreviewThumb(PreviewThumbView):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(self.__current_file)
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
|
||||
@@ -20,13 +20,15 @@ from tagstudio.qt.helpers.silent_popen import silent_Popen
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def open_file(path: str | Path, file_manager: bool = False):
|
||||
def open_file(path: str | Path, file_manager: bool = False, windows_start_command: bool = False):
|
||||
"""Open a file in the default application or file explorer.
|
||||
|
||||
Args:
|
||||
path (str): The path to the file to open.
|
||||
file_manager (bool, optional): Whether to open the file in the file manager
|
||||
(e.g. Finder on macOS). Defaults to False.
|
||||
windows_start_command (bool): Flag to determine if the older 'start' command should be used
|
||||
on Windows for opening files. This fixes issues on some systems in niche cases.
|
||||
"""
|
||||
path = Path(path)
|
||||
logger.info("Opening file", path=path)
|
||||
@@ -51,14 +53,26 @@ def open_file(path: str | Path, file_manager: bool = False):
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
else:
|
||||
command = f'"{normpath}"'
|
||||
silent_Popen(
|
||||
command,
|
||||
shell=True,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
if windows_start_command:
|
||||
command_name = "start"
|
||||
# First parameter is for title, NOT filepath
|
||||
command_args = ["", normpath]
|
||||
subprocess.Popen(
|
||||
[command_name] + command_args,
|
||||
shell=True,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
else:
|
||||
command = f'"{normpath}"'
|
||||
silent_Popen(
|
||||
command,
|
||||
shell=True,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
|
||||
)
|
||||
else:
|
||||
if sys.platform == "darwin":
|
||||
command_name = "open"
|
||||
|
||||
@@ -22,8 +22,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core import palette
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library, slugify
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import Library, TagColorGroup, slugify
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.panel import PanelWidget
|
||||
|
||||
@@ -11,8 +11,12 @@ from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
|
||||
from tagstudio.core.library.alchemy.models import Namespace
|
||||
from tagstudio.core.library.alchemy.library import (
|
||||
Library,
|
||||
Namespace,
|
||||
ReservedNamespaceError,
|
||||
slugify,
|
||||
)
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.panel import PanelWidget
|
||||
|
||||
@@ -26,8 +26,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag, TagColorGroup
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.modals.tag_color_selection import TagColorSelection
|
||||
from tagstudio.qt.modals.tag_search import TagSearchModal, TagSearchPanel
|
||||
|
||||
@@ -23,8 +23,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.flowlayout import FlowLayout
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDoubleValidator
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QTabWidget,
|
||||
@@ -18,13 +21,20 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
|
||||
from tagstudio.core.global_settings import Splash, Theme
|
||||
from tagstudio.core.global_settings import (
|
||||
DEFAULT_THUMB_CACHE_SIZE,
|
||||
MIN_THUMB_CACHE_SIZE,
|
||||
Splash,
|
||||
Theme,
|
||||
)
|
||||
from tagstudio.qt.translations import DEFAULT_TRANSLATION, LANGUAGES, Translations
|
||||
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SettingsPanel(PanelWidget):
|
||||
driver: "QtDriver"
|
||||
@@ -142,6 +152,25 @@ class SettingsPanel(PanelWidget):
|
||||
self.generate_thumbs.setChecked(self.driver.settings.generate_thumbs)
|
||||
form_layout.addRow(Translations["settings.generate_thumbs"], self.generate_thumbs)
|
||||
|
||||
# Thumbnail Cache Size
|
||||
self.thumb_cache_size_container = QWidget()
|
||||
self.thumb_cache_size_layout = QHBoxLayout(self.thumb_cache_size_container)
|
||||
self.thumb_cache_size_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.thumb_cache_size_layout.setSpacing(6)
|
||||
self.thumb_cache_size = QLineEdit()
|
||||
self.thumb_cache_size.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.validator = QDoubleValidator(MIN_THUMB_CACHE_SIZE, 1_000_000_000, 2) # High limit
|
||||
self.thumb_cache_size.setValidator(self.validator)
|
||||
self.thumb_cache_size.setText(
|
||||
str(max(self.driver.settings.thumb_cache_size, MIN_THUMB_CACHE_SIZE)).removesuffix(".0")
|
||||
)
|
||||
self.thumb_cache_size_layout.addWidget(self.thumb_cache_size)
|
||||
self.thumb_cache_size_layout.setStretch(1, 2)
|
||||
self.thumb_cache_size_layout.addWidget(QLabel("MiB"))
|
||||
form_layout.addRow(
|
||||
Translations["settings.thumb_cache_size.label"], self.thumb_cache_size_container
|
||||
)
|
||||
|
||||
# Autoplay
|
||||
self.autoplay_checkbox = QCheckBox()
|
||||
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
|
||||
@@ -252,6 +281,10 @@ class SettingsPanel(PanelWidget):
|
||||
"language": self.__get_language(),
|
||||
"open_last_loaded_on_startup": self.open_last_lib_checkbox.isChecked(),
|
||||
"generate_thumbs": self.generate_thumbs.isChecked(),
|
||||
"thumb_cache_size": max(
|
||||
float(self.thumb_cache_size.text()) or DEFAULT_THUMB_CACHE_SIZE,
|
||||
MIN_THUMB_CACHE_SIZE,
|
||||
),
|
||||
"autoplay": self.autoplay_checkbox.isChecked(),
|
||||
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
|
||||
"page_size": int(self.page_size_line_edit.text()),
|
||||
@@ -271,6 +304,7 @@ class SettingsPanel(PanelWidget):
|
||||
driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"]
|
||||
driver.settings.autoplay = settings["autoplay"]
|
||||
driver.settings.generate_thumbs = settings["generate_thumbs"]
|
||||
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
|
||||
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
|
||||
driver.settings.page_size = settings["page_size"]
|
||||
driver.settings.show_filepath = settings["show_filepath"]
|
||||
|
||||
@@ -19,8 +19,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import Library, TagColorGroup
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.flowlayout import FlowLayout
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -7,8 +7,7 @@ import structlog
|
||||
from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
|
||||
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.qt.modals.build_tag import BuildTagPanel
|
||||
from tagstudio.qt.modals.tag_search import TagSearchPanel
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -25,8 +25,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.panel import PanelModal, PanelWidget
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
"""A pagination widget created for TagStudio."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QObject, QSize, Signal
|
||||
from PySide6.QtGui import QIntValidator, QPixmap
|
||||
@@ -285,9 +287,10 @@ class Pagination(QWidget, QObject):
|
||||
|
||||
|
||||
class Validator(QIntValidator):
|
||||
def __init__(self, bottom: int, top: int, parent=None) -> None:
|
||||
super().__init__(bottom, top, parent)
|
||||
def __init__(self, bottom: int, top: int) -> None:
|
||||
super().__init__(bottom, top)
|
||||
|
||||
@override
|
||||
def fixup(self, input: str) -> str:
|
||||
input = input.strip("0")
|
||||
return super().fixup(str(self.top()) if input else "1")
|
||||
|
||||
@@ -13,10 +13,9 @@ logger = structlog.get_logger(__name__)
|
||||
DEFAULT_TRANSLATION = "en"
|
||||
|
||||
LANGUAGES = {
|
||||
# "Cantonese (Traditional)": "yue_Hant", # Empty
|
||||
"Chinese (Simplified)": "zh_Hans",
|
||||
"Chinese (Traditional)": "zh_Hant",
|
||||
# "Czech": "cs", # Minimal
|
||||
"Czech": "cs",
|
||||
# "Danish": "da", # Minimal
|
||||
"Dutch": "nl",
|
||||
"English": "en",
|
||||
@@ -29,7 +28,8 @@ LANGUAGES = {
|
||||
"Norwegian Bokmål": "nb_NO",
|
||||
"Polish": "pl",
|
||||
"Portuguese (Brazil)": "pt_BR",
|
||||
# "Portuguese (Portugal)": "pt", # Empty
|
||||
"Portuguese (Portugal)": "pt",
|
||||
"Romanian": "ro",
|
||||
"Russian": "ru",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
|
||||
@@ -49,16 +49,18 @@ import tagstudio.qt.resources_rc # noqa: F401
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
|
||||
from tagstudio.core.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme
|
||||
from tagstudio.core.global_settings import (
|
||||
DEFAULT_GLOBAL_SETTINGS_PATH,
|
||||
GlobalSettings,
|
||||
Theme,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.enums import (
|
||||
BrowsingState,
|
||||
FieldTypeEnum,
|
||||
ItemType,
|
||||
SortingModeEnum,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.fields import _FieldID
|
||||
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, FieldID, Library, LibraryStatus
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
@@ -1125,7 +1127,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
elif name == MacroID.BUILD_URL:
|
||||
url = TagStudioCore.build_url(entry, source)
|
||||
if url is not None:
|
||||
self.lib.add_field_to_entry(entry.id, field_id=_FieldID.SOURCE, value=url)
|
||||
self.lib.add_field_to_entry(entry.id, field_id=FieldID.SOURCE, value=url)
|
||||
elif name == MacroID.MATCH:
|
||||
TagStudioCore.match_conditions(self.lib, entry.id)
|
||||
elif name == MacroID.CLEAN_URL:
|
||||
@@ -1693,15 +1695,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
open_status = LibraryStatus(
|
||||
success=False, library_path=path, message=type(e).__name__, msg_description=str(e)
|
||||
)
|
||||
|
||||
max_size: int = self.cached_values.value(
|
||||
SettingItems.THUMB_CACHE_SIZE_LIMIT,
|
||||
defaultValue=CacheManager.DEFAULT_MAX_SIZE,
|
||||
type=int,
|
||||
) # type: ignore
|
||||
self.cache_manager = CacheManager(path, max_size=max_size)
|
||||
self.cache_manager = CacheManager(path, max_size=self.settings.thumb_cache_size)
|
||||
logger.info(
|
||||
f"[Config] Thumbnail cache size limit: {format_size(max_size)}",
|
||||
f"[Config] Thumbnail Cache Size: {format_size(self.settings.thumb_cache_size)}",
|
||||
)
|
||||
|
||||
# Migration is required
|
||||
|
||||
@@ -7,8 +7,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.qt.flowlayout import FlowLayout
|
||||
from tagstudio.qt.widgets.fields import FieldWidget
|
||||
from tagstudio.qt.widgets.tag import TagWidget
|
||||
|
||||
@@ -13,8 +13,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.constants import IGNORE_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb
|
||||
|
||||
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import TagColorGroup
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.flowlayout import FlowLayout
|
||||
from tagstudio.qt.modals.build_color import BuildColorPanel
|
||||
|
||||
@@ -30,10 +30,10 @@ from tagstudio.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from tagstudio.core.enums import LibraryPrefs
|
||||
from tagstudio.core.library.alchemy import default_color_groups
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.constants import SQL_FILENAME
|
||||
from tagstudio.core.library.alchemy.library import Entry, TagAlias, TagParent
|
||||
from tagstudio.core.library.alchemy.library import Library as SqliteLibrary
|
||||
from tagstudio.core.library.alchemy.models import Entry, TagAlias
|
||||
from tagstudio.core.library.helpers.migration import json_to_sql_color
|
||||
from tagstudio.core.library.json.library import Library as JsonLibrary
|
||||
from tagstudio.core.library.json.library import Tag as JsonTag
|
||||
from tagstudio.qt.helpers.custom_runnable import CustomRunnable
|
||||
@@ -492,7 +492,7 @@ class JsonMigrationModal(QObject):
|
||||
|
||||
def finish_migration(self):
|
||||
"""Finish the migration upon user approval."""
|
||||
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME
|
||||
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SQL_FILENAME
|
||||
if self.temp_path.exists():
|
||||
self.temp_path.rename(final_name)
|
||||
|
||||
@@ -779,7 +779,7 @@ class JsonMigrationModal(QObject):
|
||||
for tag in self.sql_lib.tags:
|
||||
tag_id = tag.id # Tag IDs start at 0
|
||||
sql_color = (tag.color_namespace, tag.color_slug)
|
||||
json_color = default_color_groups.json_to_sql_color(self.json_lib.get_tag(tag_id).color)
|
||||
json_color = json_to_sql_color(self.json_lib.get_tag(tag_id).color)
|
||||
|
||||
logger.info(
|
||||
"[Color Parity]",
|
||||
|
||||
@@ -24,14 +24,15 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
|
||||
from tagstudio.core.library.alchemy.library import (
|
||||
BaseField,
|
||||
DatetimeField,
|
||||
FieldTypeEnum,
|
||||
Entry,
|
||||
Library,
|
||||
Tag,
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controller.components.tag_box_controller import TagBoxWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -12,7 +12,7 @@ from PySide6.QtGui import QAction, QColor, QEnterEvent, QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.core.library.alchemy.library import Tag
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.helpers.escape_text import escape_text
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -10,7 +10,7 @@ from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QAction, QColor, QEnterEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import TagColorGroup
|
||||
from tagstudio.qt.helpers.escape_text import escape_text
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.tag import (
|
||||
|
||||
@@ -11,7 +11,7 @@ from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.library.alchemy.library import TagColorGroup
|
||||
from tagstudio.core.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.tag import (
|
||||
|
||||
@@ -1524,8 +1524,23 @@ class ThumbRenderer(QObject):
|
||||
if _filepath and _filepath.is_file():
|
||||
try:
|
||||
ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower()
|
||||
# Images =======================================================
|
||||
# Ebooks =======================================================
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._epub_cover(_filepath)
|
||||
# Krita ========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._krita_thumb(_filepath)
|
||||
# VTF ==========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._vtf_thumb(_filepath)
|
||||
# Images =======================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
|
||||
):
|
||||
# Raw Images -----------------------------------------------
|
||||
@@ -1552,11 +1567,6 @@ class ThumbRenderer(QObject):
|
||||
# PowerPoint Slideshow
|
||||
elif ext in {".pptx"}:
|
||||
image = self._powerpoint_thumb(_filepath)
|
||||
# Krita ========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._krita_thumb(_filepath)
|
||||
# OpenDocument/OpenOffice ======================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
|
||||
@@ -1590,11 +1600,6 @@ class ThumbRenderer(QObject):
|
||||
savable_media_type = False
|
||||
if image is not None:
|
||||
image = self._apply_overlay_color(image, UiColor.GREEN)
|
||||
# Ebooks =======================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._epub_cover(_filepath)
|
||||
# Blender ======================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
|
||||
@@ -1605,11 +1610,6 @@ class ThumbRenderer(QObject):
|
||||
ext, MediaCategories.PDF_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._pdf_thumb(_filepath, adj_size)
|
||||
# VTF ==========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._vtf_thumb(_filepath)
|
||||
# No Rendered Thumbnail ========================================
|
||||
if not image:
|
||||
raise NoRendererError
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"settings.theme.label": "Theme:",
|
||||
"settings.theme.light": "Light",
|
||||
"settings.theme.system": "System",
|
||||
"settings.thumb_cache_size.label": "Thumbnail Cache Size",
|
||||
"settings.title": "Settings",
|
||||
"settings.zeropadding.label": "Date Zero-Padding",
|
||||
"sorting.direction.ascending": "Ascending",
|
||||
|
||||
@@ -16,8 +16,7 @@ CWD = Path(__file__).parent
|
||||
sys.path.insert(0, str(CWD.parent))
|
||||
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ from collections.abc import Callable
|
||||
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagAlias
|
||||
from tagstudio.core.library.alchemy.library import Library, Tag, TagAlias
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.modals.build_tag import BuildTagPanel, CustomTableItem
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,8 +16,7 @@ from PySide6.QtWidgets import QMenu, QMenuBar
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.library import Entry, Library, LibraryStatus
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controller.widgets.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.modals.settings_panel import SettingsPanel
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ import structlog
|
||||
|
||||
from tagstudio.core.enums import DefaultEnum, LibraryPrefs
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
from tagstudio.core.library.alchemy.library import (
|
||||
Entry,
|
||||
FieldID,
|
||||
Library,
|
||||
Tag,
|
||||
TextField,
|
||||
_FieldID, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -270,7 +271,7 @@ def test_mirror_entry_fields(library: Library, entry_full: Entry):
|
||||
path=Path("xxx"),
|
||||
fields=[
|
||||
TextField(
|
||||
type_key=_FieldID.NOTES.name,
|
||||
type_key=FieldID.NOTES.name,
|
||||
value="notes",
|
||||
position=0,
|
||||
)
|
||||
@@ -292,8 +293,8 @@ def test_mirror_entry_fields(library: Library, entry_full: Entry):
|
||||
# make sure fields are there after getting it from the library again
|
||||
assert len(entry.fields) == 2
|
||||
assert {x.type_key for x in entry.fields} == {
|
||||
_FieldID.TITLE.name,
|
||||
_FieldID.NOTES.name,
|
||||
FieldID.TITLE.name,
|
||||
FieldID.NOTES.name,
|
||||
}
|
||||
|
||||
|
||||
@@ -308,14 +309,14 @@ def test_merge_entries(library: Library):
|
||||
folder=folder,
|
||||
path=Path("a"),
|
||||
fields=[
|
||||
TextField(type_key=_FieldID.AUTHOR.name, value="Author McAuthorson", position=0),
|
||||
TextField(type_key=_FieldID.DESCRIPTION.name, value="test description", position=2),
|
||||
TextField(type_key=FieldID.AUTHOR.name, value="Author McAuthorson", position=0),
|
||||
TextField(type_key=FieldID.DESCRIPTION.name, value="test description", position=2),
|
||||
],
|
||||
)
|
||||
b = Entry(
|
||||
folder=folder,
|
||||
path=Path("b"),
|
||||
fields=[TextField(type_key=_FieldID.NOTES.name, value="test note", position=1)],
|
||||
fields=[TextField(type_key=FieldID.NOTES.name, value="test note", position=1)],
|
||||
)
|
||||
ids = library.add_entries([a, b])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user