mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-05-26 02:32:28 +00:00
feat!: tag categories (#655)
* refactor: remove TagBoxField and TagField (NOT WORKING)
* refactor: remove tag field types
* ci: fix mypy and ruff tests
* refactor: split up preview_panel
* fix: search now uses `TagEntry` (#656)
* fix: move theme check inside class
* refactor: reimplement file previews
* refactor: modularize `file_attributes.py`
* ui: show fields in preview panel
known issues:
- fields to not visually update after being edited until the entries are reloaded from the thumbnail grid (yes, the thumbnail grid)
- add field button currently non-functional
- surprise segfaults
* search: remove TagEntry join
* fix: remove extra `self.filter` assignment
* add success return flag to `add_tags_to_entry()`
* refactor: use entry IDs instead of objects and indices
- fixes preview panel not updating after entry edits
- fixes slow selection performance
- fixes double render call
* feat: add tag categories to preview panel
* ui: add "is category" checkbox in tag panel
* fix: tags can be compared for name sorting
* fix: don't add tags to previous selections
* fix: badges now properly update
* ui: hide sizeGrip
* ui: add blue ui color
* ui: display empty selection; better multi-selection
* cleanup comments; rename tsp to tag_search_panel
* fix(ui): properly unset container callbacks
* fix: optimize queries
* fix: catch int cast exception
* fix: remove unnecessary update calls
* fix: restore try/except block in preview_panel
* fix: correct type hints for get_tag_categories
* fix: tags no longer lazy load subtags and aliases
* fix: recursively include parent tag categories
* chore: update copyright info
* chore: remove unused code
* fix: load fields for selected entry
* refactor: remove `is_connected` from AddFieldModal
* fix: include category tags under their own categories
* fix: badges now update when last tag is removed
* fix: resolve differences with main
* fix: return empty set in place of `None`
* ui: add field highlighting, tweak theming
* refactor!: eradicate use of the term "subtag"
- Removes ambiguity between the use of the term "parent tag" and "subtag"
- Fixes inconstancies between the use of the term "subtag" to refer to either parent tags or child tags
- Fixes duplicate and ambiguous subtags mapped relationship for the Tag model
- Does NOT fix tests
* fix: catch and show library load errors
* tests: fix and/or remove tests
* suppress db preference warnings
* tests: add field container tests
* tests: add tag category tests
* refactor(ui): move recent libraries list to file menu
* docs: update roadmap and docs for tag categories
* fix: restore json migration functionality
* logs: remove/update debug logs
* chore: remove unused code
* tests: remove tests related to `TagBoxWidget`
* ui: optimize selection and badge updates
* docs: update usage
* fix: change typo of `tag.id` to `tag_id`
Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
* fix: use term "child tags" instead of "subtags" in docstring
Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
* fix: reference `child_id` instead of `parent_id` when deleting tags
Co-Authored-By: Jann Stute <46534683+Computerdores@users.noreply.github.com>
* add TODO comment for `update_thumbs()` optimization
* fix: combine and check (most) built-in tag data from JSON
Known issue: Tag colors from built-in JSON tags are not updated. This can be seen in the failing test.
* refactor: rename `select_item()` to `toggle_item_selection()`
* add TODO to optimize `add_tags_to_entry()`
* fix: remove unnecessary joins in search
* Revert "fix: remove unnecessary joins in search"
This reverts commit 4c019ca19c.
* fix: remove unnecessary joins in search
* reremove unused method `get_all_child_tag_ids()`
* fix: migrate user-edited tag colors for built-in tags
* style: update header for contributor-created files
* fix: use absolute path in "open file" context menu
* chore: change paramater type hint
---------
Co-authored-by: python357-1 <jb2101554@gmail.com>
Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
5860a2ca9b
commit
fce97852d3
@@ -158,6 +158,8 @@
|
||||
"menu.file.close_library": "&Close Library",
|
||||
"menu.file.new_library": "New Library",
|
||||
"menu.file.open_create_library": "&Open/Create Library",
|
||||
"menu.file.open_recent_library": "Open Recent",
|
||||
"menu.file.clear_recent_libraries": "Clear Recent",
|
||||
"menu.file.open_library": "Open Library",
|
||||
"menu.file.refresh_directories": "&Refresh Directories",
|
||||
"menu.file.save_backup": "&Save Library Backup",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
VERSION: str = "9.5.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release"
|
||||
|
||||
@@ -11,7 +15,12 @@ FONT_SAMPLE_TEXT: str = (
|
||||
)
|
||||
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
|
||||
|
||||
TAG_FAVORITE = 1
|
||||
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
|
||||
# the legacy JSON database. These are used to help migrate libraries from JSON to SQLite.
|
||||
LEGACY_TAG_FIELD_IDS: set[int] = {6, 7, 8}
|
||||
|
||||
TAG_ARCHIVED = 0
|
||||
TAG_FAVORITE = 1
|
||||
TAG_META = 2
|
||||
RESERVED_TAG_START = 0
|
||||
RESERVED_TAG_END = 999
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import enum
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@@ -20,10 +24,11 @@ class Theme(str, enum.Enum):
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
COLOR_BG = "#65000000"
|
||||
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
COLOR_DISABLED_BG = "#65440D12"
|
||||
COLOR_HOVER = "#65444444"
|
||||
COLOR_PRESSED = "#65777777"
|
||||
COLOR_DISABLED_BG = "#30000000"
|
||||
COLOR_FORBIDDEN = "#65F39CAA"
|
||||
COLOR_FORBIDDEN_BG = "#65440D12"
|
||||
|
||||
|
||||
class OpenStatus(enum.IntEnum):
|
||||
@@ -65,4 +70,4 @@ class LibraryPrefs(DefaultEnum):
|
||||
IS_EXCLUDE_LIST = True
|
||||
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
|
||||
PAGE_SIZE: int = 500
|
||||
DB_VERSION: int = 2
|
||||
DB_VERSION: int = 3
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -44,7 +48,10 @@ def make_tables(engine: Engine) -> None:
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
conn.execute(
|
||||
text(f"INSERT INTO tags (id, name, color) VALUES ({RESERVED_TAG_END}, 'temp', 1)")
|
||||
text(
|
||||
"INSERT INTO tags (id, name, color, is_category) VALUES "
|
||||
f"({RESERVED_TAG_END}, 'temp', 1, false)"
|
||||
)
|
||||
)
|
||||
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
|
||||
conn.commit()
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# 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
|
||||
@@ -11,7 +15,7 @@ from .db import Base
|
||||
from .enums import FieldTypeEnum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Entry, Tag, ValueType
|
||||
from .models import Entry, ValueType
|
||||
|
||||
|
||||
class BaseField(Base):
|
||||
@@ -75,33 +79,11 @@ class TextField(BaseField):
|
||||
def __eq__(self, value) -> bool:
|
||||
if isinstance(value, TextField):
|
||||
return self.__key() == value.__key()
|
||||
elif isinstance(value, (TagBoxField, DatetimeField)):
|
||||
elif isinstance(value, DatetimeField):
|
||||
return False
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TagBoxField(BaseField):
|
||||
__tablename__ = "tag_box_fields"
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_fields")
|
||||
|
||||
def __key(self):
|
||||
return (
|
||||
self.entry_id,
|
||||
self.type_key,
|
||||
)
|
||||
|
||||
@property
|
||||
def value(self) -> None:
|
||||
"""For interface compatibility with other field types."""
|
||||
return None
|
||||
|
||||
def __eq__(self, value) -> bool:
|
||||
if isinstance(value, TagBoxField):
|
||||
return self.__key() == value.__key()
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DatetimeField(BaseField):
|
||||
__tablename__ = "datetime_fields"
|
||||
|
||||
@@ -133,9 +115,6 @@ class _FieldID(Enum):
|
||||
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
|
||||
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE)
|
||||
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
|
||||
TAGS = DefaultField(id=6, name="Tags", type=FieldTypeEnum.TAGS)
|
||||
TAGS_CONTENT = DefaultField(id=7, name="Content Tags", type=FieldTypeEnum.TAGS, is_default=True)
|
||||
TAGS_META = DefaultField(id=8, name="Meta Tags", type=FieldTypeEnum.TAGS, is_default=True)
|
||||
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)
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
# 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 .db import Base
|
||||
|
||||
|
||||
class TagSubtag(Base):
|
||||
__tablename__ = "tag_subtags"
|
||||
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 TagField(Base):
|
||||
__tablename__ = "tag_fields"
|
||||
class TagEntry(Base):
|
||||
__tablename__ = "tag_entries"
|
||||
|
||||
field_id: Mapped[int] = mapped_column(ForeignKey("tag_box_fields.id"), primary_key=True)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
@@ -7,8 +11,8 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from humanfriendly import format_timespan
|
||||
@@ -32,6 +36,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import (
|
||||
Session,
|
||||
contains_eager,
|
||||
joinedload,
|
||||
make_transient,
|
||||
selectinload,
|
||||
)
|
||||
@@ -39,8 +44,12 @@ from src.core.library.json.library import Library as JsonLibrary # type: ignore
|
||||
|
||||
from ...constants import (
|
||||
BACKUP_FOLDER_NAME,
|
||||
LEGACY_TAG_FIELD_IDS,
|
||||
RESERVED_TAG_END,
|
||||
RESERVED_TAG_START,
|
||||
TAG_ARCHIVED,
|
||||
TAG_FAVORITE,
|
||||
TAG_META,
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from ...enums import LibraryPrefs
|
||||
@@ -49,11 +58,10 @@ from .enums import FieldTypeEnum, FilterState, SortingModeEnum, TagColor
|
||||
from .fields import (
|
||||
BaseField,
|
||||
DatetimeField,
|
||||
TagBoxField,
|
||||
TextField,
|
||||
_FieldID,
|
||||
)
|
||||
from .joins import TagField, TagSubtag
|
||||
from .joins import TagEntry, TagParent
|
||||
from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType
|
||||
from .visitors import SQLBoolExpressionBuilder
|
||||
|
||||
@@ -74,13 +82,19 @@ def slugify(input_string: str) -> str:
|
||||
|
||||
|
||||
def get_default_tags() -> tuple[Tag, ...]:
|
||||
meta_tag = Tag(
|
||||
id=TAG_META,
|
||||
name="Meta Tags",
|
||||
aliases={TagAlias(name="Meta"), TagAlias(name="Meta Tag")},
|
||||
is_category=True,
|
||||
)
|
||||
archive_tag = Tag(
|
||||
id=TAG_ARCHIVED,
|
||||
name="Archived",
|
||||
aliases={TagAlias(name="Archive")},
|
||||
parent_tags={meta_tag},
|
||||
color=TagColor.RED,
|
||||
)
|
||||
|
||||
favorite_tag = Tag(
|
||||
id=TAG_FAVORITE,
|
||||
name="Favorite",
|
||||
@@ -88,10 +102,15 @@ def get_default_tags() -> tuple[Tag, ...]:
|
||||
TagAlias(name="Favorited"),
|
||||
TagAlias(name="Favorites"),
|
||||
},
|
||||
parent_tags={meta_tag},
|
||||
color=TagColor.YELLOW,
|
||||
)
|
||||
|
||||
return archive_tag, favorite_tag
|
||||
return archive_tag, favorite_tag, meta_tag
|
||||
|
||||
|
||||
# The difference in the number of default JSON tags vs default tags in the current version.
|
||||
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -168,18 +187,30 @@ class Library:
|
||||
color=TagColor.get_color_from_str(tag.color),
|
||||
)
|
||||
)
|
||||
# Apply user edits to built-in JSON tags.
|
||||
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1):
|
||||
updated_tag = self.get_tag(tag.id)
|
||||
updated_tag.color = TagColor.get_color_from_str(tag.color)
|
||||
self.update_tag(updated_tag) # NOTE: This just calls add_tag?
|
||||
|
||||
# Tag Aliases
|
||||
for tag in json_lib.tags:
|
||||
for alias in tag.aliases:
|
||||
if not alias:
|
||||
break
|
||||
self.add_alias(name=alias, tag_id=tag.id)
|
||||
# Only add new (user-created) aliases to the default tags.
|
||||
# This prevents pre-existing built-in aliases from being added as duplicates.
|
||||
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1):
|
||||
for dt in get_default_tags():
|
||||
if dt.id == tag.id and alias not in dt.alias_strings:
|
||||
self.add_alias(name=alias, tag_id=tag.id)
|
||||
else:
|
||||
self.add_alias(name=alias, tag_id=tag.id)
|
||||
|
||||
# Tag Subtags
|
||||
# Parent Tags (Previously known as "Subtags" in JSON)
|
||||
for tag in json_lib.tags:
|
||||
for subtag_id in tag.subtag_ids:
|
||||
self.add_subtag(parent_id=tag.id, child_id=subtag_id)
|
||||
for child_id in tag.subtag_ids:
|
||||
self.add_parent_tag(parent_id=tag.id, child_id=child_id)
|
||||
|
||||
# Entries
|
||||
self.add_entries(
|
||||
@@ -196,11 +227,15 @@ class Library:
|
||||
for entry in json_lib.entries:
|
||||
for field in entry.fields:
|
||||
for k, v in field.items():
|
||||
self.add_entry_field_type(
|
||||
entry_ids=(entry.id + 1), # JSON IDs start at 0 instead of 1
|
||||
field_id=self.get_field_name_from_id(k),
|
||||
value=v,
|
||||
)
|
||||
# Old tag fields get added as tags
|
||||
if k in LEGACY_TAG_FIELD_IDS:
|
||||
self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v)
|
||||
else:
|
||||
self.add_field_to_entry(
|
||||
entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1
|
||||
field_id=self.get_field_name_from_id(k),
|
||||
value=v,
|
||||
)
|
||||
|
||||
# Preferences
|
||||
self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list])
|
||||
@@ -236,9 +271,7 @@ class Library:
|
||||
|
||||
return self.open_sqlite_library(library_dir, is_new)
|
||||
|
||||
def open_sqlite_library(
|
||||
self, library_dir: Path, is_new: bool, add_default_data: bool = True
|
||||
) -> LibraryStatus:
|
||||
def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
|
||||
connection_string = URL.create(
|
||||
drivername="sqlite",
|
||||
database=str(self.storage_path),
|
||||
@@ -259,13 +292,13 @@ class Library:
|
||||
with Session(self.engine) as session:
|
||||
make_tables(self.engine)
|
||||
|
||||
if add_default_data:
|
||||
# Add default tags to new libraries only.
|
||||
if is_new:
|
||||
tags = get_default_tags()
|
||||
try:
|
||||
session.add_all(tags)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# default tags may exist already
|
||||
session.rollback()
|
||||
|
||||
# dont check db version when creating new library
|
||||
@@ -284,12 +317,13 @@ class Library:
|
||||
)
|
||||
|
||||
for pref in LibraryPrefs:
|
||||
try:
|
||||
session.add(Preferences(key=pref.name, value=pref.default))
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
logger.debug("preference already exists", pref=pref)
|
||||
session.rollback()
|
||||
with catch_warnings(record=True):
|
||||
try:
|
||||
session.add(Preferences(key=pref.name, value=pref.default))
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
logger.debug("preference already exists", pref=pref)
|
||||
session.rollback()
|
||||
|
||||
for field in _FieldID:
|
||||
try:
|
||||
@@ -359,43 +393,6 @@ class Library:
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
def remove_field_tag(self, entry: Entry, tag_id: int, field_key: str) -> bool:
|
||||
assert isinstance(field_key, str), f"field_key is {type(field_key)}"
|
||||
with Session(self.engine) as session:
|
||||
# find field matching entry and field_type
|
||||
field = session.scalars(
|
||||
select(TagBoxField).where(
|
||||
and_(
|
||||
TagBoxField.entry_id == entry.id,
|
||||
TagBoxField.type_key == field_key,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not field:
|
||||
logger.error("no field found", entry=entry, field=field)
|
||||
return False
|
||||
|
||||
try:
|
||||
# find the record in `TagField` table and delete it
|
||||
tag_field = session.scalars(
|
||||
select(TagField).where(
|
||||
and_(
|
||||
TagField.tag_id == tag_id,
|
||||
TagField.field_id == field.id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if tag_field:
|
||||
session.delete(tag_field)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
logger.exception(e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
def get_entry(self, entry_id: int) -> Entry | None:
|
||||
"""Load entry without joins."""
|
||||
with Session(self.engine) as session:
|
||||
@@ -406,22 +403,29 @@ class Library:
|
||||
make_transient(entry)
|
||||
return entry
|
||||
|
||||
def get_entry_full(self, entry_id: int) -> Entry | None:
|
||||
"""Load entry an join with all joins and all tags."""
|
||||
def get_entry_full(
|
||||
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
|
||||
) -> Entry | None:
|
||||
"""Load entry and join with all joins and all tags."""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Entry).where(Entry.id == entry_id)
|
||||
statement = (
|
||||
statement.outerjoin(Entry.text_fields)
|
||||
.outerjoin(Entry.datetime_fields)
|
||||
.outerjoin(Entry.tag_box_fields)
|
||||
)
|
||||
statement = statement.options(
|
||||
selectinload(Entry.text_fields),
|
||||
selectinload(Entry.datetime_fields),
|
||||
selectinload(Entry.tag_box_fields)
|
||||
.joinedload(TagBoxField.tags)
|
||||
.options(selectinload(Tag.aliases), selectinload(Tag.subtags)),
|
||||
)
|
||||
if with_fields:
|
||||
statement = (
|
||||
statement.outerjoin(Entry.text_fields)
|
||||
.outerjoin(Entry.datetime_fields)
|
||||
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
|
||||
)
|
||||
if with_tags:
|
||||
statement = (
|
||||
statement.outerjoin(Entry.tags)
|
||||
.outerjoin(TagAlias)
|
||||
.options(
|
||||
selectinload(Entry.tags).options(
|
||||
joinedload(Tag.aliases),
|
||||
joinedload(Tag.parent_tags),
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = session.scalar(statement)
|
||||
if not entry:
|
||||
return None
|
||||
@@ -429,6 +433,32 @@ class Library:
|
||||
make_transient(entry)
|
||||
return entry
|
||||
|
||||
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
|
||||
"""Load entry and join with all joins and all tags."""
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Entry).where(Entry.id.in_(set(entry_ids)))
|
||||
statement = (
|
||||
statement.outerjoin(Entry.text_fields)
|
||||
.outerjoin(Entry.datetime_fields)
|
||||
.outerjoin(Entry.tags)
|
||||
)
|
||||
statement = statement.options(
|
||||
selectinload(Entry.text_fields),
|
||||
selectinload(Entry.datetime_fields),
|
||||
selectinload(Entry.tags).options(
|
||||
selectinload(Tag.aliases),
|
||||
selectinload(Tag.parent_tags),
|
||||
),
|
||||
)
|
||||
statement = statement.distinct()
|
||||
|
||||
entries = session.execute(statement).scalars()
|
||||
entries = entries.unique()
|
||||
|
||||
for entry in entries:
|
||||
yield entry
|
||||
session.expunge(entry)
|
||||
|
||||
@property
|
||||
def entries_count(self) -> int:
|
||||
with Session(self.engine) as session:
|
||||
@@ -443,12 +473,12 @@ class Library:
|
||||
stmt = (
|
||||
stmt.outerjoin(Entry.text_fields)
|
||||
.outerjoin(Entry.datetime_fields)
|
||||
.outerjoin(Entry.tag_box_fields)
|
||||
.outerjoin(Entry.tags)
|
||||
)
|
||||
stmt = stmt.options(
|
||||
contains_eager(Entry.text_fields),
|
||||
contains_eager(Entry.datetime_fields),
|
||||
contains_eager(Entry.tag_box_fields).selectinload(TagBoxField.tags),
|
||||
contains_eager(Entry.tags),
|
||||
)
|
||||
|
||||
stmt = stmt.distinct()
|
||||
@@ -464,8 +494,8 @@ class Library:
|
||||
@property
|
||||
def tags(self) -> list[Tag]:
|
||||
with Session(self.engine) as session:
|
||||
# load all tags and join subtags
|
||||
tags_query = select(Tag).options(selectinload(Tag.subtags))
|
||||
# load all tags and join parent tags
|
||||
tags_query = select(Tag).options(selectinload(Tag.parent_tags))
|
||||
tags = session.scalars(tags_query).unique()
|
||||
tags_list = list(tags)
|
||||
|
||||
@@ -547,13 +577,8 @@ class Library:
|
||||
|
||||
if search.ast:
|
||||
start_time = time.time()
|
||||
|
||||
statement = statement.outerjoin(Entry.tag_box_fields).where(
|
||||
SQLBoolExpressionBuilder(self).visit(search.ast)
|
||||
)
|
||||
|
||||
statement = statement.where(SQLBoolExpressionBuilder(self).visit(search.ast))
|
||||
end_time = time.time()
|
||||
|
||||
logger.info(
|
||||
f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})"
|
||||
)
|
||||
@@ -566,16 +591,7 @@ class Library:
|
||||
elif extensions:
|
||||
statement = statement.where(Entry.suffix.in_(extensions))
|
||||
|
||||
statement = statement.options(
|
||||
selectinload(Entry.text_fields),
|
||||
selectinload(Entry.datetime_fields),
|
||||
selectinload(Entry.tag_box_fields)
|
||||
.joinedload(TagBoxField.tags)
|
||||
.options(selectinload(Tag.aliases), selectinload(Tag.subtags)),
|
||||
)
|
||||
|
||||
statement = statement.distinct(Entry.id)
|
||||
|
||||
query_count = select(func.count()).select_from(statement.alias("entries"))
|
||||
count_all: int = session.execute(query_count).scalar()
|
||||
|
||||
@@ -585,7 +601,6 @@ class Library:
|
||||
sort_on = Entry.id
|
||||
|
||||
statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on))
|
||||
|
||||
statement = statement.limit(search.limit).offset(search.offset)
|
||||
|
||||
logger.info(
|
||||
@@ -613,7 +628,7 @@ class Library:
|
||||
with Session(self.engine) as session:
|
||||
query = select(Tag)
|
||||
query = query.options(
|
||||
selectinload(Tag.subtags),
|
||||
selectinload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
).limit(tag_limit)
|
||||
|
||||
@@ -640,24 +655,6 @@ class Library:
|
||||
|
||||
return res
|
||||
|
||||
def get_all_child_tag_ids(self, tag_id: int) -> list[int]:
|
||||
"""Recursively traverse a Tag's subtags and return a list of all children tags."""
|
||||
all_subtags: set[int] = {tag_id}
|
||||
|
||||
with Session(self.engine) as session:
|
||||
tag = session.scalar(select(Tag).where(Tag.id == tag_id))
|
||||
if tag is None:
|
||||
raise ValueError(f"No tag found with id {tag_id}.")
|
||||
|
||||
subtag_ids = tag.subtag_ids
|
||||
|
||||
all_subtags.update(subtag_ids)
|
||||
|
||||
for sub_id in subtag_ids:
|
||||
all_subtags.update(self.get_all_child_tag_ids(sub_id))
|
||||
|
||||
return list(all_subtags)
|
||||
|
||||
def update_entry_path(self, entry_id: int | Entry, path: Path) -> None:
|
||||
if isinstance(entry_id, Entry):
|
||||
entry_id = entry_id.id
|
||||
@@ -679,11 +676,11 @@ class Library:
|
||||
def remove_tag(self, tag: Tag):
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
subtags = session.scalars(
|
||||
select(TagSubtag).where(TagSubtag.parent_id == tag.id)
|
||||
child_tags = session.scalars(
|
||||
select(TagParent).where(TagParent.child_id == tag.id)
|
||||
).all()
|
||||
tags_query = select(Tag).options(
|
||||
selectinload(Tag.subtags), selectinload(Tag.aliases)
|
||||
selectinload(Tag.parent_tags), selectinload(Tag.aliases)
|
||||
)
|
||||
tag = session.scalar(tags_query.where(Tag.id == tag.id))
|
||||
aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id))
|
||||
@@ -691,9 +688,9 @@ class Library:
|
||||
for alias in aliases or []:
|
||||
session.delete(alias)
|
||||
|
||||
for subtag in subtags or []:
|
||||
session.delete(subtag)
|
||||
session.expunge(subtag)
|
||||
for child_tag in child_tags or []:
|
||||
session.delete(child_tag)
|
||||
session.expunge(child_tag)
|
||||
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
@@ -707,16 +704,6 @@ class Library:
|
||||
|
||||
return None
|
||||
|
||||
def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None:
|
||||
with Session(self.engine) as session:
|
||||
field_ = session.scalars(select(TagBoxField).where(TagBoxField.id == field.id)).one()
|
||||
|
||||
tag = session.scalars(select(Tag).where(Tag.id == tag.id)).one()
|
||||
|
||||
field_.tags.remove(tag)
|
||||
session.add(field_)
|
||||
session.commit()
|
||||
|
||||
def update_field_position(
|
||||
self,
|
||||
field_class: type[BaseField],
|
||||
@@ -786,7 +773,7 @@ class Library:
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: BaseField,
|
||||
content: str | datetime | set[Tag],
|
||||
content: str | datetime,
|
||||
):
|
||||
if isinstance(entry_ids, int):
|
||||
entry_ids = [entry_ids]
|
||||
@@ -820,17 +807,17 @@ class Library:
|
||||
session.expunge(field)
|
||||
return field
|
||||
|
||||
def add_entry_field_type(
|
||||
def add_field_to_entry(
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
entry_id: int,
|
||||
*,
|
||||
field: ValueType | None = None,
|
||||
field_id: _FieldID | str | None = None,
|
||||
value: str | datetime | list[int] | None = None,
|
||||
value: str | datetime | None = None,
|
||||
) -> bool:
|
||||
logger.info(
|
||||
"add_field_to_entry",
|
||||
entry_ids=entry_ids,
|
||||
entry_id=entry_id,
|
||||
field_type=field,
|
||||
field_id=field_id,
|
||||
value=value,
|
||||
@@ -838,32 +825,17 @@ class Library:
|
||||
# supply only instance or ID, not both
|
||||
assert bool(field) != (field_id is not None)
|
||||
|
||||
if isinstance(entry_ids, int):
|
||||
entry_ids = [entry_ids]
|
||||
|
||||
if not field:
|
||||
if isinstance(field_id, _FieldID):
|
||||
field_id = field_id.name
|
||||
field = self.get_value_type(field_id)
|
||||
|
||||
field_model: TextField | DatetimeField | TagBoxField
|
||||
field_model: TextField | DatetimeField
|
||||
if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX):
|
||||
field_model = TextField(
|
||||
type_key=field.key,
|
||||
value=value or "",
|
||||
)
|
||||
elif field.type == FieldTypeEnum.TAGS:
|
||||
field_model = TagBoxField(
|
||||
type_key=field.key,
|
||||
)
|
||||
|
||||
if value:
|
||||
assert isinstance(value, list)
|
||||
with Session(self.engine) as session:
|
||||
for tag_id in list(set(value)):
|
||||
tag = session.scalar(select(Tag).where(Tag.id == tag_id))
|
||||
field_model.tags.add(tag)
|
||||
session.flush()
|
||||
|
||||
elif field.type == FieldTypeEnum.DATETIME:
|
||||
field_model = DatetimeField(
|
||||
@@ -875,11 +847,9 @@ class Library:
|
||||
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
for entry_id in entry_ids:
|
||||
field_model.entry_id = entry_id
|
||||
session.add(field_model)
|
||||
session.flush()
|
||||
|
||||
field_model.entry_id = entry_id
|
||||
session.add(field_model)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.exception(e)
|
||||
@@ -891,7 +861,7 @@ class Library:
|
||||
self.update_field_position(
|
||||
field_class=type(field_model),
|
||||
field_type=field.key,
|
||||
entry_ids=entry_ids,
|
||||
entry_ids=entry_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -920,7 +890,7 @@ class Library:
|
||||
def add_tag(
|
||||
self,
|
||||
tag: Tag,
|
||||
subtag_ids: list[int] | set[int] | None = None,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
alias_names: list[str] | set[str] | None = None,
|
||||
alias_ids: list[int] | set[int] | None = None,
|
||||
) -> Tag | None:
|
||||
@@ -929,8 +899,8 @@ class Library:
|
||||
session.add(tag)
|
||||
session.flush()
|
||||
|
||||
if subtag_ids is not None:
|
||||
self.update_subtags(tag, subtag_ids, session)
|
||||
if parent_ids is not None:
|
||||
self.update_parent_tags(tag, parent_ids, session)
|
||||
|
||||
if alias_ids is not None and alias_names is not None:
|
||||
self.update_aliases(tag, alias_ids, alias_names, session)
|
||||
@@ -944,59 +914,44 @@ class Library:
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def add_field_tag(
|
||||
self,
|
||||
entry: Entry,
|
||||
tag: Tag,
|
||||
field_key: str = _FieldID.TAGS.name,
|
||||
create_field: bool = False,
|
||||
) -> bool:
|
||||
assert isinstance(field_key, str), f"field_key is {type(field_key)}"
|
||||
|
||||
with Session(self.engine) as session:
|
||||
# find field matching entry and field_type
|
||||
field = session.scalars(
|
||||
select(TagBoxField).where(
|
||||
and_(
|
||||
TagBoxField.entry_id == entry.id,
|
||||
TagBoxField.type_key == field_key,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not field and not create_field:
|
||||
logger.error("no field found", entry=entry, field_key=field_key)
|
||||
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
|
||||
"""Add one or more tags to an entry."""
|
||||
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
# TODO: Optimize this by using a single query to update.
|
||||
for tag_id in tag_ids_:
|
||||
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
|
||||
session.flush()
|
||||
session.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
logger.warning("[add_tags_to_entry]", warning=e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
|
||||
"""Remove one or more tags from an entry."""
|
||||
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
if not field:
|
||||
field = TagBoxField(
|
||||
type_key=field_key,
|
||||
entry_id=entry.id,
|
||||
position=0,
|
||||
)
|
||||
session.add(field)
|
||||
session.flush()
|
||||
|
||||
# create record for `TagField` table
|
||||
if not tag.id:
|
||||
session.add(tag)
|
||||
session.flush()
|
||||
|
||||
tag_field = TagField(
|
||||
tag_id=tag.id,
|
||||
field_id=field.id,
|
||||
)
|
||||
|
||||
session.add(tag_field)
|
||||
for tag_id in tag_ids_:
|
||||
tag_entry = session.scalars(
|
||||
select(TagEntry).where(
|
||||
and_(
|
||||
TagEntry.tag_id == tag_id,
|
||||
TagEntry.entry_id == entry_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if tag_entry:
|
||||
session.delete(tag_entry)
|
||||
session.commit()
|
||||
session.commit()
|
||||
logger.info("tag added to field", tag=tag, field=field, entry_id=entry.id)
|
||||
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
logger.exception(e)
|
||||
session.rollback()
|
||||
|
||||
return False
|
||||
|
||||
def save_library_backup_to_disk(self) -> Path:
|
||||
@@ -1016,12 +971,14 @@ class Library:
|
||||
|
||||
def get_tag(self, tag_id: int) -> Tag:
|
||||
with Session(self.engine) as session:
|
||||
tags_query = select(Tag).options(selectinload(Tag.subtags), selectinload(Tag.aliases))
|
||||
tags_query = select(Tag).options(
|
||||
selectinload(Tag.parent_tags), selectinload(Tag.aliases)
|
||||
)
|
||||
tag = session.scalar(tags_query.where(Tag.id == tag_id))
|
||||
|
||||
session.expunge(tag)
|
||||
for subtag in tag.subtags:
|
||||
session.expunge(subtag)
|
||||
for parent in tag.parent_tags:
|
||||
session.expunge(parent)
|
||||
|
||||
for alias in tag.aliases:
|
||||
session.expunge(alias)
|
||||
@@ -1044,19 +1001,19 @@ class Library:
|
||||
|
||||
return alias
|
||||
|
||||
def add_subtag(self, parent_id: int, child_id: int) -> bool:
|
||||
def add_parent_tag(self, parent_id: int, child_id: int) -> bool:
|
||||
if parent_id == child_id:
|
||||
return False
|
||||
|
||||
# open session and save as parent tag
|
||||
with Session(self.engine) as session:
|
||||
subtag = TagSubtag(
|
||||
parent_tag = TagParent(
|
||||
parent_id=parent_id,
|
||||
child_id=child_id,
|
||||
)
|
||||
|
||||
try:
|
||||
session.add(subtag)
|
||||
session.add(parent_tag)
|
||||
session.commit()
|
||||
return True
|
||||
except IntegrityError:
|
||||
@@ -1083,11 +1040,11 @@ class Library:
|
||||
logger.exception("IntegrityError")
|
||||
return False
|
||||
|
||||
def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool:
|
||||
def remove_parent_tag(self, base_id: int, remove_tag_id: int) -> bool:
|
||||
with Session(self.engine) as session:
|
||||
p_id = base_id
|
||||
r_id = remove_tag_id
|
||||
remove = session.query(TagSubtag).filter_by(parent_id=p_id, child_id=r_id).one()
|
||||
remove = session.query(TagParent).filter_by(parent_id=p_id, child_id=r_id).one()
|
||||
session.delete(remove)
|
||||
session.commit()
|
||||
|
||||
@@ -1096,12 +1053,12 @@ class Library:
|
||||
def update_tag(
|
||||
self,
|
||||
tag: Tag,
|
||||
subtag_ids: list[int] | set[int] | None = None,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
alias_names: list[str] | set[str] | None = None,
|
||||
alias_ids: list[int] | set[int] | None = None,
|
||||
) -> None:
|
||||
"""Edit a Tag in the Library."""
|
||||
self.add_tag(tag, subtag_ids, alias_names, alias_ids)
|
||||
self.add_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
|
||||
def update_aliases(self, tag, alias_ids, alias_names, session):
|
||||
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
|
||||
@@ -1117,35 +1074,37 @@ class Library:
|
||||
alias = TagAlias(alias_name, tag.id)
|
||||
session.add(alias)
|
||||
|
||||
def update_subtags(self, tag, subtag_ids, session):
|
||||
if tag.id in subtag_ids:
|
||||
subtag_ids.remove(tag.id)
|
||||
def update_parent_tags(self, tag, parent_ids, session):
|
||||
if tag.id in parent_ids:
|
||||
parent_ids.remove(tag.id)
|
||||
|
||||
# load all tag's subtag to know which to remove
|
||||
prev_subtags = session.scalars(select(TagSubtag).where(TagSubtag.parent_id == tag.id)).all()
|
||||
# load all tag's parent tags to know which to remove
|
||||
prev_parent_tags = session.scalars(
|
||||
select(TagParent).where(TagParent.parent_id == tag.id)
|
||||
).all()
|
||||
|
||||
for subtag in prev_subtags:
|
||||
if subtag.child_id not in subtag_ids:
|
||||
session.delete(subtag)
|
||||
for parent_tag in prev_parent_tags:
|
||||
if parent_tag.child_id not in parent_ids:
|
||||
session.delete(parent_tag)
|
||||
else:
|
||||
# no change, remove from list
|
||||
subtag_ids.remove(subtag.child_id)
|
||||
parent_ids.remove(parent_tag.child_id)
|
||||
|
||||
# create remaining items
|
||||
for subtag_id in subtag_ids:
|
||||
# add new subtag
|
||||
subtag = TagSubtag(
|
||||
for parent_id in parent_ids:
|
||||
# add new parent tag
|
||||
parent_tag = TagParent(
|
||||
parent_id=tag.id,
|
||||
child_id=subtag_id,
|
||||
child_id=parent_id,
|
||||
)
|
||||
session.add(subtag)
|
||||
session.add(parent_tag)
|
||||
|
||||
def prefs(self, key: LibraryPrefs) -> Any:
|
||||
def prefs(self, key: LibraryPrefs):
|
||||
# load given item from Preferences table
|
||||
with Session(self.engine) as session:
|
||||
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
|
||||
|
||||
def set_prefs(self, key: LibraryPrefs, value: Any) -> None:
|
||||
def set_prefs(self, key: LibraryPrefs, value) -> None:
|
||||
# set given item in Preferences table
|
||||
with Session(self.engine) as session:
|
||||
# load existing preference and update value
|
||||
@@ -1168,8 +1127,8 @@ class Library:
|
||||
for entry in entries:
|
||||
for field_key, field in fields.items():
|
||||
if field_key not in existing_fields:
|
||||
self.add_entry_field_type(
|
||||
entry_ids=entry.id,
|
||||
self.add_field_to_entry(
|
||||
entry_id=entry.id,
|
||||
field_id=field.type_key,
|
||||
value=field.value,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, Integer, event
|
||||
@@ -11,20 +15,16 @@ from .fields import (
|
||||
BooleanField,
|
||||
DatetimeField,
|
||||
FieldTypeEnum,
|
||||
TagBoxField,
|
||||
TextField,
|
||||
_FieldID,
|
||||
)
|
||||
from .joins import TagSubtag
|
||||
from .joins import TagParent
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -46,27 +46,21 @@ class Tag(Base):
|
||||
name: Mapped[str]
|
||||
shorthand: Mapped[str | None]
|
||||
color: Mapped[TagColor]
|
||||
is_category: Mapped[bool]
|
||||
icon: Mapped[str | None]
|
||||
|
||||
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
|
||||
|
||||
parent_tags: Mapped[set["Tag"]] = relationship(
|
||||
secondary=TagSubtag.__tablename__,
|
||||
primaryjoin="Tag.id == TagSubtag.child_id",
|
||||
secondaryjoin="Tag.id == TagSubtag.parent_id",
|
||||
back_populates="subtags",
|
||||
)
|
||||
|
||||
subtags: Mapped[set["Tag"]] = relationship(
|
||||
secondary=TagSubtag.__tablename__,
|
||||
primaryjoin="Tag.id == TagSubtag.parent_id",
|
||||
secondaryjoin="Tag.id == TagSubtag.child_id",
|
||||
secondary=TagParent.__tablename__,
|
||||
primaryjoin="Tag.id == TagParent.parent_id",
|
||||
secondaryjoin="Tag.id == TagParent.child_id",
|
||||
back_populates="parent_tags",
|
||||
)
|
||||
|
||||
@property
|
||||
def subtag_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.subtags]
|
||||
def parent_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.parent_tags]
|
||||
|
||||
@property
|
||||
def alias_strings(self) -> list[str]:
|
||||
@@ -83,17 +77,17 @@ class Tag(Base):
|
||||
shorthand: str | None = None,
|
||||
aliases: set[TagAlias] | None = None,
|
||||
parent_tags: set["Tag"] | None = None,
|
||||
subtags: set["Tag"] | None = None,
|
||||
icon: str | None = None,
|
||||
color: TagColor = TagColor.DEFAULT,
|
||||
is_category: bool = False,
|
||||
):
|
||||
self.name = name
|
||||
self.aliases = aliases or set()
|
||||
self.parent_tags = parent_tags or set()
|
||||
self.subtags = subtags or set()
|
||||
self.color = color
|
||||
self.icon = icon
|
||||
self.shorthand = shorthand
|
||||
self.is_category = is_category
|
||||
assert not self.id
|
||||
self.id = id
|
||||
super().__init__()
|
||||
@@ -104,6 +98,18 @@ class Tag(Base):
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
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"
|
||||
@@ -125,6 +131,8 @@ class Entry(Base):
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
|
||||
|
||||
text_fields: Mapped[list[TextField]] = relationship(
|
||||
back_populates="entry",
|
||||
cascade="all, delete",
|
||||
@@ -133,44 +141,22 @@ class Entry(Base):
|
||||
back_populates="entry",
|
||||
cascade="all, delete",
|
||||
)
|
||||
tag_box_fields: Mapped[list[TagBoxField]] = relationship(
|
||||
back_populates="entry",
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[BaseField]:
|
||||
fields: list[BaseField] = []
|
||||
fields.extend(self.tag_box_fields)
|
||||
fields.extend(self.text_fields)
|
||||
fields.extend(self.datetime_fields)
|
||||
fields = sorted(fields, key=lambda field: field.type.position)
|
||||
return fields
|
||||
|
||||
@property
|
||||
def tags(self) -> set[Tag]:
|
||||
tag_set: set[Tag] = set()
|
||||
for tag_box_field in self.tag_box_fields:
|
||||
tag_set.update(tag_box_field.tags)
|
||||
return tag_set
|
||||
|
||||
@property
|
||||
def is_favorited(self) -> bool:
|
||||
for tag_box_field in self.tag_box_fields:
|
||||
if tag_box_field.type_key == _FieldID.TAGS_META.name:
|
||||
for tag in tag_box_field.tags:
|
||||
if tag.id == TAG_FAVORITE:
|
||||
return True
|
||||
return False
|
||||
def is_favorite(self) -> bool:
|
||||
return any(tag.id == TAG_FAVORITE for tag in self.tags)
|
||||
|
||||
@property
|
||||
def is_archived(self) -> bool:
|
||||
for tag_box_field in self.tag_box_fields:
|
||||
if tag_box_field.type_key == _FieldID.TAGS_META.name:
|
||||
for tag in tag_box_field.tags:
|
||||
if tag.id == TAG_ARCHIVED:
|
||||
return True
|
||||
return False
|
||||
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -189,27 +175,15 @@ class Entry(Base):
|
||||
self.text_fields.append(field)
|
||||
elif isinstance(field, DatetimeField):
|
||||
self.datetime_fields.append(field)
|
||||
elif isinstance(field, TagBoxField):
|
||||
self.tag_box_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, field: TagBoxField | None = None) -> None:
|
||||
"""Removes a Tag from the Entry.
|
||||
|
||||
If given a field index, the given Tag will
|
||||
only be removed from that index. If left blank, all instances of that
|
||||
Tag will be removed from the Entry.
|
||||
"""
|
||||
if field:
|
||||
field.tags.remove(tag)
|
||||
return
|
||||
|
||||
for tag_box_field in self.tag_box_fields:
|
||||
tag_box_field.tags.remove(tag)
|
||||
def remove_tag(self, tag: Tag) -> None:
|
||||
"""Removes a Tag from the Entry."""
|
||||
self.tags.remove(tag)
|
||||
|
||||
|
||||
class ValueType(Base):
|
||||
@@ -237,7 +211,6 @@ class ValueType(Base):
|
||||
datetime_fields: Mapped[list[DatetimeField]] = relationship(
|
||||
"DatetimeField", back_populates="type"
|
||||
)
|
||||
tag_box_fields: Mapped[list[TagBoxField]] = relationship("TagBoxField", back_populates="type")
|
||||
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
|
||||
|
||||
@property
|
||||
@@ -245,7 +218,6 @@ class ValueType(Base):
|
||||
FieldClass = { # noqa: N806
|
||||
FieldTypeEnum.TEXT_LINE: TextField,
|
||||
FieldTypeEnum.TEXT_BOX: TextField,
|
||||
FieldTypeEnum.TAGS: TagBoxField,
|
||||
FieldTypeEnum.DATETIME: DatetimeField,
|
||||
FieldTypeEnum.BOOLEAN: BooleanField,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
@@ -8,8 +12,8 @@ from src.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
|
||||
from src.core.query_lang import BaseVisitor
|
||||
from src.core.query_lang.ast import AST, ANDList, Constraint, ConstraintType, Not, ORList, Property
|
||||
|
||||
from .joins import TagField
|
||||
from .models import Entry, Tag, TagAlias, TagBoxField
|
||||
from .joins import TagEntry
|
||||
from .models import Entry, Tag, TagAlias
|
||||
|
||||
# workaround to have autocompletion in the Editor
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,16 +23,17 @@ else:
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# TODO: Reevaluate after subtags -> parent tags name change
|
||||
CHILDREN_QUERY = text("""
|
||||
-- Note for this entire query that tag_subtags.child_id is the parent id and tag_subtags.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE Subtags AS (
|
||||
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS child_id
|
||||
UNION ALL
|
||||
SELECT ts.parent_id AS child_id
|
||||
FROM tag_subtags ts
|
||||
INNER JOIN Subtags s ON ts.child_id = s.child_id
|
||||
SELECT tp.parent_id AS child_id
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.child_id = c.child_id
|
||||
)
|
||||
SELECT * FROM Subtags;
|
||||
SELECT * FROM ChildTags;
|
||||
""") # noqa: E501
|
||||
|
||||
|
||||
@@ -51,12 +56,18 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
|
||||
tag_ids: list[int] = []
|
||||
bool_expressions: list[ColumnExpressionArgument] = []
|
||||
|
||||
# Search for TagID / unambigous Tag Constraints and store the respective tag ids seperately
|
||||
# Search for TagID / unambiguous Tag Constraints and store the respective tag ids separately
|
||||
for term in node.terms:
|
||||
if isinstance(term, Constraint) and len(term.properties) == 0:
|
||||
match term.type:
|
||||
case ConstraintType.TagID:
|
||||
tag_ids.append(int(term.value))
|
||||
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:
|
||||
if len(ids := self.__get_tag_ids(term.value)) == 1:
|
||||
@@ -72,19 +83,20 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
|
||||
# If there is just one tag id, check the normal way
|
||||
elif len(tag_ids) == 1:
|
||||
bool_expressions.append(
|
||||
self.__entry_satisfies_expression(TagField.tag_id == tag_ids[0])
|
||||
self.__entry_satisfies_expression(TagEntry.tag_id == tag_ids[0])
|
||||
)
|
||||
|
||||
return and_(*bool_expressions)
|
||||
|
||||
def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument:
|
||||
"""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 TagBoxField.tags.any(Tag.id.in_(self.__get_tag_ids(node.value)))
|
||||
return Entry.tags.any(Tag.id.in_(self.__get_tag_ids(node.value)))
|
||||
elif node.type == ConstraintType.TagID:
|
||||
return TagBoxField.tags.any(Tag.id == int(node.value))
|
||||
return Entry.tags.any(Tag.id == int(node.value))
|
||||
elif node.type == ConstraintType.Path:
|
||||
return Entry.path.op("GLOB")(node.value)
|
||||
elif node.type == ConstraintType.MediaType:
|
||||
@@ -100,9 +112,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
|
||||
)
|
||||
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(Entry.tag_box_fields).join(TagBoxField.tags)
|
||||
)
|
||||
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")
|
||||
@@ -141,11 +151,10 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
|
||||
# Relational Division Query
|
||||
return Entry.id.in_(
|
||||
select(Entry.id)
|
||||
.outerjoin(TagBoxField)
|
||||
.outerjoin(TagField)
|
||||
.where(TagField.tag_id.in_(tag_ids))
|
||||
.outerjoin(TagEntry)
|
||||
.where(TagEntry.tag_id.in_(tag_ids))
|
||||
.group_by(Entry.id)
|
||||
.having(func.count(distinct(TagField.tag_id)) == len(tag_ids))
|
||||
.having(func.count(distinct(TagEntry.tag_id)) == len(tag_ids))
|
||||
)
|
||||
|
||||
def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]:
|
||||
@@ -155,7 +164,8 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
|
||||
def __entry_satisfies_expression(
|
||||
self, expr: ColumnExpressionArgument
|
||||
) -> BinaryExpression[bool]:
|
||||
"""Returns Binary Expression that is true if the Entry satisfies the column expression."""
|
||||
return Entry.id.in_(
|
||||
select(Entry.id).outerjoin(Entry.tag_box_fields).outerjoin(TagField).where(expr)
|
||||
)
|
||||
"""Returns Binary Expression that is true if the Entry satisfies the column expression.
|
||||
|
||||
Executed on: Entry ⟕ TagEntry (Entry LEFT OUTER JOIN TagEntry).
|
||||
"""
|
||||
return Entry.id.in_(select(Entry.id).outerjoin(TagEntry).where(expr))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import traceback
|
||||
@@ -25,7 +25,8 @@ class UiColor(IntEnum):
|
||||
THEME_LIGHT = 2
|
||||
RED = 3
|
||||
GREEN = 4
|
||||
PURPLE = 5
|
||||
BLUE = 5
|
||||
PURPLE = 6
|
||||
|
||||
|
||||
TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
|
||||
@@ -309,6 +310,12 @@ UI_COLORS: dict[UiColor, dict[ColorType, Any]] = {
|
||||
ColorType.LIGHT_ACCENT: "#DDFFCC",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
UiColor.BLUE: {
|
||||
ColorType.PRIMARY: "#3b87f0",
|
||||
ColorType.BORDER: "#4e95f2",
|
||||
ColorType.LIGHT_ACCENT: "#aedbfa",
|
||||
ColorType.DARK_ACCENT: "#122948",
|
||||
},
|
||||
UiColor.PURPLE: {
|
||||
ColorType.PRIMARY: "#C76FF3",
|
||||
ColorType.BORDER: "#c364f2",
|
||||
|
||||
@@ -126,7 +126,7 @@ class TagStudioCore:
|
||||
is_new = field["id"] not in entry_field_types
|
||||
field_key = field["id"]
|
||||
if is_new:
|
||||
lib.add_entry_field_type(entry.id, field_key, field["value"])
|
||||
lib.add_field_to_entry(entry.id, field_key, field["value"])
|
||||
else:
|
||||
lib.update_entry_field(entry.id, field_key, field["value"])
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
# Form generated from reading UI file 'home.ui'
|
||||
##
|
||||
# Created by: Qt User Interface Compiler version 6.5.1
|
||||
##
|
||||
# WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -180,6 +170,7 @@ class Ui_MainWindow(QMainWindow):
|
||||
sizePolicy1.setHeightForWidth(
|
||||
self.statusbar.sizePolicy().hasHeightForWidth())
|
||||
self.statusbar.setSizePolicy(sizePolicy1)
|
||||
self.statusbar.setSizeGripEnabled(False)
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
|
||||
@@ -25,7 +25,6 @@ class AddFieldModal(QWidget):
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.is_connected = False
|
||||
self.lib = library
|
||||
Translations.translate_with_setter(self.setWindowTitle, "library.field.add")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -10,8 +10,10 @@ import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
@@ -56,6 +58,7 @@ class BuildTagPanel(PanelWidget):
|
||||
def __init__(self, library: Library, tag: Tag | None = None):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.tag: Tag # NOTE: This gets set at the end of the init.
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -115,23 +118,22 @@ class BuildTagPanel(PanelWidget):
|
||||
|
||||
self.alias_add_button.clicked.connect(self.add_alias_callback)
|
||||
|
||||
# Subtags ------------------------------------------------------------
|
||||
# Parent Tags ----------------------------------------------------------
|
||||
self.parent_tags_widget = QWidget()
|
||||
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
|
||||
self.parent_tags_layout.setStretch(1, 1)
|
||||
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.parent_tags_layout.setSpacing(0)
|
||||
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.subtags_widget = QWidget()
|
||||
self.subtags_layout = QVBoxLayout(self.subtags_widget)
|
||||
self.subtags_layout.setStretch(1, 1)
|
||||
self.subtags_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.subtags_layout.setSpacing(0)
|
||||
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.subtags_title = QLabel()
|
||||
Translations.translate_qobject(self.subtags_title, "tag.parent_tags")
|
||||
self.subtags_layout.addWidget(self.subtags_title)
|
||||
self.parent_tags_title = QLabel()
|
||||
Translations.translate_qobject(self.parent_tags_title, "tag.parent_tags")
|
||||
self.parent_tags_layout.addWidget(self.parent_tags_title)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.subtags_scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.subtags_scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.subtags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.parent_tags_scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.parent_tags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
@@ -141,29 +143,29 @@ class BuildTagPanel(PanelWidget):
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
# self.scroll_area.setMinimumHeight(60)
|
||||
|
||||
self.subtags_layout.addWidget(self.scroll_area)
|
||||
self.parent_tags_layout.addWidget(self.scroll_area)
|
||||
|
||||
self.subtags_add_button = QPushButton()
|
||||
self.subtags_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.subtags_add_button.setText("+")
|
||||
self.subtags_layout.addWidget(self.subtags_add_button)
|
||||
self.parent_tags_add_button = QPushButton()
|
||||
self.parent_tags_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.parent_tags_add_button.setText("+")
|
||||
self.parent_tags_layout.addWidget(self.parent_tags_add_button)
|
||||
|
||||
exclude_ids: list[int] = list()
|
||||
if tag is not None:
|
||||
exclude_ids.append(tag.id)
|
||||
|
||||
tsp = TagSearchPanel(self.lib, exclude_ids)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
|
||||
self.add_tag_modal = PanelModal(tsp)
|
||||
Translations.translate_with_setter(self.add_tag_modal.setTitle, "tag.parent_tags.add")
|
||||
Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.parent_tags.add")
|
||||
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
# Shorthand ------------------------------------------------------------
|
||||
# Color ----------------------------------------------------------------
|
||||
self.color_widget = QWidget()
|
||||
self.color_layout = QVBoxLayout(self.color_widget)
|
||||
self.color_layout.setStretch(1, 1)
|
||||
self.color_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.color_layout.setContentsMargins(0, 0, 0, 24)
|
||||
self.color_layout.setSpacing(0)
|
||||
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.color_title = QLabel()
|
||||
@@ -190,16 +192,47 @@ class BuildTagPanel(PanelWidget):
|
||||
)
|
||||
self.color_layout.addWidget(self.color_field)
|
||||
|
||||
# Category -------------------------------------------------------------
|
||||
self.cat_widget = QWidget()
|
||||
self.cat_layout = QHBoxLayout(self.cat_widget)
|
||||
self.cat_layout.setStretch(1, 1)
|
||||
self.cat_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.cat_layout.setSpacing(0)
|
||||
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.cat_title = QLabel()
|
||||
self.cat_title.setText("Is Category")
|
||||
self.cat_checkbox = QCheckBox()
|
||||
# TODO: Style checkbox
|
||||
self.cat_checkbox.setStyleSheet(
|
||||
"QCheckBox::indicator{"
|
||||
"width: 19px; height: 19px;"
|
||||
# f"background: #1e1e1e;"
|
||||
# f"border-color: #333333;"
|
||||
# f"border-radius: 6px;"
|
||||
# f"border-style:solid;"
|
||||
# f"border-width:{math.ceil(self.devicePixelRatio())}px;"
|
||||
"}"
|
||||
# f"QCheckBox::indicator::hover"
|
||||
# f"{{"
|
||||
# f"border-color: #CCCCCC;"
|
||||
# f"background: #555555;"
|
||||
# f"}}"
|
||||
)
|
||||
self.cat_layout.addWidget(self.cat_checkbox)
|
||||
self.cat_layout.addWidget(self.cat_title)
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.name_widget)
|
||||
self.root_layout.addWidget(self.shorthand_widget)
|
||||
self.root_layout.addWidget(self.aliases_widget)
|
||||
self.root_layout.addWidget(self.aliases_table)
|
||||
self.root_layout.addWidget(self.alias_add_button)
|
||||
self.root_layout.addWidget(self.subtags_widget)
|
||||
self.root_layout.addWidget(self.parent_tags_widget)
|
||||
self.root_layout.addWidget(self.color_widget)
|
||||
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
|
||||
self.root_layout.addWidget(self.cat_widget)
|
||||
|
||||
self.subtag_ids: set[int] = set()
|
||||
self.parent_ids: set[int] = set()
|
||||
self.alias_ids: list[int] = []
|
||||
self.alias_names: list[str] = []
|
||||
self.new_alias_names: dict = {}
|
||||
@@ -239,26 +272,23 @@ class BuildTagPanel(PanelWidget):
|
||||
if isinstance(focused_widget, CustomTableItem):
|
||||
self.add_alias_callback()
|
||||
|
||||
def add_subtag_callback(self, tag_id: int):
|
||||
logger.info("add_subtag_callback", tag_id=tag_id)
|
||||
self.subtag_ids.add(tag_id)
|
||||
self.set_subtags()
|
||||
def add_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("add_parent_tag_callback", tag_id=tag_id)
|
||||
self.parent_ids.add(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def remove_subtag_callback(self, tag_id: int):
|
||||
logger.info("removing subtag", tag_id=tag_id)
|
||||
self.subtag_ids.remove(tag_id)
|
||||
self.set_subtags()
|
||||
def remove_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("remove_parent_tag_callback", tag_id=tag_id)
|
||||
self.parent_ids.remove(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def add_alias_callback(self):
|
||||
logger.info("add_alias_callback")
|
||||
|
||||
id = self.new_item_id
|
||||
|
||||
self.alias_ids.append(id)
|
||||
self.new_alias_names[id] = ""
|
||||
|
||||
self.new_item_id -= 1
|
||||
|
||||
self._set_aliases()
|
||||
|
||||
row = self.aliases_table.rowCount() - 1
|
||||
@@ -271,20 +301,20 @@ class BuildTagPanel(PanelWidget):
|
||||
self.alias_ids.remove(alias_id)
|
||||
self._set_aliases()
|
||||
|
||||
def set_subtags(self):
|
||||
while self.subtags_scroll_layout.itemAt(0):
|
||||
self.subtags_scroll_layout.takeAt(0).widget().deleteLater()
|
||||
def set_parent_tags(self):
|
||||
while self.parent_tags_scroll_layout.itemAt(0):
|
||||
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
c = QWidget()
|
||||
layout = QVBoxLayout(c)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(3)
|
||||
for tag_id in self.subtag_ids:
|
||||
for tag_id in self.parent_ids:
|
||||
tag = self.lib.get_tag(tag_id)
|
||||
tw = TagWidget(tag, has_edit=False, has_remove=True)
|
||||
tw.on_remove.connect(lambda t=tag_id: self.remove_subtag_callback(t))
|
||||
tw.on_remove.connect(lambda t=tag_id: self.remove_parent_tag_callback(t))
|
||||
layout.addWidget(tw)
|
||||
self.subtags_scroll_layout.addWidget(c)
|
||||
self.parent_tags_scroll_layout.addWidget(c)
|
||||
|
||||
def add_aliases(self):
|
||||
names: set[str] = set()
|
||||
@@ -349,22 +379,18 @@ class BuildTagPanel(PanelWidget):
|
||||
self.new_alias_names[item.id] = item.text()
|
||||
|
||||
def set_tag(self, tag: Tag):
|
||||
logger.info("[BuildTagPanel] Setting Tag", tag=tag)
|
||||
self.tag = tag
|
||||
|
||||
logger.info("setting tag", tag=tag)
|
||||
|
||||
self.name_field.setText(tag.name)
|
||||
self.shorthand_field.setText(tag.shorthand or "")
|
||||
|
||||
for alias_id in tag.alias_ids:
|
||||
self.alias_ids.append(alias_id)
|
||||
|
||||
self._set_aliases()
|
||||
|
||||
for subtag in tag.subtag_ids:
|
||||
self.subtag_ids.add(subtag)
|
||||
|
||||
self.set_subtags()
|
||||
for parent_id in tag.parent_ids:
|
||||
self.parent_ids.add(parent_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
# select item in self.color_field where the userData value matched tag.color
|
||||
for i in range(self.color_field.count()):
|
||||
@@ -372,6 +398,8 @@ class BuildTagPanel(PanelWidget):
|
||||
self.color_field.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self.cat_checkbox.setChecked(tag.is_category)
|
||||
|
||||
def on_name_changed(self):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
|
||||
@@ -386,14 +414,13 @@ class BuildTagPanel(PanelWidget):
|
||||
|
||||
def build_tag(self) -> Tag:
|
||||
color = self.color_field.currentData() or TagColor.DEFAULT
|
||||
|
||||
tag = self.tag
|
||||
|
||||
self.add_aliases()
|
||||
|
||||
tag.name = self.name_field.text()
|
||||
tag.shorthand = self.shorthand_field.text()
|
||||
tag.color = color
|
||||
tag.is_category = self.cat_checkbox.isChecked()
|
||||
|
||||
logger.info("built tag", tag=tag)
|
||||
return tag
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -20,7 +20,6 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.translations import Translations
|
||||
@@ -42,7 +41,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
|
||||
branch = tree
|
||||
for folder in items:
|
||||
if folder not in branch.dirs:
|
||||
# TODO - subtags
|
||||
# TODO: Reimplement parent tags
|
||||
new_tag = Tag(name=folder)
|
||||
library.add_tag(new_tag)
|
||||
branch.dirs[folder] = BranchData(tag=new_tag)
|
||||
@@ -73,7 +72,7 @@ def folders_to_tags(library: Library):
|
||||
|
||||
tag = add_folders_to_tree(library, tree, folders).tag
|
||||
if tag and not entry.has_tag(tag):
|
||||
library.add_field_tag(entry, tag, _FieldID.TAGS.name, create_field=True)
|
||||
library.add_tags_to_entry(entry.id, tag.id)
|
||||
|
||||
logger.info("Done")
|
||||
|
||||
@@ -82,11 +81,11 @@ def reverse_tag(library: Library, tag: Tag, items: list[Tag] | None) -> list[Tag
|
||||
items = items or []
|
||||
items.append(tag)
|
||||
|
||||
if not tag.subtag_ids:
|
||||
if not tag.parent_ids:
|
||||
items.reverse()
|
||||
return items
|
||||
|
||||
for subtag_id in tag.subtag_ids:
|
||||
for subtag_id in tag.parent_ids:
|
||||
subtag = library.get_tag(subtag_id)
|
||||
return reverse_tag(library, subtag, items)
|
||||
|
||||
@@ -127,11 +126,10 @@ def generate_preview_data(library: Library) -> BranchData:
|
||||
branch = _add_folders_to_tree(folders)
|
||||
if branch:
|
||||
has_tag = False
|
||||
for tag_field in entry.tag_box_fields:
|
||||
for tag in tag_field.tags:
|
||||
if tag.name == branch.tag.name:
|
||||
has_tag = True
|
||||
break
|
||||
for tag in entry.tags:
|
||||
if tag.name == branch.tag.name:
|
||||
has_tag = True
|
||||
break
|
||||
if not has_tag:
|
||||
branch.files.append(entry.path.name)
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ class TagDatabasePanel(PanelWidget):
|
||||
lambda: (
|
||||
self.lib.add_tag(
|
||||
tag=panel.build_tag(),
|
||||
subtag_ids=panel.subtag_ids,
|
||||
parent_ids=panel.parent_ids,
|
||||
alias_names=panel.alias_names,
|
||||
alias_ids=panel.alias_ids,
|
||||
),
|
||||
@@ -121,7 +121,7 @@ class TagDatabasePanel(PanelWidget):
|
||||
row.setSpacing(3)
|
||||
|
||||
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
|
||||
tag_widget = TagWidget(tag, has_edit=False, has_remove=False)
|
||||
tag_widget = TagWidget(tag, has_edit=True, has_remove=False)
|
||||
else:
|
||||
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)
|
||||
|
||||
@@ -151,9 +151,6 @@ class TagDatabasePanel(PanelWidget):
|
||||
self.update_tags()
|
||||
|
||||
def edit_tag(self, tag: Tag):
|
||||
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
|
||||
return
|
||||
|
||||
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
|
||||
|
||||
self.edit_modal = PanelModal(
|
||||
@@ -169,7 +166,7 @@ class TagDatabasePanel(PanelWidget):
|
||||
|
||||
def edit_tag_callback(self, btp: BuildTagPanel):
|
||||
self.lib.update_tag(
|
||||
btp.build_tag(), set(btp.subtag_ids), set(btp.alias_names), set(btp.alias_ids)
|
||||
btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids)
|
||||
)
|
||||
self.update_tags(self.search_field.text())
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -15,8 +15,6 @@ import re
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Sequence
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
|
||||
@@ -93,6 +91,12 @@ from src.qt.widgets.preview_panel import PreviewPanel
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
|
||||
BADGE_TAGS = {
|
||||
BadgeType.FAVORITE: TAG_FAVORITE,
|
||||
BadgeType.ARCHIVED: TAG_ARCHIVED,
|
||||
}
|
||||
|
||||
|
||||
# SIGQUIT is not defined on Windows
|
||||
if sys.platform == "win32":
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
@@ -137,8 +141,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.lib = backend.Library()
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
self.args = args
|
||||
self.frame_content = []
|
||||
self.filter = FilterState.show_all()
|
||||
self.frame_content: list[int] = [] # List of Entry IDs on the current page
|
||||
self.pages_count = 0
|
||||
|
||||
self.scrollbar_pos = 0
|
||||
@@ -152,9 +156,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.thumb_job_queue: Queue = Queue()
|
||||
self.thumb_threads: list[Consumer] = []
|
||||
self.thumb_cutoff: float = time.time()
|
||||
|
||||
# grid indexes of selected items
|
||||
self.selected: list[int] = []
|
||||
self.selected: list[int] = [] # Selected Entry IDs
|
||||
|
||||
self.SIGTERM.connect(self.handle_sigterm)
|
||||
|
||||
@@ -295,6 +297,26 @@ class QtDriver(DriverMixin, QObject):
|
||||
open_library_action.setToolTip("Ctrl+O")
|
||||
file_menu.addAction(open_library_action)
|
||||
|
||||
self.open_recent_library_menu = QMenu(menu_bar)
|
||||
Translations.translate_qobject(
|
||||
self.open_recent_library_menu, "menu.file.open_recent_library"
|
||||
)
|
||||
file_menu.addMenu(self.open_recent_library_menu)
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
open_on_start_action = QAction(self)
|
||||
Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
|
||||
open_on_start_action.setCheckable(True)
|
||||
open_on_start_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
|
||||
)
|
||||
open_on_start_action.triggered.connect(
|
||||
lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
|
||||
)
|
||||
file_menu.addAction(open_on_start_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
save_library_backup_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(save_library_backup_action, "menu.file.save_backup")
|
||||
save_library_backup_action.triggered.connect(
|
||||
@@ -335,17 +357,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
file_menu.addAction(close_library_action)
|
||||
file_menu.addSeparator()
|
||||
|
||||
open_on_start_action = QAction(self)
|
||||
Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
|
||||
open_on_start_action.setCheckable(True)
|
||||
open_on_start_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
|
||||
)
|
||||
open_on_start_action.triggered.connect(
|
||||
lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
|
||||
)
|
||||
file_menu.addAction(open_on_start_action)
|
||||
|
||||
# Edit Menu ============================================================
|
||||
new_tag_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(new_tag_action, "menu.edit.new_tag")
|
||||
@@ -401,13 +412,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
show_libs_list_action.setChecked(
|
||||
bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool))
|
||||
)
|
||||
show_libs_list_action.triggered.connect(
|
||||
lambda checked: (
|
||||
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked),
|
||||
self.toggle_libs_list(checked),
|
||||
)
|
||||
)
|
||||
view_menu.addAction(show_libs_list_action)
|
||||
|
||||
show_filenames_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(show_filenames_action, "settings.show_filenames_in_grid")
|
||||
@@ -489,6 +493,17 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.searchField.textChanged.connect(self.update_completions_list)
|
||||
|
||||
self.preview_panel = PreviewPanel(self.lib, self)
|
||||
self.preview_panel.fields.archived_updated.connect(
|
||||
lambda hidden: self.update_badges(
|
||||
{BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False
|
||||
)
|
||||
)
|
||||
self.preview_panel.fields.favorite_updated.connect(
|
||||
lambda hidden: self.update_badges(
|
||||
{BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False
|
||||
)
|
||||
)
|
||||
|
||||
splitter = self.main_window.splitter
|
||||
splitter.addWidget(self.preview_panel)
|
||||
|
||||
@@ -613,13 +628,6 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.splash.finish(self.main_window)
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def toggle_libs_list(self, value: bool):
|
||||
if value:
|
||||
self.preview_panel.libs_flow_container.show()
|
||||
else:
|
||||
self.preview_panel.libs_flow_container.hide()
|
||||
self.preview_panel.update()
|
||||
|
||||
def show_grid_filenames(self, value: bool):
|
||||
for thumb in self.item_thumbs:
|
||||
thumb.set_filename_visibility(value)
|
||||
@@ -710,7 +718,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
lambda: (
|
||||
self.lib.add_tag(
|
||||
panel.build_tag(),
|
||||
set(panel.subtag_ids),
|
||||
set(panel.parent_ids),
|
||||
set(panel.alias_names),
|
||||
set(panel.alias_ids),
|
||||
),
|
||||
@@ -720,10 +728,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.modal.show()
|
||||
|
||||
def select_all_action_callback(self):
|
||||
self.selected = list(range(0, len(self.frame_content)))
|
||||
|
||||
for grid_idx in self.selected:
|
||||
self.item_thumbs[grid_idx].thumb_button.set_selected(True)
|
||||
"""Set the selection to all visible items."""
|
||||
self.selected.clear()
|
||||
for item in self.item_thumbs:
|
||||
if item.mode and item.item_id not in self.selected:
|
||||
self.selected.append(item.item_id)
|
||||
item.thumb_button.set_selected(True)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
@@ -839,29 +849,20 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
def new_file_macros_runnable(self, new_ids):
|
||||
"""Threaded method that runs macros on a set of Entry IDs."""
|
||||
# sleep(1)
|
||||
# for i, id in enumerate(new_ids):
|
||||
# # pb.setValue(i)
|
||||
# # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries')
|
||||
# # self.run_macro('autofill', id)
|
||||
|
||||
# NOTE: I don't know. I don't know why it needs this. The whole program
|
||||
# falls apart if this method doesn't run, and it DOESN'T DO ANYTHING
|
||||
yield 0
|
||||
|
||||
# self.main_window.statusbar.showMessage('', 3)
|
||||
|
||||
# sleep(5)
|
||||
# pb.deleteLater()
|
||||
|
||||
def run_macros(self, name: MacroID, grid_idx: list[int]):
|
||||
def run_macros(self, name: MacroID, entry_ids: list[int]):
|
||||
"""Run a specific Macro on a group of given entry_ids."""
|
||||
for gid in grid_idx:
|
||||
self.run_macro(name, gid)
|
||||
for entry_id in entry_ids:
|
||||
self.run_macro(name, entry_id)
|
||||
|
||||
def run_macro(self, name: MacroID, grid_idx: int):
|
||||
def run_macro(self, name: MacroID, entry_id: int):
|
||||
"""Run a specific Macro on an Entry given a Macro name."""
|
||||
entry: Entry = self.frame_content[grid_idx]
|
||||
entry: Entry = self.lib.get_entry(entry_id)
|
||||
full_path = self.lib.library_dir / entry.path
|
||||
source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower()
|
||||
|
||||
@@ -870,21 +871,21 @@ class QtDriver(DriverMixin, QObject):
|
||||
source=source,
|
||||
macro=name,
|
||||
entry_id=entry.id,
|
||||
grid_idx=grid_idx,
|
||||
grid_idx=entry_id,
|
||||
)
|
||||
|
||||
if name == MacroID.AUTOFILL:
|
||||
for macro_id in MacroID:
|
||||
if macro_id == MacroID.AUTOFILL:
|
||||
continue
|
||||
self.run_macro(macro_id, grid_idx)
|
||||
self.run_macro(macro_id, entry_id)
|
||||
|
||||
elif name == MacroID.SIDECAR:
|
||||
parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source)
|
||||
for field_id, value in parsed_items.items():
|
||||
if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str):
|
||||
value = self.lib.tag_from_strings(value)
|
||||
self.lib.add_entry_field_type(
|
||||
self.lib.add_field_to_entry(
|
||||
entry.id,
|
||||
field_id=field_id,
|
||||
value=value,
|
||||
@@ -893,7 +894,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
elif name == MacroID.BUILD_URL:
|
||||
url = TagStudioCore.build_url(entry, source)
|
||||
if url is not None:
|
||||
self.lib.add_entry_field_type(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:
|
||||
@@ -979,6 +980,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
page_index = max(0, min(page_index, self.pages_count - 1))
|
||||
|
||||
self.filter.page_index = page_index
|
||||
# TODO: Re-allow selecting entries across multiple pages at once.
|
||||
# This works fine with additive selection but becomes a nightmare with bridging.
|
||||
self.filter_items()
|
||||
|
||||
def remove_grid_item(self, grid_idx: int):
|
||||
@@ -992,13 +995,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# TODO - init after library is loaded, it can have different page_size
|
||||
for grid_idx in range(self.filter.page_size):
|
||||
for _ in range(self.filter.page_size):
|
||||
item_thumb = ItemThumb(
|
||||
None,
|
||||
self.lib,
|
||||
self,
|
||||
(self.thumb_size, self.thumb_size),
|
||||
grid_idx,
|
||||
bool(
|
||||
self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)
|
||||
),
|
||||
@@ -1015,44 +1017,74 @@ class QtDriver(DriverMixin, QObject):
|
||||
sa.setWidgetResizable(True)
|
||||
sa.setWidget(self.flow_container)
|
||||
|
||||
def select_item(self, grid_index: int, append: bool, bridge: bool):
|
||||
"""Select one or more items in the Thumbnail Grid."""
|
||||
logger.info("selecting item", grid_index=grid_index, append=append, bridge=bridge)
|
||||
def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
|
||||
"""Toggle the selection of an item in the Thumbnail Grid.
|
||||
|
||||
If an item is not selected, this selects it. If an item is already selected, this will
|
||||
deselect it as long as append and bridge are False.
|
||||
|
||||
Args:
|
||||
item_id(int): The ID of the item/entry to select.
|
||||
append(bool): Whether or not to add this item to the previous selection
|
||||
or to restart the selection with this item.
|
||||
Setting to True acts like "Ctrl + Click" selecting.
|
||||
bridge(bool): Whether or not to select items in the visual range of the last item
|
||||
selected and this current item.
|
||||
Setting to True acts like "Shift + Click" selecting.
|
||||
"""
|
||||
logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge)
|
||||
|
||||
if append:
|
||||
if grid_index not in self.selected:
|
||||
self.selected.append(grid_index)
|
||||
self.item_thumbs[grid_index].thumb_button.set_selected(True)
|
||||
if item_id not in self.selected:
|
||||
self.selected.append(item_id)
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id == item_id:
|
||||
it.thumb_button.set_selected(True)
|
||||
else:
|
||||
self.selected.remove(grid_index)
|
||||
self.item_thumbs[grid_index].thumb_button.set_selected(False)
|
||||
self.selected.remove(item_id)
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id == item_id:
|
||||
it.thumb_button.set_selected(False)
|
||||
|
||||
# TODO: Allow bridge selecting across pages.
|
||||
elif bridge and self.selected:
|
||||
select_from = min(self.selected)
|
||||
select_to = max(self.selected)
|
||||
last_index = -1
|
||||
current_index = -1
|
||||
try:
|
||||
contents = self.frame_content
|
||||
last_index = self.frame_content.index(self.selected[-1])
|
||||
current_index = self.frame_content.index(item_id)
|
||||
index_range: list = contents[
|
||||
min(last_index, current_index) : max(last_index, current_index) + 1
|
||||
]
|
||||
|
||||
if select_to < grid_index:
|
||||
index_range = range(select_from, grid_index + 1)
|
||||
else:
|
||||
index_range = range(grid_index, select_to + 1)
|
||||
# Preserve bridge direction for correct appending order.
|
||||
if last_index < current_index:
|
||||
index_range.reverse()
|
||||
for entry_id in index_range:
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id == entry_id:
|
||||
it.thumb_button.set_selected(True)
|
||||
if entry_id not in self.selected:
|
||||
self.selected.append(entry_id)
|
||||
except Exception as e:
|
||||
# TODO: Allow bridge selecting across pages.
|
||||
logger.error(
|
||||
"[QtDriver] Previous selected item not on current page!",
|
||||
error=e,
|
||||
item_id=item_id,
|
||||
current_index=current_index,
|
||||
last_index=last_index,
|
||||
)
|
||||
|
||||
self.selected = list(index_range)
|
||||
|
||||
for selected_idx in self.selected:
|
||||
self.item_thumbs[selected_idx].thumb_button.set_selected(True)
|
||||
else:
|
||||
self.selected = [grid_index]
|
||||
for thumb_idx, item_thumb in enumerate(self.item_thumbs):
|
||||
item_matched = thumb_idx == grid_index
|
||||
item_thumb.thumb_button.set_selected(item_matched)
|
||||
|
||||
# NOTE: By using the preview panel's "set_tags_updated_slot" method,
|
||||
# only the last of multiple identical item selections are connected.
|
||||
# If attaching the slot to multiple duplicate selections is needed,
|
||||
# just bypass the method and manually disconnect and connect the slots.
|
||||
if len(self.selected) == 1:
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id == id:
|
||||
self.preview_panel.set_tags_updated_slot(it.refresh_badge)
|
||||
self.selected.clear()
|
||||
self.selected.append(item_id)
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id in self.selected:
|
||||
it.thumb_button.set_selected(True)
|
||||
else:
|
||||
it.thumb_button.set_selected(False)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
@@ -1149,18 +1181,26 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.update()
|
||||
|
||||
is_grid_thumb = True
|
||||
# Show loading placeholder icons
|
||||
for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs):
|
||||
if not entry:
|
||||
logger.info("[QtDriver] Loading Entries...")
|
||||
# TODO: The full entries with joins don't need to be grabbed here.
|
||||
# Use a method that only selects the frame content but doesn't include the joins.
|
||||
entries: list[Entry] = list(self.lib.get_entries_full(self.frame_content))
|
||||
logger.info("[QtDriver] Building Filenames...")
|
||||
filenames: list[Path] = [self.lib.library_dir / e.path for e in entries]
|
||||
logger.info("[QtDriver] Done! Processing ItemThumbs...")
|
||||
for index, item_thumb in enumerate(self.item_thumbs, start=0):
|
||||
entry = None
|
||||
try:
|
||||
entry = entries[index]
|
||||
except IndexError:
|
||||
item_thumb.hide()
|
||||
continue
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
item_thumb.set_mode(ItemType.ENTRY)
|
||||
item_thumb.set_item_id(entry)
|
||||
|
||||
# TODO - show after item is rendered
|
||||
item_thumb.set_item_id(entry.id)
|
||||
item_thumb.show()
|
||||
|
||||
is_loading = True
|
||||
self.thumb_job_queue.put(
|
||||
(
|
||||
@@ -1170,29 +1210,29 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
|
||||
# Show rendered thumbnails
|
||||
for idx, (entry, item_thumb) in enumerate(
|
||||
zip_longest(self.frame_content, self.item_thumbs)
|
||||
):
|
||||
for index, item_thumb in enumerate(self.item_thumbs, start=0):
|
||||
entry = None
|
||||
try:
|
||||
entry = entries[index]
|
||||
except IndexError:
|
||||
item_thumb.hide()
|
||||
continue
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
filepath = self.lib.library_dir / entry.path
|
||||
is_loading = False
|
||||
|
||||
self.thumb_job_queue.put(
|
||||
(
|
||||
item_thumb.renderer.render,
|
||||
(time.time(), filepath, base_size, ratio, is_loading, is_grid_thumb),
|
||||
(time.time(), filenames[index], base_size, ratio, is_loading, is_grid_thumb),
|
||||
)
|
||||
)
|
||||
|
||||
entry_tag_ids = {tag.id for tag in entry.tags}
|
||||
item_thumb.assign_badge(BadgeType.ARCHIVED, TAG_ARCHIVED in entry_tag_ids)
|
||||
item_thumb.assign_badge(BadgeType.FAVORITE, TAG_FAVORITE in entry_tag_ids)
|
||||
item_thumb.assign_badge(BadgeType.ARCHIVED, entry.is_archived)
|
||||
item_thumb.assign_badge(BadgeType.FAVORITE, entry.is_favorite)
|
||||
item_thumb.update_clickable(
|
||||
clickable=(
|
||||
lambda checked=False, index=idx: self.select_item(
|
||||
index,
|
||||
lambda checked=False, item_id=entry.id: self.toggle_item_selection(
|
||||
item_id,
|
||||
append=(
|
||||
QGuiApplication.keyboardModifiers()
|
||||
== Qt.KeyboardModifier.ControlModifier
|
||||
@@ -1205,27 +1245,28 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
|
||||
# Restore Selected Borders
|
||||
is_selected = (item_thumb.mode, item_thumb.item_id) in self.selected
|
||||
is_selected = item_thumb.item_id in self.selected
|
||||
item_thumb.thumb_button.set_selected(is_selected)
|
||||
|
||||
self.thumb_job_queue.put(
|
||||
(
|
||||
item_thumb.renderer.render,
|
||||
(time.time(), filepath, base_size, ratio, False, True),
|
||||
)
|
||||
)
|
||||
def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True):
|
||||
"""Update the tag badges for item_thumbs.
|
||||
|
||||
def update_badges(self, grid_item_ids: Sequence[int] = None):
|
||||
if not grid_item_ids:
|
||||
# no items passed, update all items in grid
|
||||
grid_item_ids = range(min(len(self.item_thumbs), len(self.frame_content)))
|
||||
Args:
|
||||
badge_values(dict[BadgeType, bool]): The BadgeType and associated viability state.
|
||||
origin_id(int): The ID of the item_thumb calling this method. If the ID is found as a
|
||||
part of the current selection, or if the ID is 0, the the entire current selection
|
||||
will be updated. Otherwise, only item_thumbs with that ID will be updated.
|
||||
add_tags(bool): Flag determining if tags associated with the badges need to be added to
|
||||
the items. Defaults to True.
|
||||
"""
|
||||
item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id]
|
||||
|
||||
logger.info("updating badges for items", grid_item_ids=grid_item_ids)
|
||||
|
||||
for grid_idx in grid_item_ids:
|
||||
# get the entry from grid to avoid loading from db again
|
||||
entry = self.frame_content[grid_idx]
|
||||
self.item_thumbs[grid_idx].refresh_badge(entry)
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id in item_ids:
|
||||
for badge_type, value in badge_values.items():
|
||||
if add_tags:
|
||||
it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type])
|
||||
it.assign_badge(badge_type, value)
|
||||
|
||||
def filter_items(self, filter: FilterState | None = None) -> None:
|
||||
if not self.lib.library_dir:
|
||||
@@ -1244,13 +1285,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
# search the library
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
results = self.lib.search_library(self.filter)
|
||||
|
||||
logger.info("items to render", count=len(results))
|
||||
|
||||
end_time = time.time()
|
||||
|
||||
# inform user about completed search
|
||||
@@ -1263,7 +1300,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
|
||||
# update page content
|
||||
self.frame_content = results.items
|
||||
self.frame_content = [item.id for item in results.items]
|
||||
self.update_thumbs()
|
||||
|
||||
# update pagination
|
||||
@@ -1303,6 +1340,63 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
def update_recent_lib_menu(self):
|
||||
"""Updates the recent library menu from the latest values from the settings file."""
|
||||
actions: list[QAction] = []
|
||||
lib_items: dict[str, tuple[str, str]] = {}
|
||||
|
||||
settings = self.settings
|
||||
settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
for item_tstamp in settings.allKeys():
|
||||
val = str(settings.value(item_tstamp, type=str))
|
||||
cut_val = val
|
||||
if len(val) > 45:
|
||||
cut_val = f"{val[0:10]} ... {val[-10:]}"
|
||||
lib_items[item_tstamp] = (val, cut_val)
|
||||
|
||||
# Sort lib_items by the key
|
||||
libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True)
|
||||
settings.endGroup()
|
||||
|
||||
# Create actions for each library
|
||||
for library_key in libs_sorted:
|
||||
path = Path(library_key[1][0])
|
||||
action = QAction(self.open_recent_library_menu)
|
||||
action.setText(str(path))
|
||||
action.triggered.connect(lambda checked=False, p=path: self.open_library(p))
|
||||
actions.append(action)
|
||||
|
||||
clear_recent_action = QAction(self.open_recent_library_menu)
|
||||
Translations.translate_qobject(clear_recent_action, "menu.file.clear_recent_libraries")
|
||||
clear_recent_action.triggered.connect(self.clear_recent_libs)
|
||||
actions.append(clear_recent_action)
|
||||
|
||||
# Clear previous actions
|
||||
for action in self.open_recent_library_menu.actions():
|
||||
self.open_recent_library_menu.removeAction(action)
|
||||
|
||||
# Add new actions
|
||||
for action in actions:
|
||||
self.open_recent_library_menu.addAction(action)
|
||||
|
||||
# Only enable add "clear recent" if there are still recent libraries.
|
||||
if len(actions) > 1:
|
||||
self.open_recent_library_menu.setDisabled(False)
|
||||
self.open_recent_library_menu.addSeparator()
|
||||
self.open_recent_library_menu.addAction(clear_recent_action)
|
||||
else:
|
||||
self.open_recent_library_menu.setDisabled(True)
|
||||
|
||||
def clear_recent_libs(self):
|
||||
"""Clear the list of recent libraries from the settings file."""
|
||||
settings = self.settings
|
||||
settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.settings.remove("")
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
self.update_recent_lib_menu()
|
||||
|
||||
def open_library(self, path: Path) -> None:
|
||||
"""Open a TagStudio library."""
|
||||
@@ -1315,7 +1409,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
self.main_window.repaint()
|
||||
|
||||
open_status: LibraryStatus = self.lib.open_library(path)
|
||||
open_status: LibraryStatus = None
|
||||
try:
|
||||
open_status = self.lib.open_library(path)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
open_status = LibraryStatus(success=False, library_path=path, message=type(e).__name__)
|
||||
|
||||
# Migration is required
|
||||
if open_status.json_migration_req:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -14,7 +14,6 @@ from PySide6.QtCore import (
|
||||
Signal,
|
||||
)
|
||||
from src.core.library import Library
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
@@ -43,27 +42,7 @@ class CollageIconRenderer(QObject):
|
||||
|
||||
try:
|
||||
if data_tint_mode or data_only_mode:
|
||||
if entry.fields:
|
||||
has_any_tags: bool = False
|
||||
has_content_tags: bool = False
|
||||
has_meta_tags: bool = False
|
||||
for field in entry.tag_box_fields:
|
||||
if field.tags:
|
||||
has_any_tags = True
|
||||
if field.type_key == _FieldID.TAGS_CONTENT.name:
|
||||
has_content_tags = True
|
||||
elif field.type_key == _FieldID.TAGS_META.name:
|
||||
has_meta_tags = True
|
||||
if has_content_tags and has_meta_tags:
|
||||
color = "#28bb48" # Green
|
||||
elif has_any_tags:
|
||||
color = "#ffd63d" # Yellow
|
||||
# color = '#95e345' # Yellow-Green
|
||||
else:
|
||||
# color = '#fa9a2c' # Yellow-Orange
|
||||
color = "#ed8022" # Orange
|
||||
else:
|
||||
color = "#e22c3c" # Red
|
||||
color = "#28bb48" if entry.tags else "#e22c3c"
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import MethodType
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
from warnings import catch_warnings
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
from src.core.enums import Theme
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
@@ -32,6 +32,19 @@ class FieldContainer(QWidget):
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
trash_icon_128.load()
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
container_style = (
|
||||
f"QWidget#fieldContainer{{"
|
||||
"border-radius:4px;"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::hover{{"
|
||||
f"background-color:{Theme.COLOR_HOVER.value};"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::pressed{{"
|
||||
f"background-color:{Theme.COLOR_PRESSED.value};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("fieldContainer")
|
||||
@@ -48,12 +61,12 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.inner_layout.setContentsMargins(6, 0, 6, 6)
|
||||
self.inner_layout.setSpacing(0)
|
||||
self.inner_container = QWidget()
|
||||
self.inner_container.setObjectName("innerContainer")
|
||||
self.inner_container.setLayout(self.inner_layout)
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
self.field_container = QWidget()
|
||||
self.field_container.setObjectName("fieldContainer")
|
||||
self.field_container.setLayout(self.inner_layout)
|
||||
self.root_layout.addWidget(self.field_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
@@ -67,12 +80,12 @@ class FieldContainer(QWidget):
|
||||
self.title_widget.setMinimumHeight(button_size)
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
self.title_widget.setText(title)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addStretch(2)
|
||||
|
||||
self.copy_button = QPushButtonWrapper()
|
||||
self.copy_button = QPushButton()
|
||||
self.copy_button.setObjectName("copyButton")
|
||||
self.copy_button.setMinimumSize(button_size, button_size)
|
||||
self.copy_button.setMaximumSize(button_size, button_size)
|
||||
self.copy_button.setFlat(True)
|
||||
@@ -81,7 +94,8 @@ class FieldContainer(QWidget):
|
||||
self.title_layout.addWidget(self.copy_button)
|
||||
self.copy_button.setHidden(True)
|
||||
|
||||
self.edit_button = QPushButtonWrapper()
|
||||
self.edit_button = QPushButton()
|
||||
self.edit_button.setObjectName("editButton")
|
||||
self.edit_button.setMinimumSize(button_size, button_size)
|
||||
self.edit_button.setMaximumSize(button_size, button_size)
|
||||
self.edit_button.setFlat(True)
|
||||
@@ -90,7 +104,8 @@ class FieldContainer(QWidget):
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
|
||||
self.remove_button = QPushButtonWrapper()
|
||||
self.remove_button = QPushButton()
|
||||
self.remove_button.setObjectName("removeButton")
|
||||
self.remove_button.setMinimumSize(button_size, button_size)
|
||||
self.remove_button.setMaximumSize(button_size, button_size)
|
||||
self.remove_button.setFlat(True)
|
||||
@@ -99,37 +114,39 @@ class FieldContainer(QWidget):
|
||||
self.title_layout.addWidget(self.remove_button)
|
||||
self.remove_button.setHidden(True)
|
||||
|
||||
self.field_container = QWidget()
|
||||
self.field_container.setObjectName("fieldContainer")
|
||||
self.field = QWidget()
|
||||
self.field.setObjectName("field")
|
||||
self.field_layout = QHBoxLayout()
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
self.field.setLayout(self.field_layout)
|
||||
self.inner_layout.addWidget(self.field)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
if self.copy_button.is_connected:
|
||||
self.setStyleSheet(FieldContainer.container_style)
|
||||
|
||||
def set_copy_callback(self, callback: Callable | None = None):
|
||||
with catch_warnings(record=True):
|
||||
self.copy_button.clicked.disconnect()
|
||||
|
||||
self.copy_callback = callback
|
||||
self.copy_button.clicked.connect(callback)
|
||||
self.copy_button.is_connected = callable(callback)
|
||||
if callback:
|
||||
self.copy_button.clicked.connect(callback)
|
||||
|
||||
def set_edit_callback(self, callback: Callable):
|
||||
if self.edit_button.is_connected:
|
||||
def set_edit_callback(self, callback: Callable | None = None):
|
||||
with catch_warnings(record=True):
|
||||
self.edit_button.clicked.disconnect()
|
||||
|
||||
self.edit_callback = callback
|
||||
self.edit_button.clicked.connect(callback)
|
||||
self.edit_button.is_connected = callable(callback)
|
||||
if callback:
|
||||
self.edit_button.clicked.connect(callback)
|
||||
|
||||
def set_remove_callback(self, callback: Callable):
|
||||
if self.remove_button.is_connected:
|
||||
def set_remove_callback(self, callback: Callable | None = None):
|
||||
with catch_warnings(record=True):
|
||||
self.remove_button.clicked.disconnect()
|
||||
|
||||
self.remove_callback = callback
|
||||
self.remove_button.clicked.connect(callback)
|
||||
self.remove_button.is_connected = callable(callback)
|
||||
if callback:
|
||||
self.remove_button.clicked.connect(callback)
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
if self.field_layout.itemAt(0):
|
||||
@@ -145,8 +162,8 @@ class FieldContainer(QWidget):
|
||||
return None
|
||||
|
||||
def set_title(self, title: str):
|
||||
self.title = title
|
||||
self.title_widget.setText(title)
|
||||
self.title = self.title = f"<h4>{title}</h4>"
|
||||
self.title_widget.setText(self.title)
|
||||
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import time
|
||||
@@ -7,6 +7,7 @@ from enum import Enum
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
@@ -24,8 +25,7 @@ from src.core.constants import (
|
||||
TAG_ARCHIVED,
|
||||
TAG_FAVORITE,
|
||||
)
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.library import ItemType, Library
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.flowlayout import FlowWidget
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
@@ -119,11 +119,9 @@ class ItemThumb(FlowWidget):
|
||||
library: Library,
|
||||
driver: "QtDriver",
|
||||
thumb_size: tuple[int, int],
|
||||
grid_idx: int,
|
||||
show_filename_label: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.grid_idx = grid_idx
|
||||
self.lib = library
|
||||
self.mode: ItemType = mode
|
||||
self.driver = driver
|
||||
@@ -206,10 +204,10 @@ class ItemThumb(FlowWidget):
|
||||
self.thumb_button = ThumbButton(self.thumb_container, thumb_size)
|
||||
self.renderer = ThumbRenderer()
|
||||
self.renderer.updated.connect(
|
||||
lambda ts, i, s, fn, ext: (
|
||||
self.update_thumb(ts, image=i),
|
||||
self.update_size(ts, size=s),
|
||||
self.set_filename_text(fn),
|
||||
lambda timestamp, image, size, filename, ext: (
|
||||
self.update_thumb(timestamp, image=image),
|
||||
self.update_size(timestamp, size=size),
|
||||
self.set_filename_text(filename),
|
||||
self.set_extension(ext),
|
||||
)
|
||||
)
|
||||
@@ -325,7 +323,7 @@ class ItemThumb(FlowWidget):
|
||||
return self.badge_active[BadgeType.FAVORITE]
|
||||
|
||||
@property
|
||||
def is_archived(self):
|
||||
def is_archived(self) -> bool:
|
||||
return self.badge_active[BadgeType.ARCHIVED]
|
||||
|
||||
def set_mode(self, mode: ItemType | None) -> None:
|
||||
@@ -399,8 +397,9 @@ class ItemThumb(FlowWidget):
|
||||
self.ext_badge.setHidden(True)
|
||||
self.count_badge.setHidden(True)
|
||||
|
||||
def set_filename_text(self, filename: Path | str | None):
|
||||
self.file_label.setText(str(filename))
|
||||
def set_filename_text(self, filename: Path | None):
|
||||
self.set_item_path(filename)
|
||||
self.file_label.setText(str(filename.name))
|
||||
|
||||
def set_filename_visibility(self, set_visible: bool):
|
||||
"""Toggle the visibility of the filename label.
|
||||
@@ -439,30 +438,17 @@ class ItemThumb(FlowWidget):
|
||||
|
||||
def update_clickable(self, clickable: typing.Callable):
|
||||
"""Updates attributes of a thumbnail element."""
|
||||
if self.thumb_button.is_connected:
|
||||
self.thumb_button.pressed.disconnect()
|
||||
if clickable:
|
||||
with catch_warnings(record=True):
|
||||
self.thumb_button.pressed.disconnect()
|
||||
self.thumb_button.pressed.connect(clickable)
|
||||
self.thumb_button.is_connected = True
|
||||
|
||||
def refresh_badge(self, entry: Entry | None = None):
|
||||
if not entry:
|
||||
if not self.item_id:
|
||||
logger.error("missing both entry and item_id")
|
||||
return None
|
||||
def set_item_id(self, item_id: int):
|
||||
self.item_id = item_id
|
||||
|
||||
entry = self.lib.get_entry(self.item_id)
|
||||
if not entry:
|
||||
logger.error("Entry not found", item_id=self.item_id)
|
||||
return
|
||||
|
||||
self.assign_badge(BadgeType.ARCHIVED, entry.is_archived)
|
||||
self.assign_badge(BadgeType.FAVORITE, entry.is_favorited)
|
||||
|
||||
def set_item_id(self, entry: Entry):
|
||||
filepath = self.lib.library_dir / entry.path
|
||||
self.opener.set_filepath(filepath)
|
||||
self.item_id = entry.id
|
||||
def set_item_path(self, path: Path | str | None):
|
||||
"""Set the absolute filepath for the item. Used for locating on disk."""
|
||||
self.opener.set_filepath(path)
|
||||
|
||||
def assign_badge(self, badge_type: BadgeType, value: bool) -> None:
|
||||
mode = self.mode
|
||||
@@ -496,47 +482,22 @@ class ItemThumb(FlowWidget):
|
||||
return
|
||||
|
||||
toggle_value = self.badges[badge_type].isChecked()
|
||||
|
||||
self.badge_active[badge_type] = toggle_value
|
||||
tag_id = BADGE_TAGS[badge_type]
|
||||
|
||||
# check if current item is selected. if so, update all selected items
|
||||
if self.grid_idx in self.driver.selected:
|
||||
update_items = self.driver.selected
|
||||
else:
|
||||
update_items = [self.grid_idx]
|
||||
|
||||
for idx in update_items:
|
||||
entry = self.driver.frame_content[idx]
|
||||
self.toggle_item_tag(
|
||||
entry, toggle_value, tag_id, _FieldID.TAGS_META.name, create_field=True
|
||||
)
|
||||
# update the entry
|
||||
self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id)
|
||||
|
||||
self.driver.update_badges(update_items)
|
||||
badge_values: dict[BadgeType, bool] = {badge_type: toggle_value}
|
||||
self.driver.update_badges(badge_values, self.item_id)
|
||||
|
||||
def toggle_item_tag(
|
||||
self,
|
||||
entry: Entry,
|
||||
entry_id: int,
|
||||
toggle_value: bool,
|
||||
tag_id: int,
|
||||
field_key: str,
|
||||
create_field: bool = False,
|
||||
):
|
||||
logger.info(
|
||||
"toggle_item_tag",
|
||||
entry_id=entry.id,
|
||||
toggle_value=toggle_value,
|
||||
tag_id=tag_id,
|
||||
field_key=field_key,
|
||||
)
|
||||
logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id)
|
||||
|
||||
tag = self.lib.get_tag(tag_id)
|
||||
if toggle_value:
|
||||
self.lib.add_field_tag(entry, tag, field_key, create_field)
|
||||
self.lib.add_tags_to_entry(entry_id, tag_id)
|
||||
else:
|
||||
self.lib.remove_field_tag(entry, tag.id, field_key)
|
||||
self.lib.remove_tags_from_entry(entry_id, tag_id)
|
||||
|
||||
if self.driver.preview_panel.is_open:
|
||||
self.driver.preview_panel.update_widgets()
|
||||
@@ -549,13 +510,13 @@ class ItemThumb(FlowWidget):
|
||||
paths = []
|
||||
mimedata = QMimeData()
|
||||
|
||||
selected_idxs = self.driver.selected
|
||||
if self.grid_idx not in selected_idxs:
|
||||
selected_idxs = [self.grid_idx]
|
||||
selected_ids = self.driver.selected
|
||||
if self.item_id not in selected_ids:
|
||||
selected_ids = [self.item_id]
|
||||
|
||||
for grid_idx in selected_idxs:
|
||||
id = self.driver.item_thumbs[grid_idx].item_id
|
||||
entry = self.lib.get_entry(id)
|
||||
for selected_id in selected_ids:
|
||||
item_id = self.driver.item_thumbs[selected_id].item_id
|
||||
entry = self.lib.get_entry(item_id)
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
@@ -565,4 +526,4 @@ class ItemThumb(FlowWidget):
|
||||
mimedata.setUrls(paths)
|
||||
drag.setMimeData(mimedata)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
logger.info("dragged files to external program", thumbnail_indexs=selected_idxs)
|
||||
logger.info("dragged files to external program", thumbnail_indexs=selected_ids)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -17,16 +17,17 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from src.core.constants import TS_FOLDER_NAME
|
||||
from src.core.constants import LEGACY_TAG_FIELD_IDS, TS_FOLDER_NAME
|
||||
from src.core.enums import LibraryPrefs
|
||||
from src.core.library.alchemy.enums import FieldTypeEnum, TagColor
|
||||
from src.core.library.alchemy.fields import TagBoxField, _FieldID
|
||||
from src.core.library.alchemy.joins import TagField, TagSubtag
|
||||
from src.core.library.alchemy.enums import TagColor
|
||||
from src.core.library.alchemy.joins import TagParent
|
||||
from src.core.library.alchemy.library import TAG_ARCHIVED, TAG_FAVORITE, TAG_META
|
||||
from src.core.library.alchemy.library import Library as SqliteLibrary
|
||||
from src.core.library.alchemy.models import Entry, Tag, TagAlias
|
||||
from src.core.library.alchemy.models import Entry, TagAlias
|
||||
from src.core.library.json.library import Library as JsonLibrary # type: ignore
|
||||
from src.core.library.json.library import Tag as JsonTag # type: ignore
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
@@ -115,7 +116,7 @@ class JsonMigrationModal(QObject):
|
||||
entries_text: str = Translations["json_migration.heading.entires"]
|
||||
tags_text: str = Translations["json_migration.heading.tags"]
|
||||
shorthand_text: str = tab + Translations["json_migration.heading.shorthands"]
|
||||
subtags_text: str = tab + Translations["json_migration.heading.parent_tags"]
|
||||
parent_tags_text: str = tab + Translations["json_migration.heading.parent_tags"]
|
||||
aliases_text: str = tab + Translations["json_migration.heading.aliases"]
|
||||
colors_text: str = tab + Translations["json_migration.heading.colors"]
|
||||
ext_text: str = Translations["json_migration.heading.file_extension_list"]
|
||||
@@ -129,7 +130,7 @@ class JsonMigrationModal(QObject):
|
||||
self.fields_row: int = 2
|
||||
self.tags_row: int = 3
|
||||
self.shorthands_row: int = 4
|
||||
self.subtags_row: int = 5
|
||||
self.parent_tags_row: int = 5
|
||||
self.aliases_row: int = 6
|
||||
self.colors_row: int = 7
|
||||
self.ext_row: int = 8
|
||||
@@ -151,7 +152,7 @@ class JsonMigrationModal(QObject):
|
||||
self.old_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0)
|
||||
self.old_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0)
|
||||
@@ -183,7 +184,7 @@ class JsonMigrationModal(QObject):
|
||||
self.old_content_layout.addWidget(old_field_value, self.fields_row, 1)
|
||||
self.old_content_layout.addWidget(old_tag_count, self.tags_row, 1)
|
||||
self.old_content_layout.addWidget(old_shorthand_count, self.shorthands_row, 1)
|
||||
self.old_content_layout.addWidget(old_subtag_value, self.subtags_row, 1)
|
||||
self.old_content_layout.addWidget(old_subtag_value, self.parent_tags_row, 1)
|
||||
self.old_content_layout.addWidget(old_alias_value, self.aliases_row, 1)
|
||||
self.old_content_layout.addWidget(old_color_value, self.colors_row, 1)
|
||||
self.old_content_layout.addWidget(old_ext_count, self.ext_row, 1)
|
||||
@@ -192,7 +193,7 @@ class JsonMigrationModal(QObject):
|
||||
self.old_content_layout.addWidget(QLabel(), self.path_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.fields_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.shorthands_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.subtags_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.parent_tags_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2)
|
||||
self.old_content_layout.addWidget(QLabel(), self.colors_row, 2)
|
||||
|
||||
@@ -214,7 +215,7 @@ class JsonMigrationModal(QObject):
|
||||
self.new_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0)
|
||||
self.new_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0)
|
||||
@@ -246,7 +247,7 @@ class JsonMigrationModal(QObject):
|
||||
self.new_content_layout.addWidget(field_parity_value, self.fields_row, 1)
|
||||
self.new_content_layout.addWidget(new_tag_count, self.tags_row, 1)
|
||||
self.new_content_layout.addWidget(new_shorthand_count, self.shorthands_row, 1)
|
||||
self.new_content_layout.addWidget(subtag_parity_value, self.subtags_row, 1)
|
||||
self.new_content_layout.addWidget(subtag_parity_value, self.parent_tags_row, 1)
|
||||
self.new_content_layout.addWidget(alias_parity_value, self.aliases_row, 1)
|
||||
self.new_content_layout.addWidget(new_color_value, self.colors_row, 1)
|
||||
self.new_content_layout.addWidget(new_ext_count, self.ext_row, 1)
|
||||
@@ -257,7 +258,7 @@ class JsonMigrationModal(QObject):
|
||||
self.new_content_layout.addWidget(QLabel(), self.fields_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.shorthands_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.tags_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.subtags_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.parent_tags_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.colors_row, 2)
|
||||
self.new_content_layout.addWidget(QLabel(), self.ext_row, 2)
|
||||
@@ -283,7 +284,6 @@ class JsonMigrationModal(QObject):
|
||||
Translations.translate_qobject(start_button, "json_migration.start_and_preview")
|
||||
start_button.setMinimumWidth(120)
|
||||
start_button.clicked.connect(self.migrate)
|
||||
start_button.clicked.connect(lambda: finish_button.setDisabled(False))
|
||||
start_button.clicked.connect(lambda: start_button.setDisabled(True))
|
||||
finish_button: QPushButtonWrapper = QPushButtonWrapper()
|
||||
Translations.translate_qobject(finish_button, "json_migration.finish_migration")
|
||||
@@ -311,6 +311,7 @@ class JsonMigrationModal(QObject):
|
||||
# Open the JSON Library
|
||||
self.json_lib = JsonLibrary()
|
||||
self.json_lib.open_library(self.path)
|
||||
self.update_json_builtins()
|
||||
|
||||
# Update JSON UI
|
||||
self.update_json_entry_count(len(self.json_lib.entries))
|
||||
@@ -321,6 +322,26 @@ class JsonMigrationModal(QObject):
|
||||
self.migration_progress(skip_ui=skip_ui)
|
||||
self.is_migration_initialized = True
|
||||
|
||||
def update_json_builtins(self):
|
||||
"""Updates the built-in JSON values to include any future changes or additions.
|
||||
|
||||
Used to preserve user-modified built-in tags and to
|
||||
match values between JSON and SQL during parity checking.
|
||||
"""
|
||||
# v9.5.0: Add "Meta Tags" tag and parent that to "Archived" and "Favorite".
|
||||
meta_tags: JsonTag = JsonTag(TAG_META, "Meta Tags", "", ["Meta", "Meta Tag"], [], "")
|
||||
# self.json_lib.add_tag_to_library(meta_tags)
|
||||
self.json_lib.tags.append(meta_tags)
|
||||
self.json_lib._map_tag_id_to_index(meta_tags, len(self.json_lib.tags) - 1)
|
||||
|
||||
archived_tag: JsonTag = self.json_lib.get_tag(TAG_ARCHIVED)
|
||||
archived_tag.subtag_ids.append(TAG_META)
|
||||
self.json_lib.update_tag(archived_tag)
|
||||
|
||||
favorite_tag: JsonTag = self.json_lib.get_tag(TAG_FAVORITE)
|
||||
favorite_tag.subtag_ids.append(TAG_META)
|
||||
self.json_lib.update_tag(favorite_tag)
|
||||
|
||||
def migration_progress(self, skip_ui: bool = False):
|
||||
"""Initialize the progress bar and iterator for the library migration."""
|
||||
pb = QProgressDialog(
|
||||
@@ -350,6 +371,8 @@ class JsonMigrationModal(QObject):
|
||||
self.update_sql_value_ui(show_msg_box=not skip_ui),
|
||||
pb.setMinimum(1),
|
||||
pb.setValue(1),
|
||||
# Enable the finish button
|
||||
self.stack[1].buttons[4].setDisabled(False), # type: ignore
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
@@ -367,9 +390,7 @@ class JsonMigrationModal(QObject):
|
||||
if self.temp_path.exists():
|
||||
logger.info('Temporary migration file "temp_path" already exists. Removing...')
|
||||
self.temp_path.unlink()
|
||||
self.sql_lib.open_sqlite_library(
|
||||
self.json_lib.library_dir, is_new=True, add_default_data=False
|
||||
)
|
||||
self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True)
|
||||
yield Translations.translate_formatted(
|
||||
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
|
||||
)
|
||||
@@ -382,15 +403,19 @@ class JsonMigrationModal(QObject):
|
||||
check_set.add(self.check_subtag_parity())
|
||||
check_set.add(self.check_alias_parity())
|
||||
check_set.add(self.check_color_parity())
|
||||
self.update_parity_ui()
|
||||
if False not in check_set:
|
||||
yield Translations["json_migration.migration_complete"]
|
||||
else:
|
||||
yield Translations["json_migration.migration_complete_with_discrepancies"]
|
||||
self.update_parity_ui()
|
||||
QApplication.beep()
|
||||
QApplication.alert(self.paged_panel)
|
||||
self.done = True
|
||||
|
||||
except Exception as e:
|
||||
yield f"Error: {type(e).__name__}"
|
||||
QApplication.beep()
|
||||
QApplication.alert(self.paged_panel)
|
||||
self.done = True
|
||||
|
||||
def update_parity_ui(self):
|
||||
@@ -398,7 +423,7 @@ class JsonMigrationModal(QObject):
|
||||
self.update_parity_value(self.fields_row, self.field_parity)
|
||||
self.update_parity_value(self.path_row, self.path_parity)
|
||||
self.update_parity_value(self.shorthands_row, self.shorthand_parity)
|
||||
self.update_parity_value(self.subtags_row, self.subtag_parity)
|
||||
self.update_parity_value(self.parent_tags_row, self.subtag_parity)
|
||||
self.update_parity_value(self.aliases_row, self.alias_parity)
|
||||
self.update_parity_value(self.colors_row, self.color_parity)
|
||||
self.sql_lib.close()
|
||||
@@ -429,7 +454,7 @@ class JsonMigrationModal(QObject):
|
||||
if self.discrepancies:
|
||||
logger.warning("Discrepancies found:")
|
||||
logger.warning("\n".join(self.discrepancies))
|
||||
QApplication.beep()
|
||||
QApplication.alert(self.paged_panel)
|
||||
if not show_msg_box:
|
||||
return
|
||||
msg_box = QMessageBox()
|
||||
@@ -498,28 +523,10 @@ class JsonMigrationModal(QObject):
|
||||
return str(f"<b><a style='color: {color}'>{new_value}</a></b>")
|
||||
|
||||
def check_field_parity(self) -> bool:
|
||||
"""Check if all JSON field data matches the new SQL field data."""
|
||||
"""Check if all JSON field and tag data matches the new SQL data."""
|
||||
|
||||
def sanitize_field(session, entry: Entry, value, type, type_key):
|
||||
if type is FieldTypeEnum.TAGS:
|
||||
tags = list(
|
||||
session.scalars(
|
||||
select(Tag.id)
|
||||
.join(TagField)
|
||||
.join(TagBoxField)
|
||||
.where(
|
||||
and_(
|
||||
TagBoxField.entry_id == entry.id,
|
||||
TagBoxField.id == TagField.field_id,
|
||||
TagBoxField.type_key == type_key,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return set(tags) if tags else None
|
||||
else:
|
||||
return value if value else None
|
||||
def sanitize_field(entry: Entry, value, type, type_key):
|
||||
return value if value else None
|
||||
|
||||
def sanitize_json_field(value):
|
||||
if isinstance(value, list):
|
||||
@@ -527,107 +534,68 @@ class JsonMigrationModal(QObject):
|
||||
else:
|
||||
return value if value else None
|
||||
|
||||
with Session(self.sql_lib.engine) as session:
|
||||
for json_entry in self.json_lib.entries:
|
||||
sql_fields: list[tuple] = []
|
||||
json_fields: list[tuple] = []
|
||||
for json_entry in self.json_lib.entries:
|
||||
sql_fields: list[tuple] = []
|
||||
json_fields: list[tuple] = []
|
||||
|
||||
sql_entry: Entry = session.scalar(
|
||||
select(Entry).where(Entry.id == json_entry.id + 1)
|
||||
sql_entry: Entry = self.sql_lib.get_entry_full(json_entry.id + 1)
|
||||
if not sql_entry:
|
||||
logger.info(
|
||||
"[Field Comparison]",
|
||||
message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}",
|
||||
)
|
||||
if not sql_entry:
|
||||
logger.info(
|
||||
"[Field Comparison]",
|
||||
message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}",
|
||||
)
|
||||
self.discrepancies.append(
|
||||
f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}"
|
||||
)
|
||||
self.field_parity = False
|
||||
return self.field_parity
|
||||
self.discrepancies.append(
|
||||
f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}"
|
||||
)
|
||||
self.field_parity = False
|
||||
return self.field_parity
|
||||
|
||||
for sf in sql_entry.fields:
|
||||
for sf in sql_entry.fields:
|
||||
if sf.type.type.value not in LEGACY_TAG_FIELD_IDS:
|
||||
sql_fields.append(
|
||||
(
|
||||
sql_entry.id,
|
||||
sf.type.key,
|
||||
sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key),
|
||||
sanitize_field(sql_entry, sf.value, sf.type.type, sf.type_key),
|
||||
)
|
||||
)
|
||||
sql_fields.sort()
|
||||
sql_fields.sort()
|
||||
|
||||
# NOTE: The JSON database allowed for separate tag fields of the same type with
|
||||
# different values. The SQL database does not, and instead merges these values
|
||||
# across all instances of that field on an entry.
|
||||
# TODO: ROADMAP: "Tag Categories" will merge all field tags onto the entry.
|
||||
# All visual separation from there will be data-driven from the tag itself.
|
||||
meta_tags_count: int = 0
|
||||
content_tags_count: int = 0
|
||||
tags_count: int = 0
|
||||
merged_meta_tags: set[int] = set()
|
||||
merged_content_tags: set[int] = set()
|
||||
merged_tags: set[int] = set()
|
||||
for jf in json_entry.fields:
|
||||
key: str = self.sql_lib.get_field_name_from_id(list(jf.keys())[0]).name
|
||||
value = sanitize_json_field(list(jf.values())[0])
|
||||
# NOTE: The JSON database stored tags inside of special "tag field" types which
|
||||
# no longer exist. The SQL database instead associates tags directly with entries.
|
||||
tags_count: int = 0
|
||||
json_tags: set[int] = set()
|
||||
for jf in json_entry.fields:
|
||||
int_key: int = list(jf.keys())[0]
|
||||
value = sanitize_json_field(list(jf.values())[0])
|
||||
if int_key in LEGACY_TAG_FIELD_IDS:
|
||||
tags_count += 1
|
||||
json_tags = json_tags.union(value or [])
|
||||
else:
|
||||
key: str = self.sql_lib.get_field_name_from_id(int_key).name
|
||||
json_fields.append((json_entry.id + 1, key, value))
|
||||
json_fields.sort()
|
||||
|
||||
if key == _FieldID.TAGS_META.name:
|
||||
meta_tags_count += 1
|
||||
merged_meta_tags = merged_meta_tags.union(value or [])
|
||||
elif key == _FieldID.TAGS_CONTENT.name:
|
||||
content_tags_count += 1
|
||||
merged_content_tags = merged_content_tags.union(value or [])
|
||||
elif key == _FieldID.TAGS.name:
|
||||
tags_count += 1
|
||||
merged_tags = merged_tags.union(value or [])
|
||||
else:
|
||||
# JSON IDs start at 0 instead of 1
|
||||
json_fields.append((json_entry.id + 1, key, value))
|
||||
sql_tags = {t.id for t in sql_entry.tags}
|
||||
|
||||
if meta_tags_count:
|
||||
for _ in range(0, meta_tags_count):
|
||||
json_fields.append(
|
||||
(
|
||||
json_entry.id + 1,
|
||||
_FieldID.TAGS_META.name,
|
||||
merged_meta_tags if merged_meta_tags else None,
|
||||
)
|
||||
)
|
||||
if content_tags_count:
|
||||
for _ in range(0, content_tags_count):
|
||||
json_fields.append(
|
||||
(
|
||||
json_entry.id + 1,
|
||||
_FieldID.TAGS_CONTENT.name,
|
||||
merged_content_tags if merged_content_tags else None,
|
||||
)
|
||||
)
|
||||
if tags_count:
|
||||
for _ in range(0, tags_count):
|
||||
json_fields.append(
|
||||
(
|
||||
json_entry.id + 1,
|
||||
_FieldID.TAGS.name,
|
||||
merged_tags if merged_tags else None,
|
||||
)
|
||||
)
|
||||
json_fields.sort()
|
||||
|
||||
if not (
|
||||
json_fields is not None
|
||||
and sql_fields is not None
|
||||
and (json_fields == sql_fields)
|
||||
):
|
||||
self.discrepancies.append(
|
||||
f"[Field Comparison]:\nOLD (JSON):{json_fields}\nNEW (SQL):{sql_fields}"
|
||||
)
|
||||
self.field_parity = False
|
||||
return self.field_parity
|
||||
|
||||
logger.info(
|
||||
"[Field Comparison]",
|
||||
fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]),
|
||||
if not (
|
||||
json_fields is not None
|
||||
and sql_fields is not None
|
||||
and (json_fields == sql_fields)
|
||||
and (json_tags == sql_tags)
|
||||
):
|
||||
self.discrepancies.append(
|
||||
f"[Field Comparison]:\n"
|
||||
f"OLD (JSON):{json_fields}\n{json_tags}\n"
|
||||
f"NEW (SQL):{sql_fields}\n{sql_tags}"
|
||||
)
|
||||
self.field_parity = False
|
||||
return self.field_parity
|
||||
|
||||
logger.info(
|
||||
"[Field Comparison]",
|
||||
fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]),
|
||||
)
|
||||
|
||||
self.field_parity = True
|
||||
return self.field_parity
|
||||
@@ -643,35 +611,36 @@ class JsonMigrationModal(QObject):
|
||||
return self.path_parity
|
||||
|
||||
def check_subtag_parity(self) -> bool:
|
||||
"""Check if all JSON subtags match the new SQL subtags."""
|
||||
sql_subtags: set[int] = None
|
||||
json_subtags: set[int] = None
|
||||
"""Check if all JSON parent tags match the new SQL parent tags."""
|
||||
sql_parent_tags: set[int] = None
|
||||
json_parent_tags: set[int] = None
|
||||
|
||||
with Session(self.sql_lib.engine) as session:
|
||||
for tag in self.sql_lib.tags:
|
||||
tag_id = tag.id # Tag IDs start at 0
|
||||
sql_subtags = set(
|
||||
session.scalars(select(TagSubtag.child_id).where(TagSubtag.parent_id == tag.id))
|
||||
sql_parent_tags = set(
|
||||
session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id))
|
||||
)
|
||||
|
||||
# JSON tags allowed self-parenting; SQL tags no longer allow this.
|
||||
json_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids).difference(
|
||||
set([self.json_lib.get_tag(tag_id).id])
|
||||
)
|
||||
json_parent_tags = set(self.json_lib.get_tag(tag_id).subtag_ids)
|
||||
json_parent_tags.discard(tag_id)
|
||||
|
||||
logger.info(
|
||||
"[Subtag Parity]",
|
||||
tag_id=tag_id,
|
||||
json_subtags=json_subtags,
|
||||
sql_subtags=sql_subtags,
|
||||
json_parent_tags=json_parent_tags,
|
||||
sql_parent_tags=sql_parent_tags,
|
||||
)
|
||||
|
||||
if not (
|
||||
sql_subtags is not None
|
||||
and json_subtags is not None
|
||||
and (sql_subtags == json_subtags)
|
||||
sql_parent_tags is not None
|
||||
and json_parent_tags is not None
|
||||
and (sql_parent_tags == json_parent_tags)
|
||||
):
|
||||
self.discrepancies.append(
|
||||
f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}"
|
||||
f"[Subtag Parity][Tag ID: {tag_id}]:"
|
||||
f"\nOLD (JSON):{json_parent_tags}\nNEW (SQL):{sql_parent_tags}"
|
||||
)
|
||||
self.subtag_parity = False
|
||||
return self.subtag_parity
|
||||
@@ -707,7 +676,8 @@ class JsonMigrationModal(QObject):
|
||||
and (sql_aliases == json_aliases)
|
||||
):
|
||||
self.discrepancies.append(
|
||||
f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}"
|
||||
f"[Alias Parity][Tag ID: {tag_id}]:"
|
||||
f"\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}"
|
||||
)
|
||||
self.alias_parity = False
|
||||
return self.alias_parity
|
||||
@@ -720,10 +690,14 @@ class JsonMigrationModal(QObject):
|
||||
sql_shorthand: str = None
|
||||
json_shorthand: str = None
|
||||
|
||||
def sanitize(value):
|
||||
"""Return value or convert a "not" value into None."""
|
||||
return value if value else None
|
||||
|
||||
for tag in self.sql_lib.tags:
|
||||
tag_id = tag.id # Tag IDs start at 0
|
||||
sql_shorthand = tag.shorthand
|
||||
json_shorthand = self.json_lib.get_tag(tag_id).shorthand
|
||||
sql_shorthand = sanitize(tag.shorthand)
|
||||
json_shorthand = sanitize(self.json_lib.get_tag(tag_id).shorthand)
|
||||
|
||||
logger.info(
|
||||
"[Shorthand Parity]",
|
||||
@@ -732,13 +706,10 @@ class JsonMigrationModal(QObject):
|
||||
sql_shorthand=sql_shorthand,
|
||||
)
|
||||
|
||||
if not (
|
||||
sql_shorthand is not None
|
||||
and json_shorthand is not None
|
||||
and (sql_shorthand == json_shorthand)
|
||||
):
|
||||
if sql_shorthand != json_shorthand:
|
||||
self.discrepancies.append(
|
||||
f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}"
|
||||
f"[Shorthand Parity][Tag ID: {tag_id}]:"
|
||||
f"\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}"
|
||||
)
|
||||
self.shorthand_parity = False
|
||||
return self.shorthand_parity
|
||||
@@ -756,7 +727,7 @@ class JsonMigrationModal(QObject):
|
||||
sql_color = tag.color.name
|
||||
json_color = (
|
||||
TagColor.get_color_from_str(self.json_lib.get_tag(tag_id).color).name
|
||||
if self.json_lib.get_tag(tag_id).color != ""
|
||||
if (self.json_lib.get_tag(tag_id).color) != ""
|
||||
else TagColor.DEFAULT.name
|
||||
)
|
||||
|
||||
@@ -769,7 +740,8 @@ class JsonMigrationModal(QObject):
|
||||
|
||||
if not (sql_color is not None and json_color is not None and (sql_color == json_color)):
|
||||
self.discrepancies.append(
|
||||
f"[Color Parity]:\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}"
|
||||
f"[Color Parity][Tag ID: {tag_id}]:"
|
||||
f"\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}"
|
||||
)
|
||||
self.color_parity = False
|
||||
return self.color_parity
|
||||
|
||||
520
tagstudio/src/qt/widgets/preview/field_containers.py
Normal file
520
tagstudio/src/qt/widgets/preview/field_containers.py
Normal file
@@ -0,0 +1,520 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import sys
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.constants import (
|
||||
TAG_ARCHIVED,
|
||||
TAG_FAVORITE,
|
||||
)
|
||||
from src.core.enums import Theme
|
||||
from src.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
DatetimeField,
|
||||
FieldTypeEnum,
|
||||
TextField,
|
||||
)
|
||||
from src.core.library.alchemy.library import Library
|
||||
from src.core.library.alchemy.models import Entry, Tag
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.fields import FieldContainer
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.tag_box import TagBoxWidget
|
||||
from src.qt.widgets.text import TextWidget
|
||||
from src.qt.widgets.text_box_edit import EditTextBox
|
||||
from src.qt.widgets.text_line_edit import EditTextLine
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainers(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
favorite_updated = Signal(bool)
|
||||
archived_updated = Signal(bool)
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__()
|
||||
|
||||
self.lib = library
|
||||
self.driver: QtDriver = driver
|
||||
self.initialized = False
|
||||
self.is_open: bool = False
|
||||
self.common_fields: list = []
|
||||
self.mixed_fields: list = []
|
||||
self.cached_entries: list[Entry] = []
|
||||
self.containers: list[FieldContainer] = []
|
||||
|
||||
self.panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.scroll_layout.setSpacing(0)
|
||||
|
||||
scroll_container: QWidget = QWidget()
|
||||
scroll_container.setObjectName("entryScrollContainer")
|
||||
scroll_container.setLayout(self.scroll_layout)
|
||||
|
||||
info_section = QWidget()
|
||||
info_layout = QVBoxLayout(info_section)
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
info_layout.setSpacing(0)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setObjectName("entryScrollArea")
|
||||
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
# NOTE: I would rather have this style applied to the scroll_area
|
||||
# background and NOT the scroll container background, so that the
|
||||
# rounded corners are maintained when scrolling. I was unable to
|
||||
# find the right trick to only select that particular element.
|
||||
self.scroll_area.setStyleSheet(
|
||||
"QWidget#entryScrollContainer{"
|
||||
f"background:{self.panel_bg_color};"
|
||||
"border-radius:6px;"
|
||||
"}"
|
||||
)
|
||||
self.scroll_area.setWidget(scroll_container)
|
||||
|
||||
root_layout = QHBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.addWidget(self.scroll_area)
|
||||
|
||||
def update_from_entry(self, entry_id: int, update_badges: bool = True):
|
||||
"""Update tags and fields from a single Entry source."""
|
||||
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
|
||||
|
||||
self.cached_entries = [self.lib.get_entry_full(entry_id)]
|
||||
entry_ = self.cached_entries[0]
|
||||
container_len: int = len(entry_.fields)
|
||||
container_index = 0
|
||||
|
||||
# Write tag container(s)
|
||||
if entry_.tags:
|
||||
categories = self.get_tag_categories(entry_.tags)
|
||||
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
|
||||
self.write_tag_container(
|
||||
container_index, tags=tags, category_tag=cat, is_mixed=False
|
||||
)
|
||||
container_index += 1
|
||||
container_len += 1
|
||||
if update_badges:
|
||||
self.emit_badge_signals({t.id for t in entry_.tags})
|
||||
|
||||
# Write field container(s)
|
||||
for index, field in enumerate(entry_.fields, start=container_index):
|
||||
self.write_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
if len(self.containers) > container_len:
|
||||
for i, c in enumerate(self.containers):
|
||||
if i > (container_len - 1):
|
||||
c.setHidden(True)
|
||||
|
||||
def hide_containers(self):
|
||||
"""Hide all field and tag containers."""
|
||||
for c in self.containers:
|
||||
c.setHidden(True)
|
||||
|
||||
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
|
||||
"""Get a dictionary of category tags mapped to their respective tags."""
|
||||
cats: dict[Tag | None, set[Tag]] = {}
|
||||
cats[None] = set()
|
||||
|
||||
base_tag_ids: set[int] = {x.id for x in tags}
|
||||
exhausted: set[int] = set()
|
||||
cluster_map: dict[int, set[int]] = {}
|
||||
|
||||
def add_to_cluster(tag_id: int, p_ids: list[int] | None = None):
|
||||
"""Maps a Tag's child tags' IDs back to it's parent tag's ID.
|
||||
|
||||
Example:
|
||||
Tag: ["Johnny Bravo", Parent Tags: "Cartoon Network (TV)", "Character"] maps to:
|
||||
"Cartoon Network" -> Johnny Bravo,
|
||||
"Character" -> "Johnny Bravo",
|
||||
"TV" -> Johnny Bravo"
|
||||
"""
|
||||
tag_obj = self.lib.get_tag(tag_id) # Get full object
|
||||
if p_ids is None:
|
||||
p_ids = tag_obj.parent_ids
|
||||
|
||||
for p_id in p_ids:
|
||||
if cluster_map.get(p_id) is None:
|
||||
cluster_map[p_id] = set()
|
||||
# If the p_tag has p_tags of its own, recursively link those to the original Tag.
|
||||
if tag_id not in cluster_map[p_id]:
|
||||
cluster_map[p_id].add(tag_id)
|
||||
p_tag = self.lib.get_tag(p_id) # Get full object
|
||||
if p_tag.parent_ids:
|
||||
add_to_cluster(
|
||||
tag_id,
|
||||
[sub_id for sub_id in p_tag.parent_ids if sub_id != tag_id],
|
||||
)
|
||||
exhausted.add(p_id)
|
||||
exhausted.add(tag_id)
|
||||
|
||||
for tag in tags:
|
||||
add_to_cluster(tag.id)
|
||||
|
||||
logger.info("[FieldContainers] Entry Cluster", entry_cluster=exhausted)
|
||||
logger.info("[FieldContainers] Cluster Map", cluster_map=cluster_map)
|
||||
|
||||
# Initialize all categories from parents.
|
||||
tags_ = {self.lib.get_tag(x) for x in exhausted}
|
||||
for tag in tags_:
|
||||
if tag.is_category:
|
||||
cats[tag] = set()
|
||||
logger.info("[FieldContainers] Blank Tag Categories", cats=cats)
|
||||
|
||||
# Add tags to any applicable categories.
|
||||
added_ids: set[int] = set()
|
||||
for key in cats:
|
||||
logger.info("[FieldContainers] Checking category tag key", key=key)
|
||||
|
||||
if key:
|
||||
logger.info(
|
||||
"[FieldContainers] Key cluster:", key=key, cluster=cluster_map.get(key.id)
|
||||
)
|
||||
|
||||
if final_tags := cluster_map.get(key.id, set()).union([key.id]):
|
||||
cats[key] = {self.lib.get_tag(x) for x in final_tags if x in base_tag_ids}
|
||||
added_ids = added_ids.union({x for x in final_tags if x in base_tag_ids})
|
||||
|
||||
# Add remaining tags to None key (general case).
|
||||
cats[None] = {self.lib.get_tag(x) for x in base_tag_ids if x not in added_ids}
|
||||
logger.info(
|
||||
f"[FieldContainers] [{key}] Key cluster: None, general case!",
|
||||
general_tags=cats[key],
|
||||
added=added_ids,
|
||||
base_tag_ids=base_tag_ids,
|
||||
)
|
||||
|
||||
# Remove unused categories
|
||||
empty: list[Tag] = []
|
||||
for k, v in list(cats.items()):
|
||||
if not v:
|
||||
empty.append(k)
|
||||
for key in empty:
|
||||
cats.pop(key, None)
|
||||
|
||||
logger.info("[FieldContainers] Tag Categories", categories=cats)
|
||||
return cats
|
||||
|
||||
def remove_field_prompt(self, name: str) -> str:
|
||||
return Translations.translate_formatted("library.field.confirm_remove", name=name)
|
||||
|
||||
def add_field_to_selected(self, field_list: list):
|
||||
"""Add list of entry fields to one or more selected items.
|
||||
|
||||
Uses the current driver selection, NOT the field containers cache.
|
||||
"""
|
||||
logger.info(
|
||||
"[FieldContainers][add_field_to_selected]",
|
||||
selected=self.driver.selected,
|
||||
fields=field_list,
|
||||
)
|
||||
for entry_id in self.driver.selected:
|
||||
for field_item in field_list:
|
||||
self.lib.add_field_to_entry(
|
||||
entry_id,
|
||||
field_id=field_item.data(Qt.ItemDataRole.UserRole),
|
||||
)
|
||||
|
||||
def add_tags_to_selected(self, tags: int | list[int]):
|
||||
"""Add list of tags to one or more selected items.
|
||||
|
||||
Uses the current driver selection, NOT the field containers cache.
|
||||
"""
|
||||
if isinstance(tags, int):
|
||||
tags = [tags]
|
||||
logger.info(
|
||||
"[FieldContainers][add_tags_to_selected]",
|
||||
selected=self.driver.selected,
|
||||
tags=tags,
|
||||
)
|
||||
for entry_id in self.driver.selected:
|
||||
self.lib.add_tags_to_entry(
|
||||
entry_id,
|
||||
tag_ids=tags,
|
||||
)
|
||||
self.emit_badge_signals(tags, emit_on_absent=False)
|
||||
|
||||
def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
|
||||
"""Update/Create data for a FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
field(BaseField): The type of field to write to.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_field_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
|
||||
if field.type.type == FieldTypeEnum.TEXT_LINE:
|
||||
container.set_title(field.type.name)
|
||||
container.set_inline(False)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, (str, type(None)))
|
||||
text = field.value or ""
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
|
||||
title = f"{field.type.name} ({field.type.type.value})"
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextLine(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.type.type.value}",
|
||||
save_callback=(
|
||||
lambda content: (
|
||||
self.update_field(field, content),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
if "pytest" in sys.modules:
|
||||
# for better testability
|
||||
container.modal = modal # type: ignore
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.type.type.value),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
elif field.type.type == FieldTypeEnum.TEXT_BOX:
|
||||
container.set_title(field.type.name)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, (str, type(None)))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
title = f"{field.type.name} (Text Box)"
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextBox(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.type.name}",
|
||||
save_callback=(
|
||||
lambda content: (
|
||||
self.update_field(field, content),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.type.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
elif field.type.type == FieldTypeEnum.DATETIME:
|
||||
if not is_mixed:
|
||||
try:
|
||||
container.set_title(field.type.name)
|
||||
container.set_inline(False)
|
||||
# TODO: Localize this and/or add preferences.
|
||||
date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S")
|
||||
title = f"{field.type.name} (Date)"
|
||||
inner_widget = TextWidget(title, date.strftime("%D - %r"))
|
||||
container.set_inner_widget(inner_widget)
|
||||
except Exception:
|
||||
container.set_title(field.type.name)
|
||||
container.set_inline(False)
|
||||
title = f"{field.type.name} (Date) (Unknown Format)"
|
||||
inner_widget = TextWidget(title, str(field.value))
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
container.set_edit_callback()
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.type.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
title = f"{field.type.name} (Wacky Date)"
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
else:
|
||||
logger.warning("[FieldContainers][write_container] Unknown Field", field=field)
|
||||
container.set_title(field.type.name)
|
||||
container.set_inline(False)
|
||||
title = f"{field.type.name} (Unknown Field Type)"
|
||||
inner_widget = TextWidget(title, field.type.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.type.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
container.setHidden(False)
|
||||
|
||||
def write_tag_container(
|
||||
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
|
||||
):
|
||||
"""Update/Create tag data for a FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
tags(set[Tag]): The list of tags for this container.
|
||||
category_tag(Tag|None): The category tag this container represents.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
self.scroll_layout.addWidget(container)
|
||||
else:
|
||||
container = self.containers[index]
|
||||
|
||||
container.set_title("Tags" if not category_tag else category_tag.name)
|
||||
container.set_inline(False)
|
||||
|
||||
if not is_mixed:
|
||||
inner_widget = container.get_inner_widget()
|
||||
|
||||
if isinstance(inner_widget, TagBoxWidget):
|
||||
inner_widget.set_tags(tags)
|
||||
with catch_warnings(record=True):
|
||||
inner_widget.updated.disconnect()
|
||||
|
||||
else:
|
||||
inner_widget = TagBoxWidget(
|
||||
tags,
|
||||
"Tags",
|
||||
self.driver,
|
||||
)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
inner_widget.updated.connect(
|
||||
lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True))
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
inner_widget = TextWidget("Mixed Tags", text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
container.set_edit_callback()
|
||||
container.set_remove_callback()
|
||||
container.setHidden(False)
|
||||
|
||||
def remove_field(self, field: BaseField):
|
||||
"""Remove a field from all selected Entries."""
|
||||
logger.info(
|
||||
"[FieldContainers] Removing Field",
|
||||
field=field,
|
||||
selected=[x.path for x in self.cached_entries],
|
||||
)
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
self.lib.remove_entry_field(field, entry_ids)
|
||||
|
||||
def update_field(self, field: BaseField, content: str) -> None:
|
||||
"""Update a field in all selected Entries, given a field object."""
|
||||
assert isinstance(
|
||||
field,
|
||||
(TextField, DatetimeField),
|
||||
), f"instance: {type(field)}"
|
||||
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
|
||||
assert entry_ids, "No entries selected"
|
||||
self.lib.update_entry_field(
|
||||
entry_ids,
|
||||
field,
|
||||
content,
|
||||
)
|
||||
|
||||
def remove_message_box(self, prompt: str, callback: Callable) -> None:
|
||||
remove_mb = QMessageBox()
|
||||
remove_mb.setText(prompt)
|
||||
remove_mb.setWindowTitle("Remove Field")
|
||||
remove_mb.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = remove_mb.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
|
||||
remove_mb.setDefaultButton(cancel_button)
|
||||
remove_mb.setEscapeButton(cancel_button)
|
||||
result = remove_mb.exec_()
|
||||
if result == 3: # TODO - what is this magic number?
|
||||
callback()
|
||||
|
||||
def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True):
|
||||
"""Emit any connected signals for updating badge icons."""
|
||||
logger.info("[emit_badge_signals] Emitting", tag_ids=tag_ids, emit_on_absent=emit_on_absent)
|
||||
if TAG_ARCHIVED in tag_ids:
|
||||
self.archived_updated.emit(True) # noqa: FBT003
|
||||
elif emit_on_absent:
|
||||
self.archived_updated.emit(False) # noqa: FBT003
|
||||
|
||||
if TAG_FAVORITE in tag_ids:
|
||||
self.favorite_updated.emit(True) # noqa: FBT003
|
||||
elif emit_on_absent:
|
||||
self.favorite_updated.emit(False) # noqa: FBT003
|
||||
228
tagstudio/src/qt/widgets/preview/file_attributes.py
Normal file
228
tagstudio/src/qt/widgets/preview/file_attributes.py
Normal file
@@ -0,0 +1,228 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import os
|
||||
import platform
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from humanfriendly import format_size
|
||||
from PIL import ImageFont
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.enums import Theme
|
||||
from src.core.library.alchemy.library import Library
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FileAttributes(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__()
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
)
|
||||
|
||||
self.date_style = "font-size:12px;"
|
||||
self.file_label_style = "font-size: 12px"
|
||||
self.properties_style = (
|
||||
f"background-color:{label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.file_label = FileOpenerLabel()
|
||||
self.file_label.setObjectName("filenameLabel")
|
||||
self.file_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.file_label.setWordWrap(True)
|
||||
self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.file_label.setStyleSheet(self.file_label_style)
|
||||
|
||||
self.date_created_label = QLabel()
|
||||
self.date_created_label.setObjectName("dateCreatedLabel")
|
||||
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_created_label.setStyleSheet(self.date_style)
|
||||
self.date_created_label.setHidden(True)
|
||||
|
||||
self.date_modified_label = QLabel()
|
||||
self.date_modified_label.setObjectName("dateModifiedLabel")
|
||||
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_modified_label.setStyleSheet(self.date_style)
|
||||
self.date_modified_label.setHidden(True)
|
||||
|
||||
self.dimensions_label = QLabel()
|
||||
self.dimensions_label.setObjectName("dimensionsLabel")
|
||||
self.dimensions_label.setWordWrap(True)
|
||||
self.dimensions_label.setStyleSheet(self.properties_style)
|
||||
self.dimensions_label.setHidden(True)
|
||||
|
||||
self.date_container = QWidget()
|
||||
date_layout = QVBoxLayout(self.date_container)
|
||||
date_layout.setContentsMargins(0, 2, 0, 0)
|
||||
date_layout.setSpacing(0)
|
||||
date_layout.addWidget(self.date_created_label)
|
||||
date_layout.addWidget(self.date_modified_label)
|
||||
|
||||
root_layout.addWidget(self.file_label)
|
||||
root_layout.addWidget(self.date_container)
|
||||
root_layout.addWidget(self.dimensions_label)
|
||||
|
||||
def update_date_label(self, filepath: Path | None = None) -> None:
|
||||
"""Update the "Date Created" and "Date Modified" file property labels."""
|
||||
if filepath and filepath.is_file():
|
||||
created: dt = None
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate
|
||||
)
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
elif filepath:
|
||||
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>") # TODO: Translate
|
||||
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>") # TODO: Translate
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
else:
|
||||
self.date_created_label.setHidden(True)
|
||||
self.date_modified_label.setHidden(True)
|
||||
|
||||
def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None):
|
||||
"""Render the panel widgets with the newest data from the Library."""
|
||||
if not stats:
|
||||
stats = {}
|
||||
|
||||
if not filepath:
|
||||
self.layout().setSpacing(0)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText("<i>No Items Selected</i>") # TODO: Translate
|
||||
self.file_label.set_file_path("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.dimensions_label.setText("")
|
||||
self.dimensions_label.setHidden(True)
|
||||
else:
|
||||
self.layout().setSpacing(6)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.file_label.set_file_path(filepath)
|
||||
self.dimensions_label.setHidden(False)
|
||||
|
||||
file_str: str = ""
|
||||
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
for i, part in enumerate(filepath.parts):
|
||||
part_ = part.strip(os.path.sep)
|
||||
if i != len(filepath.parts) - 1:
|
||||
file_str += f"{"\u200b".join(part_)}{separator}</b>"
|
||||
else:
|
||||
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
|
||||
self.file_label.setText(file_str)
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
|
||||
# Initialize the possible stat variables
|
||||
stats_label_text = ""
|
||||
ext_display: str = ""
|
||||
file_size: str = ""
|
||||
width_px_text: str = ""
|
||||
height_px_text: str = ""
|
||||
duration_text: str = ""
|
||||
font_family: str = ""
|
||||
|
||||
# Attempt to populate the stat variables
|
||||
width_px_text = stats.get("width", "")
|
||||
height_px_text = stats.get("height", "")
|
||||
duration_text = stats.get("duration", "")
|
||||
font_family = stats.get("font_family", "")
|
||||
if ext:
|
||||
ext_display = ext.upper()[1:]
|
||||
if filepath:
|
||||
try:
|
||||
file_size = format_size(filepath.stat().st_size)
|
||||
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.FONT_TYPES, mime_fallback=True
|
||||
):
|
||||
font = ImageFont.truetype(filepath)
|
||||
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
logger.error(
|
||||
"[FileAttributes] Could not process file stats", filepath=filepath, error=e
|
||||
)
|
||||
|
||||
# Format and display any stat variables
|
||||
def add_newline(stats_label_text: str) -> str:
|
||||
if stats_label_text and stats_label_text[-2:] != "\n":
|
||||
return stats_label_text + "\n"
|
||||
return stats_label_text
|
||||
|
||||
if ext_display:
|
||||
stats_label_text += ext_display
|
||||
if file_size:
|
||||
stats_label_text += f" • {file_size}"
|
||||
elif file_size:
|
||||
stats_label_text += file_size
|
||||
|
||||
if width_px_text and height_px_text:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
stats_label_text += f"{width_px_text} x {height_px_text} px"
|
||||
|
||||
if duration_text:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
dur_str = str(timedelta(seconds=float(duration_text)))[:-7]
|
||||
if dur_str.startswith("0:"):
|
||||
dur_str = dur_str[2:]
|
||||
if dur_str.startswith("0"):
|
||||
dur_str = dur_str[1:]
|
||||
stats_label_text += f"{dur_str}"
|
||||
|
||||
if font_family:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
stats_label_text += f"{font_family}"
|
||||
|
||||
self.dimensions_label.setText(stats_label_text)
|
||||
|
||||
def update_multi_selection(self, count: int):
|
||||
"""Format attributes for multiple selected items."""
|
||||
self.layout().setSpacing(0)
|
||||
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.file_label.setText(f"<b>{count}</b> Items Selected") # TODO: Translate
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.file_label.set_file_path("")
|
||||
self.dimensions_label.setText("")
|
||||
self.dimensions_label.setHidden(True)
|
||||
376
tagstudio/src/qt/widgets/preview/preview_thumb.py
Normal file
376
tagstudio/src/qt/widgets/preview/preview_thumb.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import io
|
||||
import time
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
|
||||
from PySide6.QtGui import QAction, QMovie, QResizeEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QWidget,
|
||||
)
|
||||
from src.core.library.alchemy.library import Library
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper, open_file
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.media_player import MediaPlayer
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
from src.qt.widgets.video_player import VideoPlayer
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PreviewThumb(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__()
|
||||
|
||||
self.is_connected = False
|
||||
self.lib = library
|
||||
self.driver: QtDriver = driver
|
||||
|
||||
self.img_button_size: tuple[int, int] = (266, 266)
|
||||
self.image_ratio: float = 1.0
|
||||
|
||||
image_layout = QHBoxLayout(self)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.open_file_action = QAction(self)
|
||||
Translations.translate_qobject(self.open_file_action, "file.open_file")
|
||||
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
|
||||
self.preview_img = QPushButtonWrapper()
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
self.preview_img.setFlat(True)
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.preview_gif.addAction(self.open_file_action)
|
||||
self.preview_gif.addAction(self.open_explorer_action)
|
||||
self.preview_gif.hide()
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
|
||||
self.thumb_renderer.updated_ratio.connect(
|
||||
lambda ratio: (
|
||||
self.set_image_ratio(ratio),
|
||||
self.update_image_size(
|
||||
(
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
),
|
||||
ratio,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self.media_player = MediaPlayer(driver)
|
||||
self.media_player.hide()
|
||||
|
||||
image_layout.addWidget(self.preview_img)
|
||||
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_gif)
|
||||
image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_vid)
|
||||
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
|
||||
self.setMinimumSize(*self.img_button_size)
|
||||
|
||||
def set_image_ratio(self, ratio: float):
|
||||
self.image_ratio = ratio
|
||||
|
||||
def update_image_size(self, size: tuple[int, int], ratio: float = None):
|
||||
if ratio:
|
||||
self.set_image_ratio(ratio)
|
||||
|
||||
adj_width: float = size[0]
|
||||
adj_height: float = size[1]
|
||||
# Landscape
|
||||
if self.image_ratio > 1:
|
||||
adj_height = size[0] * (1 / self.image_ratio)
|
||||
# Portrait
|
||||
elif self.image_ratio <= 1:
|
||||
adj_width = size[1] * self.image_ratio
|
||||
|
||||
if adj_width > size[0]:
|
||||
adj_height = adj_height * (size[0] / adj_width)
|
||||
adj_width = size[0]
|
||||
elif adj_height > size[1]:
|
||||
adj_width = adj_width * (size[1] / adj_height)
|
||||
adj_height = size[1]
|
||||
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
self.img_button_size = (int(adj_width), int(adj_height))
|
||||
self.preview_img.setMaximumSize(adj_size)
|
||||
self.preview_img.setIconSize(adj_size)
|
||||
self.preview_vid.resize_video(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
self.preview_gif.setMaximumSize(adj_size)
|
||||
self.preview_gif.setMinimumSize(adj_size)
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.preview_gif.setStyle(proxy_style)
|
||||
self.preview_vid.setStyle(proxy_style)
|
||||
m = self.preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def get_preview_size(self) -> tuple[int, int]:
|
||||
return (
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
)
|
||||
|
||||
def switch_preview(self, preview: str):
|
||||
if preview != "image" and preview != "media":
|
||||
self.preview_img.hide()
|
||||
|
||||
if preview != "video_legacy":
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
|
||||
if preview != "media":
|
||||
self.media_player.stop()
|
||||
self.media_player.hide()
|
||||
|
||||
if preview != "animated":
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
self.preview_gif.hide()
|
||||
|
||||
def _display_fallback_image(self, filepath: Path, ext=str) -> dict:
|
||||
"""Renders the given file as an image, no matter its media type.
|
||||
|
||||
Useful for fallback scenarios.
|
||||
"""
|
||||
self.switch_preview("image")
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
self.preview_img.show()
|
||||
return self._update_image(filepath, ext)
|
||||
|
||||
def _update_image(self, filepath: Path, ext: str) -> dict:
|
||||
"""Update the static image preview from a filepath."""
|
||||
stats: dict = {}
|
||||
self.switch_preview("image")
|
||||
|
||||
image: Image.Image = None
|
||||
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
|
||||
):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except (
|
||||
rawpy._rawpy.LibRawIOError,
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
):
|
||||
pass
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
|
||||
):
|
||||
try:
|
||||
image = Image.open(str(filepath))
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except UnidentifiedImageError as e:
|
||||
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
|
||||
):
|
||||
pass
|
||||
|
||||
self.preview_img.show()
|
||||
|
||||
return stats
|
||||
|
||||
def _update_animation(self, filepath: Path, ext: str) -> dict:
|
||||
"""Update the animated image preview from a filepath."""
|
||||
stats: dict = {}
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
|
||||
try:
|
||||
image: Image.Image = Image.open(filepath)
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
self.update_image_size((image.width, image.height), image.width / image.height)
|
||||
anim_image: Image.Image = image
|
||||
image_bytes_io: io.BytesIO = io.BytesIO()
|
||||
anim_image.save(
|
||||
image_bytes_io,
|
||||
"GIF",
|
||||
lossless=True,
|
||||
save_all=True,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
image_bytes_io.seek(0)
|
||||
ba: bytes = image_bytes_io.read()
|
||||
self.gif_buffer.setData(ba)
|
||||
movie = QMovie(self.gif_buffer, QByteArray())
|
||||
self.preview_gif.setMovie(movie)
|
||||
|
||||
# If the animation only has 1 frame, display it like a normal image.
|
||||
if movie.frameCount() == 1:
|
||||
self._display_fallback_image(filepath, ext)
|
||||
return stats
|
||||
|
||||
# The animation has more than 1 frame, continue displaying it as an animation
|
||||
self.switch_preview("animated")
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
movie.start()
|
||||
self.preview_gif.show()
|
||||
|
||||
stats["duration"] = movie.frameCount() // 60
|
||||
except UnidentifiedImageError as e:
|
||||
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
|
||||
return self._display_fallback_image(filepath, ext)
|
||||
|
||||
return stats
|
||||
|
||||
def _update_video_legacy(self, filepath: Path) -> dict:
|
||||
stats: dict = {}
|
||||
self.switch_preview("video_legacy")
|
||||
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
if success:
|
||||
self.preview_vid.play(str(filepath), QSize(image.width, image.height))
|
||||
self.update_image_size((image.width, image.height), image.width / image.height)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
|
||||
stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS)
|
||||
return stats
|
||||
|
||||
def _update_media(self, filepath: Path) -> dict:
|
||||
stats: dict = {}
|
||||
self.switch_preview("media")
|
||||
|
||||
self.preview_img.show()
|
||||
self.media_player.show()
|
||||
self.media_player.play(filepath)
|
||||
|
||||
stats["duration"] = self.media_player.player.duration() * 1000
|
||||
return stats
|
||||
|
||||
def update_preview(self, filepath: Path, ext: str) -> dict:
|
||||
"""Render a single file preview."""
|
||||
stats: dict = {}
|
||||
|
||||
# Video (Legacy)
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
) and is_readable_video(filepath):
|
||||
stats = self._update_video_legacy(filepath)
|
||||
|
||||
# Audio
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
|
||||
):
|
||||
self._update_image(filepath, ext)
|
||||
stats = self._update_media(filepath)
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
# Animated Images
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
|
||||
):
|
||||
stats = self._update_animation(filepath, ext)
|
||||
|
||||
# Other Types (Including Images)
|
||||
else:
|
||||
# TODO: Get thumb renderer to return this stuff to pass on
|
||||
stats = self._update_image(filepath, ext)
|
||||
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
|
||||
self.preview_img.is_connected = True
|
||||
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
self.open_file_action.triggered.connect(self.opener.open_file)
|
||||
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
return stats
|
||||
|
||||
def hide_preview(self):
|
||||
"""Completely hide the file preview."""
|
||||
self.switch_preview("")
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
return super().resizeEvent(event)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -114,8 +114,6 @@ class TagWidget(QWidget):
|
||||
self.tag = tag
|
||||
self.has_edit = has_edit
|
||||
self.has_remove = has_remove
|
||||
# self.bg_label = QLabel()
|
||||
# self.setStyleSheet('background-color:blue;')
|
||||
|
||||
# if on_click_callback:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
@@ -148,7 +146,7 @@ class TagWidget(QWidget):
|
||||
self.inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
self.bg_button.setMinimumSize(math.ceil(22 * 2), 22)
|
||||
|
||||
self.bg_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
|
||||
111
tagstudio/src/qt/widgets/tag_box.py
Executable file → Normal file
111
tagstudio/src/qt/widgets/tag_box.py
Executable file → Normal file
@@ -1,22 +1,16 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import math
|
||||
import typing
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from src.core.library import Entry, Tag
|
||||
from PySide6.QtCore import Signal
|
||||
from src.core.library import Tag
|
||||
from src.core.library.alchemy.enums import FilterState
|
||||
from src.core.library.alchemy.fields import TagBoxField
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.fields import FieldWidget
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
@@ -33,15 +27,13 @@ class TagBoxWidget(FieldWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field: TagBoxField,
|
||||
tags: set[Tag],
|
||||
title: str,
|
||||
driver: "QtDriver",
|
||||
) -> None:
|
||||
super().__init__(title)
|
||||
|
||||
assert isinstance(field, TagBoxField), f"field is {type(field)}"
|
||||
|
||||
self.field = field
|
||||
self.tags: set[Tag] = tags
|
||||
self.driver = (
|
||||
driver # Used for creating tag click callbacks that search entries for that tag.
|
||||
)
|
||||
@@ -51,51 +43,13 @@ class TagBoxWidget(FieldWidget):
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setMinimumSize(23, 23)
|
||||
self.add_button.setMaximumSize(23, 23)
|
||||
self.add_button.setText("+")
|
||||
self.add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: #1e1e1e;"
|
||||
f"color: #FFFFFF;"
|
||||
f"font-weight: bold;"
|
||||
f"border-color: #333333;"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width:{math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-bottom: 5px;"
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
f"{{"
|
||||
f"border-color: #CCCCCC;"
|
||||
f"background: #555555;"
|
||||
f"}}"
|
||||
)
|
||||
tsp = TagSearchPanel(self.driver.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
|
||||
self.add_modal = PanelModal(tsp, title)
|
||||
Translations.translate_with_setter(self.add_modal.setWindowTitle, "tag.add.plural")
|
||||
self.add_button.clicked.connect(
|
||||
lambda: (
|
||||
tsp.update_tags(),
|
||||
self.add_modal.show(),
|
||||
)
|
||||
)
|
||||
|
||||
self.set_tags(field.tags)
|
||||
|
||||
def set_field(self, field: TagBoxField):
|
||||
self.field = field
|
||||
self.set_tags(self.tags)
|
||||
|
||||
def set_tags(self, tags: typing.Iterable[Tag]):
|
||||
tags_ = sorted(list(tags), key=lambda tag: tag.name)
|
||||
is_recycled = False
|
||||
while self.base_layout.itemAt(0) and self.base_layout.itemAt(1):
|
||||
logger.info("[TagBoxWidget] Tags:", tags=tags)
|
||||
while self.base_layout.itemAt(0):
|
||||
self.base_layout.takeAt(0).widget().deleteLater()
|
||||
is_recycled = True
|
||||
|
||||
for tag in tags_:
|
||||
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)
|
||||
@@ -115,70 +69,35 @@ class TagBoxWidget(FieldWidget):
|
||||
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
|
||||
self.base_layout.addWidget(tag_widget)
|
||||
|
||||
# Move or add the '+' button.
|
||||
if is_recycled:
|
||||
self.base_layout.addWidget(self.base_layout.takeAt(0).widget())
|
||||
else:
|
||||
self.base_layout.addWidget(self.add_button)
|
||||
|
||||
# Handles an edge case where there are no more tags and the '+' button
|
||||
# doesn't move all the way to the left.
|
||||
if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1):
|
||||
self.base_layout.update()
|
||||
|
||||
def edit_tag(self, tag: Tag):
|
||||
assert isinstance(tag, Tag), f"tag is {type(tag)}"
|
||||
build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag)
|
||||
|
||||
self.edit_modal = PanelModal(
|
||||
build_tag_panel,
|
||||
title=tag.name, # TODO - display name including subtags
|
||||
tag.name, # TODO - display name including parent tags
|
||||
"Edit Tag",
|
||||
done_callback=self.driver.preview_panel.update_widgets,
|
||||
has_save=True,
|
||||
)
|
||||
Translations.translate_with_setter(self.edit_modal.setWindowTitle, "tag.edit")
|
||||
# TODO - this was update_tag()
|
||||
self.edit_modal.saved.connect(
|
||||
lambda: self.driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
subtag_ids=set(build_tag_panel.subtag_ids),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
alias_names=set(build_tag_panel.alias_names),
|
||||
alias_ids=set(build_tag_panel.alias_ids),
|
||||
)
|
||||
)
|
||||
self.edit_modal.show()
|
||||
|
||||
def add_tag_callback(self, tag_id: int):
|
||||
logger.info("add_tag_callback", tag_id=tag_id, selected=self.driver.selected)
|
||||
|
||||
tag = self.driver.lib.get_tag(tag_id=tag_id)
|
||||
for idx in self.driver.selected:
|
||||
entry: Entry = self.driver.frame_content[idx]
|
||||
|
||||
if not self.driver.lib.add_field_tag(entry, tag, self.field.type_key):
|
||||
# TODO - add some visible error
|
||||
self.error_occurred.emit(Exception("Failed to add tag"))
|
||||
|
||||
self.updated.emit()
|
||||
|
||||
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
|
||||
self.driver.update_badges()
|
||||
|
||||
def edit_tag_callback(self, tag: Tag):
|
||||
self.driver.lib.update_tag(tag)
|
||||
|
||||
def remove_tag(self, tag_id: int):
|
||||
logger.info(
|
||||
"remove_tag",
|
||||
"[TagBoxWidget] remove_tag",
|
||||
selected=self.driver.selected,
|
||||
field_type=self.field.type,
|
||||
)
|
||||
|
||||
for grid_idx in self.driver.selected:
|
||||
entry = self.driver.frame_content[grid_idx]
|
||||
self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key)
|
||||
for entry_id in self.driver.selected:
|
||||
self.driver.lib.remove_tags_from_entry(entry_id, tag_id)
|
||||
|
||||
self.updated.emit()
|
||||
|
||||
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
|
||||
self.driver.update_badges()
|
||||
self.updated.emit()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -11,14 +11,11 @@ from src.qt.widgets.fields import FieldWidget
|
||||
class TextWidget(FieldWidget):
|
||||
def __init__(self, title, text: str) -> None:
|
||||
super().__init__(title)
|
||||
# self.item = item
|
||||
self.setObjectName("textBox")
|
||||
# self.setStyleSheet('background-color:purple;')
|
||||
self.base_layout = QHBoxLayout()
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
self.text_label = QLabel()
|
||||
# self.text_label.textFormat(Qt.TextFormat.RichText)
|
||||
self.text_label.setStyleSheet("font-size: 12px")
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -10,7 +10,6 @@ from src.qt.widgets.panel import PanelWidget
|
||||
class EditTextBox(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
# self.setLayout()
|
||||
self.setMinimumSize(480, 480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
from typing import Callable
|
||||
@@ -10,7 +10,6 @@ from src.qt.widgets.panel import PanelWidget
|
||||
class EditTextLine(PanelWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
# self.setLayout()
|
||||
self.setMinimumWidth(480)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -72,7 +72,7 @@ class ThumbRenderer(QObject):
|
||||
"""A class for rendering image and file thumbnails."""
|
||||
|
||||
rm: ResourceManager = ResourceManager()
|
||||
updated = Signal(float, QPixmap, QSize, str, str)
|
||||
updated = Signal(float, QPixmap, QSize, Path, str)
|
||||
updated_ratio = Signal(float)
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -1208,7 +1208,7 @@ class ThumbRenderer(QObject):
|
||||
math.ceil(adj_size / pixel_ratio),
|
||||
math.ceil(final.size[1] / pixel_ratio),
|
||||
),
|
||||
str(_filepath.name),
|
||||
_filepath,
|
||||
_filepath.suffix.lower(),
|
||||
)
|
||||
|
||||
@@ -1217,6 +1217,6 @@ class ThumbRenderer(QObject):
|
||||
timestamp,
|
||||
QPixmap(),
|
||||
QSize(*base_size),
|
||||
str(_filepath.name),
|
||||
_filepath,
|
||||
_filepath.suffix.lower(),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ sys.path.insert(0, str(CWD.parent))
|
||||
from src.core.library import Entry, Library, Tag
|
||||
from src.core.library import alchemy as backend
|
||||
from src.core.library.alchemy.enums import TagColor
|
||||
from src.core.library.alchemy.fields import TagBoxField, _FieldID
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
@@ -47,7 +46,7 @@ def file_mediatypes_library():
|
||||
)
|
||||
|
||||
assert lib.add_entries([entry1, entry2, entry3])
|
||||
assert len(lib.tags) == 2
|
||||
assert len(lib.tags) == 3
|
||||
|
||||
return lib
|
||||
|
||||
@@ -72,47 +71,40 @@ def library(request):
|
||||
)
|
||||
assert lib.add_tag(tag)
|
||||
|
||||
subtag = Tag(
|
||||
parent_tag = Tag(
|
||||
id=1500,
|
||||
name="subbar",
|
||||
color=TagColor.YELLOW,
|
||||
)
|
||||
assert lib.add_tag(parent_tag)
|
||||
|
||||
tag2 = Tag(
|
||||
id=2000,
|
||||
name="bar",
|
||||
color=TagColor.BLUE,
|
||||
subtags={subtag},
|
||||
parent_tags={parent_tag},
|
||||
)
|
||||
assert lib.add_tag(tag2)
|
||||
|
||||
# default item with deterministic name
|
||||
entry = Entry(
|
||||
id=1,
|
||||
folder=lib.folder,
|
||||
path=pathlib.Path("foo.txt"),
|
||||
fields=lib.default_fields,
|
||||
)
|
||||
|
||||
entry.tag_box_fields = [
|
||||
TagBoxField(type_key=_FieldID.TAGS.name, tags={tag}, position=0),
|
||||
TagBoxField(
|
||||
type_key=_FieldID.TAGS_META.name,
|
||||
position=0,
|
||||
),
|
||||
]
|
||||
assert lib.add_tags_to_entry(entry.id, tag.id)
|
||||
|
||||
entry2 = Entry(
|
||||
id=2,
|
||||
folder=lib.folder,
|
||||
path=pathlib.Path("one/two/bar.md"),
|
||||
fields=lib.default_fields,
|
||||
)
|
||||
entry2.tag_box_fields = [
|
||||
TagBoxField(
|
||||
tags={tag2},
|
||||
type_key=_FieldID.TAGS_META.name,
|
||||
position=0,
|
||||
),
|
||||
]
|
||||
assert lib.add_tags_to_entry(entry2.id, tag2.id)
|
||||
|
||||
assert lib.add_entries([entry, entry2])
|
||||
assert len(lib.tags) == 5
|
||||
assert len(lib.tags) == 6
|
||||
|
||||
yield lib
|
||||
|
||||
@@ -150,6 +142,7 @@ def qt_driver(qtbot, library):
|
||||
driver.preview_panel = Mock()
|
||||
driver.flow_container = Mock()
|
||||
driver.item_thumbs = []
|
||||
driver.autofill_action = Mock()
|
||||
|
||||
driver.lib = library
|
||||
# TODO - downsize this method and use it
|
||||
|
||||
Binary file not shown.
@@ -1,36 +1,37 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
# import shutil
|
||||
# from pathlib import Path
|
||||
# from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from src.core.enums import MacroID
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
# import pytest
|
||||
# from src.core.enums import MacroID
|
||||
# from src.core.library.alchemy.fields import _FieldID
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
|
||||
# @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
|
||||
def test_sidecar_macro(qt_driver, library, cwd, entry_full):
|
||||
entry_full.path = Path("newgrounds/foo.txt")
|
||||
# TODO: Rework and finalize sidecar loading + macro systems.
|
||||
pass
|
||||
# entry_full.path = Path("newgrounds/foo.txt")
|
||||
|
||||
fixture = cwd / "fixtures/sidecar_newgrounds.json"
|
||||
dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json")
|
||||
dst.parent.mkdir()
|
||||
shutil.copy(fixture, dst)
|
||||
# fixture = cwd / "fixtures/sidecar_newgrounds.json"
|
||||
# dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json")
|
||||
# dst.parent.mkdir()
|
||||
# shutil.copy(fixture, dst)
|
||||
|
||||
qt_driver.frame_content = [entry_full]
|
||||
qt_driver.run_macro(MacroID.SIDECAR, 0)
|
||||
# qt_driver.frame_content = [entry_full]
|
||||
# qt_driver.run_macro(MacroID.SIDECAR, entry_full.id)
|
||||
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
new_fields = (
|
||||
(_FieldID.DESCRIPTION.name, "NG description"),
|
||||
(_FieldID.ARTIST.name, "NG artist"),
|
||||
(_FieldID.SOURCE.name, "https://ng.com"),
|
||||
(_FieldID.TAGS.name, None),
|
||||
)
|
||||
found = [(field.type.key, field.value) for field in entry.fields]
|
||||
# entry = library.get_entry_full(entry_full.id)
|
||||
# new_fields = (
|
||||
# (_FieldID.DESCRIPTION.name, "NG description"),
|
||||
# (_FieldID.ARTIST.name, "NG artist"),
|
||||
# (_FieldID.SOURCE.name, "https://ng.com"),
|
||||
# )
|
||||
# found = [(field.type.key, field.value) for field in entry.fields]
|
||||
|
||||
# `new_fields` should be subset of `found`
|
||||
for field in new_fields:
|
||||
assert field in found, f"Field not found: {field} / {found}"
|
||||
# # `new_fields` should be subset of `found`
|
||||
# for field in new_fields:
|
||||
# assert field in found, f"Field not found: {field} / {found}"
|
||||
|
||||
expected_tags = {"ng_tag", "ng_tag2"}
|
||||
assert {x.name in expected_tags for x in entry.tags}
|
||||
# expected_tags = {"ng_tag", "ng_tag2"}
|
||||
# assert {x.name in expected_tags for x in entry.tags}
|
||||
|
||||
@@ -11,9 +11,9 @@ def test_build_tag_panel_add_sub_tag_callback(library, generate_tag):
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
|
||||
panel.add_subtag_callback(parent.id)
|
||||
panel.add_parent_tag_callback(parent.id)
|
||||
|
||||
assert len(panel.subtag_ids) == 1
|
||||
assert len(panel.parent_ids) == 1
|
||||
|
||||
|
||||
def test_build_tag_panel_remove_subtag_callback(library, generate_tag):
|
||||
@@ -30,9 +30,9 @@ def test_build_tag_panel_remove_subtag_callback(library, generate_tag):
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
|
||||
panel.remove_subtag_callback(parent.id)
|
||||
panel.remove_parent_tag_callback(parent.id)
|
||||
|
||||
assert len(panel.subtag_ids) == 0
|
||||
assert len(panel.parent_ids) == 0
|
||||
|
||||
|
||||
import os
|
||||
@@ -73,20 +73,20 @@ def test_build_tag_panel_remove_alias_callback(library, generate_tag):
|
||||
assert alias.name not in panel.alias_names
|
||||
|
||||
|
||||
def test_build_tag_panel_set_subtags(library, generate_tag):
|
||||
def test_build_tag_panel_set_parent_tags(library, generate_tag):
|
||||
parent = library.add_tag(generate_tag("parent", id=123))
|
||||
child = library.add_tag(generate_tag("child", id=124))
|
||||
assert parent
|
||||
assert child
|
||||
|
||||
library.add_subtag(child.id, parent.id)
|
||||
library.add_parent_tag(child.id, parent.id)
|
||||
|
||||
child = library.get_tag(child.id)
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(library, child)
|
||||
|
||||
assert len(panel.subtag_ids) == 1
|
||||
assert panel.subtags_scroll_layout.count() == 1
|
||||
assert len(panel.parent_ids) == 1
|
||||
assert panel.parent_tags_scroll_layout.count() == 1
|
||||
|
||||
|
||||
def test_build_tag_panel_add_aliases(library, generate_tag):
|
||||
|
||||
172
tagstudio/tests/qt/test_field_containers.py
Normal file
172
tagstudio/tests/qt/test_field_containers.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from src.qt.widgets.preview_panel import PreviewPanel
|
||||
|
||||
|
||||
def test_update_selection_empty(qt_driver, library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Clear the library selection (selecting 1 then unselecting 1)
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
qt_driver.toggle_item_selection(1, append=True, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
for container in panel.fields.containers:
|
||||
assert container.isHidden()
|
||||
|
||||
|
||||
def test_update_selection_single(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# FieldContainer should show all applicable tags and field containers
|
||||
for container in panel.fields.containers:
|
||||
assert not container.isHidden()
|
||||
|
||||
|
||||
def test_update_selection_multiple(qt_driver, library):
|
||||
# TODO: Implement mixed field editing. Currently these containers will be hidden,
|
||||
# same as the empty selection behavior.
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Select the multiple entries
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
qt_driver.toggle_item_selection(2, append=True, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# FieldContainer should show mixed field editing
|
||||
for container in panel.fields.containers:
|
||||
assert container.isHidden()
|
||||
|
||||
|
||||
def test_add_tag_to_selection_single(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
assert {t.id for t in entry_full.tags} == {1000}
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# Add new tag
|
||||
panel.fields.add_tags_to_selected(2000)
|
||||
|
||||
# Then reload entry
|
||||
refreshed_entry = next(library.get_entries(with_joins=True))
|
||||
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
|
||||
|
||||
|
||||
def test_add_same_tag_to_selection_single(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
assert {t.id for t in entry_full.tags} == {1000}
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# Add an existing tag
|
||||
panel.fields.add_tags_to_selected(1000)
|
||||
|
||||
# Then reload entry
|
||||
refreshed_entry = next(library.get_entries(with_joins=True))
|
||||
assert {t.id for t in refreshed_entry.tags} == {1000}
|
||||
|
||||
|
||||
def test_add_tag_to_selection_multiple(qt_driver, library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
all_entries = library.get_entries(with_joins=True)
|
||||
|
||||
# We want to verify that tag 1000 is on some, but not all entries already.
|
||||
tag_present_on_some: bool = False
|
||||
tag_absent_on_some: bool = False
|
||||
|
||||
for e in all_entries:
|
||||
if 1000 in [t.id for t in e.tags]:
|
||||
tag_present_on_some = True
|
||||
else:
|
||||
tag_absent_on_some = True
|
||||
|
||||
assert tag_present_on_some
|
||||
assert tag_absent_on_some
|
||||
|
||||
# Select the multiple entries
|
||||
for i, e in enumerate(library.get_entries(with_joins=True), start=0):
|
||||
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
|
||||
panel.update_widgets()
|
||||
|
||||
# Add new tag
|
||||
panel.fields.add_tags_to_selected(1000)
|
||||
|
||||
# Then reload all entries and recheck the presence of tag 1000
|
||||
refreshed_entries = library.get_entries(with_joins=True)
|
||||
tag_present_on_some: bool = False
|
||||
tag_absent_on_some: bool = False
|
||||
|
||||
for e in refreshed_entries:
|
||||
if 1000 in [t.id for t in e.tags]:
|
||||
tag_present_on_some = True
|
||||
else:
|
||||
tag_absent_on_some = True
|
||||
|
||||
assert tag_present_on_some
|
||||
assert not tag_absent_on_some
|
||||
|
||||
|
||||
def test_meta_tag_category(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entry(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
assert len(panel.fields.containers) == 3
|
||||
for i, container in enumerate(panel.fields.containers):
|
||||
match i:
|
||||
case 0:
|
||||
# Check if the container is the Meta Tags category
|
||||
assert container.title == f"<h4>{library.get_tag(2).name}</h4>"
|
||||
case 1:
|
||||
# Check if the container is the Tags category
|
||||
assert container.title == "<h4>Tags</h4>"
|
||||
case 2:
|
||||
# Make sure the container isn't a duplicate Tags category
|
||||
assert container.title != "<h4>Tags</h4>"
|
||||
|
||||
|
||||
def test_custom_tag_category(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Set tag 1000 (foo) as a category
|
||||
tag = library.get_tag(1000)
|
||||
tag.is_category = True
|
||||
library.update_tag(
|
||||
tag,
|
||||
)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entry(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
# FieldContainer should hide all containers
|
||||
assert len(panel.fields.containers) == 3
|
||||
for i, container in enumerate(panel.fields.containers):
|
||||
match i:
|
||||
case 0:
|
||||
# Check if the container is the Meta Tags category
|
||||
assert container.title == f"<h4>{library.get_tag(2).name}</h4>"
|
||||
case 1:
|
||||
# Check if the container is the custom "foo" category
|
||||
assert container.title == f"<h4>{tag.name}</h4>"
|
||||
case 2:
|
||||
# Make sure the container isn't a plain Tags category
|
||||
assert container.title != "<h4>Tags</h4>"
|
||||
@@ -1,124 +1,39 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from src.core.library import Entry
|
||||
from src.core.library.alchemy.enums import FieldTypeEnum
|
||||
from src.core.library.alchemy.fields import TextField, _FieldID
|
||||
from src.qt.widgets.preview_panel import PreviewPanel
|
||||
|
||||
|
||||
def test_update_widgets_not_selected(qt_driver, library):
|
||||
qt_driver.frame_content = list(library.get_entries())
|
||||
qt_driver.selected = []
|
||||
|
||||
def test_update_selection_empty(qt_driver, library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Clear the library selection (selecting 1 then unselecting 1)
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
qt_driver.toggle_item_selection(1, append=True, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
assert panel.preview_img.isVisible()
|
||||
assert panel.file_label.text() == "<i>No Items Selected</i>"
|
||||
# Panel should disable UI that allows for entry modification
|
||||
assert not panel.add_tag_button.isEnabled()
|
||||
assert not panel.add_field_button.isEnabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
|
||||
def test_update_widgets_single_selected(qt_driver, library):
|
||||
qt_driver.frame_content = list(library.get_entries())
|
||||
qt_driver.selected = [0]
|
||||
|
||||
def test_update_selection_single(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
assert panel.preview_img.isVisible()
|
||||
# Panel should enable UI that allows for entry modification
|
||||
assert panel.add_tag_button.isEnabled()
|
||||
assert panel.add_field_button.isEnabled()
|
||||
|
||||
|
||||
def test_update_widgets_multiple_selected(qt_driver, library):
|
||||
# entry with no tag fields
|
||||
entry = Entry(
|
||||
path=Path("test.txt"),
|
||||
folder=library.folder,
|
||||
fields=[TextField(type_key=_FieldID.TITLE.name, position=0)],
|
||||
)
|
||||
|
||||
assert not entry.tag_box_fields
|
||||
|
||||
library.add_entries([entry])
|
||||
assert library.entries_count == 3
|
||||
|
||||
qt_driver.frame_content = list(library.get_entries())
|
||||
qt_driver.selected = [0, 1, 2]
|
||||
|
||||
def test_update_selection_multiple(qt_driver, library):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Select the multiple entries
|
||||
qt_driver.toggle_item_selection(1, append=False, bridge=False)
|
||||
qt_driver.toggle_item_selection(2, append=True, bridge=False)
|
||||
panel.update_widgets()
|
||||
|
||||
assert {f.type_key for f in panel.common_fields} == {
|
||||
_FieldID.TITLE.name,
|
||||
}
|
||||
|
||||
assert {f.type_key for f in panel.mixed_fields} == {
|
||||
_FieldID.TAGS.name,
|
||||
_FieldID.TAGS_META.name,
|
||||
}
|
||||
|
||||
|
||||
def test_write_container_text_line(qt_driver, entry_full, library):
|
||||
# Given
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
field = entry_full.text_fields[0]
|
||||
assert len(entry_full.text_fields) == 1
|
||||
assert field.type.type == FieldTypeEnum.TEXT_LINE
|
||||
assert field.type.name == "Title"
|
||||
|
||||
# set any value
|
||||
field.value = "foo"
|
||||
panel.write_container(0, field)
|
||||
panel.selected = [0]
|
||||
|
||||
assert len(panel.containers) == 1
|
||||
container = panel.containers[0]
|
||||
widget = container.get_inner_widget()
|
||||
# test it's not "mixed data"
|
||||
assert widget.text_label.text() == "foo"
|
||||
|
||||
# When update and submit modal
|
||||
modal = panel.containers[0].modal
|
||||
modal.widget.text_edit.setText("bar")
|
||||
modal.save_button.click()
|
||||
|
||||
# Then reload entry
|
||||
entry_full = next(library.get_entries(with_joins=True))
|
||||
# the value was updated
|
||||
assert entry_full.text_fields[0].value == "bar"
|
||||
|
||||
|
||||
def test_remove_field(qt_driver, library):
|
||||
# Given
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
entries = list(library.get_entries(with_joins=True))
|
||||
qt_driver.frame_content = entries
|
||||
|
||||
# When second entry is selected
|
||||
panel.selected = [1]
|
||||
|
||||
field = entries[1].text_fields[0]
|
||||
panel.write_container(0, field)
|
||||
panel.remove_field(field)
|
||||
|
||||
entries = list(library.get_entries(with_joins=True))
|
||||
assert not entries[1].text_fields
|
||||
|
||||
|
||||
def test_update_field(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# select both entries
|
||||
qt_driver.frame_content = list(library.get_entries())[:2]
|
||||
qt_driver.selected = [0, 1]
|
||||
panel.selected = [0, 1]
|
||||
|
||||
# update field
|
||||
title_field = entry_full.text_fields[0]
|
||||
panel.update_field(title_field, "meow")
|
||||
|
||||
for entry in library.get_entries(with_joins=True):
|
||||
field = [x for x in entry.text_fields if x.type_key == title_field.type_key][0]
|
||||
assert field.value == "meow"
|
||||
# Panel should enable UI that allows for entry modification
|
||||
assert panel.add_tag_button.isEnabled()
|
||||
assert panel.add_field_button.isEnabled()
|
||||
|
||||
@@ -1,76 +1,70 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from src.core.library import Entry
|
||||
from src.core.library.alchemy.enums import FilterState
|
||||
from src.core.library.json.library import ItemType
|
||||
from src.qt.widgets.item_thumb import ItemThumb
|
||||
|
||||
# def test_update_thumbs(qt_driver):
|
||||
# qt_driver.frame_content = [
|
||||
# Entry(
|
||||
# folder=qt_driver.lib.folder,
|
||||
# path=Path("/tmp/foo"),
|
||||
# fields=qt_driver.lib.default_fields,
|
||||
# )
|
||||
# ]
|
||||
|
||||
def test_update_thumbs(qt_driver):
|
||||
qt_driver.frame_content = [
|
||||
Entry(
|
||||
folder=qt_driver.lib.folder,
|
||||
path=Path("/tmp/foo"),
|
||||
fields=qt_driver.lib.default_fields,
|
||||
)
|
||||
]
|
||||
# qt_driver.item_thumbs = []
|
||||
# for _ in range(3):
|
||||
# qt_driver.item_thumbs.append(
|
||||
# ItemThumb(
|
||||
# mode=ItemType.ENTRY,
|
||||
# library=qt_driver.lib,
|
||||
# driver=qt_driver,
|
||||
# thumb_size=(100, 100),
|
||||
# )
|
||||
# )
|
||||
|
||||
qt_driver.item_thumbs = []
|
||||
for i in range(3):
|
||||
qt_driver.item_thumbs.append(
|
||||
ItemThumb(
|
||||
mode=ItemType.ENTRY,
|
||||
library=qt_driver.lib,
|
||||
driver=qt_driver,
|
||||
thumb_size=(100, 100),
|
||||
grid_idx=i,
|
||||
)
|
||||
)
|
||||
# qt_driver.update_thumbs()
|
||||
|
||||
qt_driver.update_thumbs()
|
||||
|
||||
for idx, thumb in enumerate(qt_driver.item_thumbs):
|
||||
# only first item is visible
|
||||
assert thumb.isVisible() == (idx == 0)
|
||||
# for idx, thumb in enumerate(qt_driver.item_thumbs):
|
||||
# # only first item is visible
|
||||
# assert thumb.isVisible() == (idx == 0)
|
||||
|
||||
|
||||
def test_select_item_bridge(qt_driver, entry_min):
|
||||
# mock some props since we're not running `start()`
|
||||
qt_driver.autofill_action = Mock()
|
||||
qt_driver.sort_fields_action = Mock()
|
||||
# def test_toggle_item_selection_bridge(qt_driver, entry_min):
|
||||
# # mock some props since we're not running `start()`
|
||||
# qt_driver.autofill_action = Mock()
|
||||
# qt_driver.sort_fields_action = Mock()
|
||||
|
||||
# set the content manually
|
||||
qt_driver.frame_content = [entry_min] * 3
|
||||
# # set the content manually
|
||||
# qt_driver.frame_content = [entry_min] * 3
|
||||
|
||||
qt_driver.filter.page_size = 3
|
||||
qt_driver._init_thumb_grid()
|
||||
assert len(qt_driver.item_thumbs) == 3
|
||||
# qt_driver.filter.page_size = 3
|
||||
# qt_driver._init_thumb_grid()
|
||||
# assert len(qt_driver.item_thumbs) == 3
|
||||
|
||||
# select first item
|
||||
qt_driver.select_item(0, append=False, bridge=False)
|
||||
assert qt_driver.selected == [0]
|
||||
# # select first item
|
||||
# qt_driver.toggle_item_selection(0, append=False, bridge=False)
|
||||
# assert qt_driver.selected == [0]
|
||||
|
||||
# add second item to selection
|
||||
qt_driver.select_item(1, append=False, bridge=True)
|
||||
assert qt_driver.selected == [0, 1]
|
||||
# # add second item to selection
|
||||
# qt_driver.toggle_item_selection(1, append=False, bridge=True)
|
||||
# assert qt_driver.selected == [0, 1]
|
||||
|
||||
# add third item to selection
|
||||
qt_driver.select_item(2, append=False, bridge=True)
|
||||
assert qt_driver.selected == [0, 1, 2]
|
||||
# # add third item to selection
|
||||
# qt_driver.toggle_item_selection(2, append=False, bridge=True)
|
||||
# assert qt_driver.selected == [0, 1, 2]
|
||||
|
||||
# select third item only
|
||||
qt_driver.select_item(2, append=False, bridge=False)
|
||||
assert qt_driver.selected == [2]
|
||||
# # select third item only
|
||||
# qt_driver.toggle_item_selection(2, append=False, bridge=False)
|
||||
# assert qt_driver.selected == [2]
|
||||
|
||||
qt_driver.select_item(0, append=False, bridge=True)
|
||||
assert qt_driver.selected == [0, 1, 2]
|
||||
# qt_driver.toggle_item_selection(0, append=False, bridge=True)
|
||||
# assert qt_driver.selected == [0, 1, 2]
|
||||
|
||||
|
||||
def test_library_state_update(qt_driver):
|
||||
# Given
|
||||
for idx, entry in enumerate(qt_driver.lib.get_entries(with_joins=True)):
|
||||
thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100), idx)
|
||||
for entry in qt_driver.lib.get_entries(with_joins=True):
|
||||
thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100))
|
||||
qt_driver.item_thumbs.append(thumb)
|
||||
qt_driver.frame_content.append(entry)
|
||||
|
||||
@@ -83,21 +77,21 @@ def test_library_state_update(qt_driver):
|
||||
qt_driver.filter_items(state)
|
||||
assert qt_driver.filter.page_size == 10
|
||||
assert len(qt_driver.frame_content) == 1
|
||||
entry = qt_driver.frame_content[0]
|
||||
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
|
||||
assert list(entry.tags)[0].name == "foo"
|
||||
|
||||
# When state is not changed, previous one is still applied
|
||||
qt_driver.filter_items()
|
||||
assert qt_driver.filter.page_size == 10
|
||||
assert len(qt_driver.frame_content) == 1
|
||||
entry = qt_driver.frame_content[0]
|
||||
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
|
||||
assert list(entry.tags)[0].name == "foo"
|
||||
|
||||
# When state property is changed, previous one is overwritten
|
||||
state = FilterState.from_path("*bar.md")
|
||||
qt_driver.filter_items(state)
|
||||
assert len(qt_driver.frame_content) == 1
|
||||
entry = qt_driver.frame_content[0]
|
||||
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
|
||||
assert list(entry.tags)[0].name == "bar"
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_tag_panel(qtbot, library):
|
||||
|
||||
def test_add_tag_callback(qt_driver):
|
||||
# Given
|
||||
assert len(qt_driver.lib.tags) == 5
|
||||
assert len(qt_driver.lib.tags) == 6
|
||||
qt_driver.add_tag_action_callback()
|
||||
|
||||
# When
|
||||
@@ -20,5 +20,5 @@ def test_add_tag_callback(qt_driver):
|
||||
|
||||
# Then
|
||||
tags: set[Tag] = qt_driver.lib.tags
|
||||
assert len(tags) == 6
|
||||
assert len(tags) == 7
|
||||
assert "xxx" in {tag.name for tag in tags}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.widgets.tag_box import TagBoxWidget
|
||||
|
||||
|
||||
def test_tag_widget(qtbot, library, qt_driver):
|
||||
# given
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
field = entry.tag_box_fields[0]
|
||||
|
||||
tag_widget = TagBoxWidget(field, "title", qt_driver)
|
||||
|
||||
qtbot.add_widget(tag_widget)
|
||||
|
||||
assert not tag_widget.add_modal.isVisible()
|
||||
|
||||
# when/then check no exception is raised
|
||||
tag_widget.add_button.clicked.emit()
|
||||
# check `tag_widget.add_modal` is visible
|
||||
assert tag_widget.add_modal.isVisible()
|
||||
|
||||
|
||||
def test_tag_widget_add_existing_raises(library, qt_driver, entry_full):
|
||||
# Given
|
||||
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
|
||||
assert len(entry_full.tags) == 1
|
||||
tag = next(iter(entry_full.tags))
|
||||
|
||||
# When
|
||||
tag_widget = TagBoxWidget(tag_field, "title", qt_driver)
|
||||
tag_widget.driver.frame_content = [entry_full]
|
||||
tag_widget.driver.selected = [0]
|
||||
|
||||
# Then
|
||||
with patch.object(tag_widget, "error_occurred") as mocked:
|
||||
tag_widget.add_modal.widget.tag_chosen.emit(tag.id)
|
||||
assert mocked.emit.called
|
||||
|
||||
|
||||
def test_tag_widget_add_new_pass(qtbot, library, qt_driver, generate_tag):
|
||||
# Given
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
field = entry.tag_box_fields[0]
|
||||
|
||||
tag = generate_tag(name="new_tag")
|
||||
library.add_tag(tag)
|
||||
|
||||
tag_widget = TagBoxWidget(field, "title", qt_driver)
|
||||
|
||||
qtbot.add_widget(tag_widget)
|
||||
|
||||
tag_widget.driver.selected = [0]
|
||||
with patch.object(tag_widget, "error_occurred") as mocked:
|
||||
# When
|
||||
tag_widget.add_modal.widget.tag_chosen.emit(tag.id)
|
||||
|
||||
# Then
|
||||
assert not mocked.emit.called
|
||||
|
||||
|
||||
def test_tag_widget_remove(qtbot, qt_driver, library, entry_full):
|
||||
tag = list(entry_full.tags)[0]
|
||||
assert tag
|
||||
|
||||
assert entry_full.tag_box_fields
|
||||
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
|
||||
|
||||
tag_widget = TagBoxWidget(tag_field, "title", qt_driver)
|
||||
tag_widget.driver.selected = [0]
|
||||
|
||||
qtbot.add_widget(tag_widget)
|
||||
|
||||
tag_widget = tag_widget.base_layout.itemAt(0).widget()
|
||||
assert isinstance(tag_widget, TagWidget)
|
||||
|
||||
tag_widget.remove_button.clicked.emit()
|
||||
|
||||
entry = next(qt_driver.lib.get_entries(with_joins=True))
|
||||
assert not entry.tag_box_fields[0].tags
|
||||
|
||||
|
||||
def test_tag_widget_edit(qtbot, qt_driver, library, entry_full):
|
||||
# Given
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
library.add_tag(list(entry.tags)[0])
|
||||
tag = library.get_tag(list(entry.tags)[0].id)
|
||||
assert tag
|
||||
|
||||
assert entry_full.tag_box_fields
|
||||
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
|
||||
|
||||
tag_box_widget = TagBoxWidget(tag_field, "title", qt_driver)
|
||||
tag_box_widget.driver.selected = [0]
|
||||
|
||||
qtbot.add_widget(tag_box_widget)
|
||||
|
||||
tag_widget = tag_box_widget.base_layout.itemAt(0).widget()
|
||||
assert isinstance(tag_widget, TagWidget)
|
||||
|
||||
# When
|
||||
tag_box_widget.edit_tag(tag)
|
||||
|
||||
# Then
|
||||
panel = tag_box_widget.edit_modal.widget
|
||||
assert isinstance(panel, BuildTagPanel)
|
||||
assert panel.tag.name == tag.name
|
||||
assert panel.name_field.text() == tag.name
|
||||
@@ -13,14 +13,11 @@ def test_library_add_alias(library, generate_tag):
|
||||
tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
|
||||
subtag_ids: set[int] = set()
|
||||
parent_ids: set[int] = set()
|
||||
alias_ids: set[int] = set()
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
|
||||
|
||||
# Note: ask if it is expected behaviour that you need to re-request
|
||||
# for the tag. Or if the tag in memory should be updated
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
|
||||
assert len(alias_ids) == 1
|
||||
@@ -30,12 +27,11 @@ def test_library_get_alias(library, generate_tag):
|
||||
tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
|
||||
subtag_ids: set[int] = set()
|
||||
parent_ids: set[int] = set()
|
||||
alias_ids: set[int] = set()
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
|
||||
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
|
||||
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
|
||||
@@ -45,23 +41,20 @@ def test_library_update_alias(library, generate_tag):
|
||||
tag: Tag = library.add_tag(generate_tag("xxx", id=123))
|
||||
assert tag
|
||||
|
||||
subtag_ids: set[int] = set()
|
||||
parent_ids: set[int] = set()
|
||||
alias_ids: set[int] = set()
|
||||
alias_names: set[str] = set()
|
||||
alias_names.add("test_alias")
|
||||
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
|
||||
|
||||
tag = library.get_tag(tag.id)
|
||||
alias_ids = tag.alias_ids
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
alias_ids = library.get_tag(tag.id).alias_ids
|
||||
|
||||
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
|
||||
|
||||
alias_names.remove("test_alias")
|
||||
alias_names.add("alias_update")
|
||||
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
|
||||
library.update_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
|
||||
tag = library.get_tag(tag.id)
|
||||
|
||||
assert len(tag.alias_ids) == 1
|
||||
assert library.get_alias(tag.id, tag.alias_ids[0]).name == "alias_update"
|
||||
|
||||
@@ -77,9 +70,7 @@ def test_library_add_file(library):
|
||||
)
|
||||
|
||||
assert not library.has_path_entry(entry.path)
|
||||
|
||||
assert library.add_entries([entry])
|
||||
|
||||
assert library.has_path_entry(entry.path)
|
||||
|
||||
|
||||
@@ -96,7 +87,7 @@ def test_create_tag(library, generate_tag):
|
||||
assert tag_inc.id > 1000
|
||||
|
||||
|
||||
def test_tag_subtag_itself(library, generate_tag):
|
||||
def test_tag_self_parent(library, generate_tag):
|
||||
# tag already exists
|
||||
assert not library.add_tag(generate_tag("foo", id=1000))
|
||||
|
||||
@@ -107,7 +98,7 @@ def test_tag_subtag_itself(library, generate_tag):
|
||||
|
||||
library.update_tag(tag, {tag.id}, {}, {})
|
||||
tag = library.get_tag(tag.id)
|
||||
assert len(tag.subtag_ids) == 0
|
||||
assert len(tag.parent_ids) == 0
|
||||
|
||||
|
||||
def test_library_search(library, generate_tag, entry_full):
|
||||
@@ -121,23 +112,13 @@ def test_library_search(library, generate_tag, entry_full):
|
||||
assert results.total_count == 1
|
||||
assert len(results) == 1
|
||||
|
||||
entry = results[0]
|
||||
assert {x.name for x in entry.tags} == {
|
||||
"foo",
|
||||
}
|
||||
|
||||
assert entry.tag_box_fields
|
||||
|
||||
|
||||
def test_tag_search(library):
|
||||
tag = library.tags[0]
|
||||
|
||||
assert library.search_tags(tag.name.lower())
|
||||
|
||||
assert library.search_tags(tag.name.upper())
|
||||
|
||||
assert library.search_tags(tag.name[2:-2])
|
||||
|
||||
assert not library.search_tags(tag.name * 2)
|
||||
|
||||
|
||||
@@ -145,7 +126,7 @@ def test_get_entry(library: Library, entry_min):
|
||||
assert entry_min.id
|
||||
result = library.get_entry_full(entry_min.id)
|
||||
assert result
|
||||
assert result.tags
|
||||
assert len(result.tags) == 1
|
||||
|
||||
|
||||
def test_entries_count(library):
|
||||
@@ -159,58 +140,22 @@ def test_entries_count(library):
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
def test_add_field_to_entry(library):
|
||||
def test_parents_add(library, generate_tag):
|
||||
# Given
|
||||
entry = Entry(
|
||||
folder=library.folder,
|
||||
path=Path("xxx"),
|
||||
fields=library.default_fields,
|
||||
)
|
||||
# meta tags + content tags
|
||||
assert len(entry.tag_box_fields) == 2
|
||||
|
||||
assert library.add_entries([entry])
|
||||
|
||||
# When
|
||||
library.add_entry_field_type(entry.id, field_id=_FieldID.TAGS)
|
||||
|
||||
# Then
|
||||
entry = [x for x in library.get_entries(with_joins=True) if x.path == entry.path][0]
|
||||
# meta tags and tags field present
|
||||
assert len(entry.tag_box_fields) == 3
|
||||
|
||||
|
||||
def test_add_field_tag(library: Library, entry_full, generate_tag):
|
||||
# Given
|
||||
tag_name = "xxx"
|
||||
tag = generate_tag(tag_name)
|
||||
tag_field = entry_full.tag_box_fields[0]
|
||||
|
||||
# When
|
||||
library.add_field_tag(entry_full, tag, tag_field.type_key)
|
||||
|
||||
# Then
|
||||
result = library.get_entry_full(entry_full.id)
|
||||
tag_field = result.tag_box_fields[0]
|
||||
assert [x.name for x in tag_field.tags if x.name == tag_name]
|
||||
|
||||
|
||||
def test_subtags_add(library, generate_tag):
|
||||
# Given
|
||||
tag = library.tags[0]
|
||||
tag: Tag = library.tags[0]
|
||||
assert tag.id is not None
|
||||
|
||||
subtag = generate_tag("subtag1")
|
||||
subtag = library.add_tag(subtag)
|
||||
assert subtag.id is not None
|
||||
parent_tag = generate_tag("parent_tag_01")
|
||||
parent_tag = library.add_tag(parent_tag)
|
||||
assert parent_tag.id is not None
|
||||
|
||||
# When
|
||||
assert library.add_subtag(tag.id, subtag.id)
|
||||
assert library.add_parent_tag(tag.id, parent_tag.id)
|
||||
|
||||
# Then
|
||||
assert tag.id is not None
|
||||
tag = library.get_tag(tag.id)
|
||||
assert tag.subtag_ids
|
||||
assert tag.parent_ids
|
||||
|
||||
|
||||
def test_remove_tag(library, generate_tag):
|
||||
@@ -286,7 +231,7 @@ def test_remove_field_entry_with_multiple_field(library, entry_full):
|
||||
|
||||
# When
|
||||
# add identical field
|
||||
assert library.add_entry_field_type(entry_full.id, field_id=title_field.type_key)
|
||||
assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
|
||||
|
||||
# remove entry field
|
||||
library.remove_entry_field(title_field, [entry_full.id])
|
||||
@@ -315,7 +260,7 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full):
|
||||
|
||||
# When
|
||||
# add identical field
|
||||
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key)
|
||||
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
|
||||
|
||||
# update one of the fields
|
||||
library.update_entry_field(
|
||||
@@ -357,25 +302,21 @@ def test_mirror_entry_fields(library: Library, entry_full):
|
||||
entry = library.get_entry_full(entry_id)
|
||||
|
||||
# make sure fields are there after getting it from the library again
|
||||
assert len(entry.fields) == 4
|
||||
assert len(entry.fields) == 2
|
||||
assert {x.type_key for x in entry.fields} == {
|
||||
_FieldID.TITLE.name,
|
||||
_FieldID.NOTES.name,
|
||||
_FieldID.TAGS_META.name,
|
||||
_FieldID.TAGS.name,
|
||||
}
|
||||
|
||||
|
||||
def test_remove_tag_from_field(library, entry_full):
|
||||
for field in entry_full.tag_box_fields:
|
||||
for tag in field.tags:
|
||||
removed_tag = tag.name
|
||||
library.remove_tag_from_field(tag, field)
|
||||
break
|
||||
def test_remove_tag_from_entry(library, entry_full):
|
||||
removed_tag_id = -1
|
||||
for tag in entry_full.tags:
|
||||
removed_tag_id = tag.id
|
||||
library.remove_tags_from_entry(entry_full.id, tag.id)
|
||||
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
for field in entry.tag_box_fields:
|
||||
assert removed_tag not in [tag.name for tag in field.tags]
|
||||
assert removed_tag_id not in [t.id for t in entry.tags]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -398,8 +339,8 @@ def test_update_field_order(library, entry_full):
|
||||
title_field = entry_full.text_fields[0]
|
||||
|
||||
# When add two more fields
|
||||
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="first")
|
||||
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="second")
|
||||
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="first")
|
||||
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="second")
|
||||
|
||||
# remove the one on first position
|
||||
assert title_field.position == 0
|
||||
|
||||
@@ -13,12 +13,12 @@ def verify_count(lib: Library, query: str, count: int):
|
||||
@pytest.mark.parametrize(
|
||||
["query", "count"],
|
||||
[
|
||||
("", 29),
|
||||
("path:*", 29),
|
||||
("", 31),
|
||||
("path:*", 31),
|
||||
("path:*inherit*", 24),
|
||||
("path:*comp*", 5),
|
||||
("special:untagged", 1),
|
||||
("filetype:png", 23),
|
||||
("special:untagged", 2),
|
||||
("filetype:png", 25),
|
||||
("filetype:jpg", 6),
|
||||
("filetype:'jpg'", 6),
|
||||
("tag_id:1011", 5),
|
||||
@@ -68,7 +68,7 @@ def test_and(search_library: Library, query: str, count: int):
|
||||
("circle or green", 14),
|
||||
("green or circle", 14),
|
||||
("filetype:jpg or tag:orange", 11),
|
||||
("red or filetype:png", 25),
|
||||
("red or filetype:png", 28),
|
||||
("filetype:jpg or path:*comp*", 11),
|
||||
],
|
||||
)
|
||||
@@ -79,22 +79,22 @@ def test_or(search_library: Library, query: str, count: int):
|
||||
@pytest.mark.parametrize(
|
||||
["query", "count"],
|
||||
[
|
||||
("not unexistant", 29),
|
||||
("not unexistant", 31),
|
||||
("not path:*", 0),
|
||||
("not not path:*", 29),
|
||||
("not special:untagged", 28),
|
||||
("not not path:*", 31),
|
||||
("not special:untagged", 29),
|
||||
("not filetype:png", 6),
|
||||
("not filetype:jpg", 23),
|
||||
("not tag_id:1011", 24),
|
||||
("not tag_id:1038", 18),
|
||||
("not green", 24),
|
||||
("not filetype:jpg", 25),
|
||||
("not tag_id:1011", 26),
|
||||
("not tag_id:1038", 20),
|
||||
("not green", 26),
|
||||
("tag:favorite", 0),
|
||||
("not circle", 18),
|
||||
("not tag:square", 18),
|
||||
("not circle", 20),
|
||||
("not tag:square", 20),
|
||||
("circle and not square", 6),
|
||||
("not circle and square", 6),
|
||||
("special:untagged or not filetype:jpg", 24),
|
||||
("not square or green", 20),
|
||||
("special:untagged or not filetype:jpg", 25),
|
||||
("not square or green", 22),
|
||||
],
|
||||
)
|
||||
def test_not(search_library: Library, query: str, count: int):
|
||||
@@ -108,7 +108,7 @@ def test_not(search_library: Library, query: str, count: int):
|
||||
("(((tag_id:1041)))", 11),
|
||||
("not (not tag_id:1041)", 11),
|
||||
("((circle) and (not square))", 6),
|
||||
("(not ((square) OR (green)))", 15),
|
||||
("(not ((square) OR (green)))", 17),
|
||||
("filetype:png and (tag:square or green)", 12),
|
||||
],
|
||||
)
|
||||
@@ -121,7 +121,7 @@ def test_parentheses(search_library: Library, query: str, count: int):
|
||||
[
|
||||
("ellipse", 17),
|
||||
("yellow", 15),
|
||||
("color", 24),
|
||||
("color", 25),
|
||||
("shape", 24),
|
||||
("yellow not green", 10),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user