diff --git a/.gitignore b/.gitignore index 0868bb86b..2c9a1ea4b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ -tagstudio/tests/fixtures/library/* # Translations *.mo @@ -248,11 +247,14 @@ compile_commands.json # Ignore all local history of files .history .ionide +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt # TagStudio .TagStudio +!*/tests/**/.TagStudio +tagstudio/tests/fixtures/library/* +tagstudio/tests/fixtures/json_library/.TagStudio/*.sqlite TagStudio.ini -# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt .envrc .direnv diff --git a/tagstudio/src/core/library/alchemy/enums.py b/tagstudio/src/core/library/alchemy/enums.py index ce525019c..7c70f92e6 100644 --- a/tagstudio/src/core/library/alchemy/enums.py +++ b/tagstudio/src/core/library/alchemy/enums.py @@ -42,6 +42,13 @@ class TagColor(enum.IntEnum): COOL_GRAY = 36 OLIVE = 37 + @staticmethod + def get_color_from_str(color_name: str) -> "TagColor": + for color in TagColor: + if color.name == color_name.upper().replace(" ", "_"): + return color + return TagColor.DEFAULT + class SearchMode(enum.IntEnum): """Operational modes for item searching.""" diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index f9ba256a9..07e0efb6d 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -1,5 +1,6 @@ import re import shutil +import time import unicodedata from dataclasses import dataclass from datetime import UTC, datetime @@ -9,9 +10,11 @@ from uuid import uuid4 import structlog +from humanfriendly import format_timespan from sqlalchemy import ( URL, Engine, + NullPool, and_, create_engine, delete, @@ -29,6 +32,7 @@ make_transient, selectinload, ) +from src.core.library.json.library import Library as JsonLibrary # type: ignore from ...constants import ( BACKUP_FOLDER_NAME, @@ -122,6 +126,7 @@ class LibraryStatus: success: bool library_path: Path | None = None message: str | None = None + json_migration_req: bool = False class Library: @@ -132,7 +137,8 @@ class Library: engine: Engine | None folder: Folder | None - FILENAME: str = "ts_library.sqlite" + SQL_FILENAME: str = "ts_library.sqlite" + JSON_FILENAME: str = "ts_library.json" def close(self): if self.engine: @@ -141,32 +147,119 @@ def close(self): self.storage_path = None self.folder = None + def migrate_json_to_sqlite(self, json_lib: JsonLibrary): + """Migrate JSON library data to the SQLite database.""" + logger.info("Starting Library Conversion...") + start_time = time.time() + folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4())) + + # Tags + for tag in json_lib.tags: + self.add_tag( + Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + color=TagColor.get_color_from_str(tag.color), + ) + ) + + # Tag Aliases + for tag in json_lib.tags: + for alias in tag.aliases: + self.add_alias(name=alias, tag_id=tag.id) + + # Tag Subtags + for tag in json_lib.tags: + for subtag_id in tag.subtag_ids: + self.add_subtag(parent_id=tag.id, child_id=subtag_id) + + # Entries + self.add_entries( + [ + Entry( + path=entry.path / entry.filename, + folder=folder, + fields=[], + id=entry.id + 1, # JSON IDs start at 0 instead of 1 + ) + for entry in json_lib.entries + ] + ) + 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, + ) + + # Preferences + self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list]) + self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list) + + end_time = time.time() + logger.info(f"Library Converted! ({format_timespan(end_time-start_time)})") + + def get_field_name_from_id(self, field_id: int) -> _FieldID: + for f in _FieldID: + if field_id == f.value.id: + return f + return None + def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus: + is_new: bool = True if storage_path == ":memory:": self.storage_path = storage_path is_new = True + return self.open_sqlite_library(library_dir, is_new) else: - self.verify_ts_folders(library_dir) - self.storage_path = library_dir / TS_FOLDER_NAME / self.FILENAME - is_new = not self.storage_path.exists() + self.storage_path = library_dir / TS_FOLDER_NAME / self.SQL_FILENAME + + if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()): + json_path = library_dir / TS_FOLDER_NAME / self.JSON_FILENAME + if json_path.exists(): + return LibraryStatus( + success=False, + library_path=library_dir, + message="[JSON] Legacy v9.4 library requires conversion to v9.5+", + json_migration_req=True, + ) + + 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: connection_string = URL.create( drivername="sqlite", database=str(self.storage_path), ) + # NOTE: File-based databases should use NullPool to create new DB connection in order to + # keep connections on separate threads, which prevents the DB files from being locked + # even after a connection has been closed. + # SingletonThreadPool (the default for :memory:) should still be used for in-memory DBs. + # More info can be found on the SQLAlchemy docs: + # https://docs.sqlalchemy.org/en/20/changelog/migration_07.html + # Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases + poolclass = None if self.storage_path == ":memory:" else NullPool - logger.info("opening library", library_dir=library_dir, connection_string=connection_string) - self.engine = create_engine(connection_string) + logger.info( + "Opening SQLite Library", library_dir=library_dir, connection_string=connection_string + ) + self.engine = create_engine(connection_string, poolclass=poolclass) with Session(self.engine) as session: make_tables(self.engine) - tags = get_default_tags() - try: - session.add_all(tags) - session.commit() - except IntegrityError: - # default tags may exist already - session.rollback() + if add_default_data: + 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 if not is_new: @@ -217,7 +310,6 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li db_version=db_version.value, expected=LibraryPrefs.DB_VERSION.default, ) - # TODO - handle migration return LibraryStatus( success=False, message=( @@ -352,8 +444,12 @@ def tags(self) -> list[Tag]: return list(tags_list) - def verify_ts_folders(self, library_dir: Path) -> None: - """Verify/create folders required by TagStudio.""" + def verify_ts_folder(self, library_dir: Path) -> bool: + """Verify/create folders required by TagStudio. + + Returns: + bool: True if path exists, False if it needed to be created. + """ if library_dir is None: raise ValueError("No path set.") @@ -364,6 +460,8 @@ def verify_ts_folders(self, library_dir: Path) -> None: if not full_ts_path.exists(): logger.info("creating library directory", dir=full_ts_path) full_ts_path.mkdir(parents=True, exist_ok=True) + return False + return True def add_entries(self, items: list[Entry]) -> list[int]: """Add multiple Entry records to the Library.""" @@ -505,21 +603,23 @@ def search_library( def search_tags( self, - search: FilterState, + name: str, ) -> list[Tag]: """Return a list of Tag records matching the query.""" + tag_limit = 100 + with Session(self.engine) as session: query = select(Tag) query = query.options( selectinload(Tag.subtags), selectinload(Tag.aliases), - ) + ).limit(tag_limit) - if search.tag: + if name: query = query.where( or_( - Tag.name.icontains(search.tag), - Tag.shorthand.icontains(search.tag), + Tag.name.icontains(name), + Tag.shorthand.icontains(name), ) ) @@ -529,7 +629,7 @@ def search_tags( logger.info( "searching tags", - search=search, + search=name, statement=str(query), results=len(res), ) @@ -692,7 +792,7 @@ def add_entry_field_type( *, field: ValueType | None = None, field_id: _FieldID | str | None = None, - value: str | datetime | list[str] | None = None, + value: str | datetime | list[int] | None = None, ) -> bool: logger.info( "add_field_to_entry", @@ -725,8 +825,11 @@ def add_entry_field_type( if value: assert isinstance(value, list) - for tag in value: - field_model.tags.add(Tag(name=tag)) + 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( @@ -758,6 +861,28 @@ def add_entry_field_type( ) return True + def tag_from_strings(self, strings: list[str] | str) -> list[int]: + """Create a Tag from a given string.""" + # TODO: Port over tag searching with aliases fallbacks + # and context clue ranking for string searches. + tags: list[int] = [] + + if isinstance(strings, str): + strings = [strings] + + with Session(self.engine) as session: + for string in strings: + tag = session.scalar(select(Tag).where(Tag.name == string)) + if tag: + tags.append(tag.id) + else: + new = session.add(Tag(name=string)) + if new: + tags.append(new.id) + session.flush() + session.commit() + return tags + def add_tag( self, tag: Tag, @@ -850,7 +975,7 @@ def save_library_backup_to_disk(self) -> Path: target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename shutil.copy2( - self.library_dir / TS_FOLDER_NAME / self.FILENAME, + self.library_dir / TS_FOLDER_NAME / self.SQL_FILENAME, target_path, ) @@ -877,15 +1002,15 @@ def get_alias(self, tag_id: int, alias_id: int) -> TagAlias: return alias - def add_subtag(self, base_id: int, new_tag_id: int) -> bool: - if base_id == new_tag_id: + def add_subtag(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_id=base_id, - child_id=new_tag_id, + parent_id=parent_id, + child_id=child_id, ) try: @@ -897,6 +1022,22 @@ def add_subtag(self, base_id: int, new_tag_id: int) -> bool: logger.exception("IntegrityError") return False + def add_alias(self, name: str, tag_id: int) -> bool: + with Session(self.engine) as session: + alias = TagAlias( + name=name, + tag_id=tag_id, + ) + + try: + session.add(alias) + session.commit() + return True + except IntegrityError: + session.rollback() + logger.exception("IntegrityError") + return False + def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool: with Session(self.engine) as session: p_id = base_id diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 734c6823f..8da0c4708 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -43,7 +43,7 @@ class Tag(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] shorthand: Mapped[str | None] color: Mapped[TagColor] icon: Mapped[str | None] @@ -78,14 +78,14 @@ def alias_ids(self) -> list[int]: def __init__( self, - name: str, + id: int | None = None, + name: str | None = None, 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, - id: int | None = None, ): self.name = name self.aliases = aliases or set() @@ -177,10 +177,11 @@ def __init__( path: Path, folder: Folder, fields: list[BaseField], + id: int | None = None, ) -> None: self.path = path self.folder = folder - + self.id = id self.suffix = path.suffix.lstrip(".").lower() for field in fields: diff --git a/tagstudio/src/core/library/json/library.py b/tagstudio/src/core/library/json/library.py index 0570c2f35..562031968 100644 --- a/tagstudio/src/core/library/json/library.py +++ b/tagstudio/src/core/library/json/library.py @@ -414,17 +414,17 @@ def verify_ts_folders(self) -> None: """Verifies/creates folders required by TagStudio.""" full_ts_path = self.library_dir / TS_FOLDER_NAME - full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME - full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME + # full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME + # full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME if not os.path.isdir(full_ts_path): os.mkdir(full_ts_path) - if not os.path.isdir(full_backup_path): - os.mkdir(full_backup_path) + # if not os.path.isdir(full_backup_path): + # os.mkdir(full_backup_path) - if not os.path.isdir(full_collage_path): - os.mkdir(full_collage_path) + # if not os.path.isdir(full_collage_path): + # os.mkdir(full_collage_path) def verify_default_tags(self, tag_list: list) -> list: """ @@ -449,7 +449,7 @@ def open_library(self, path: str | Path) -> OpenStatus: return_code = OpenStatus.CORRUPTED _path: Path = self._fix_lib_path(path) - logger.info("opening library", path=_path) + logger.info("Opening JSON Library", path=_path) if (_path / TS_FOLDER_NAME / "ts_library.json").exists(): try: with open( @@ -554,7 +554,7 @@ def open_library(self, path: str | Path) -> OpenStatus: self._next_entry_id += 1 filename = entry.get("filename", "") - e_path = entry.get("path", "") + e_path = entry.get("path", "").replace("\\", "/") fields: list = [] if "fields" in entry: # Cast JSON str keys to ints diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index 9375c841b..937b4fb28 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -2,7 +2,9 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import structlog from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -12,11 +14,16 @@ QWidget, ) from src.core.library import Library, Tag -from src.core.library.alchemy.enums import FilterState from src.qt.modals.build_tag import BuildTagPanel from src.qt.widgets.panel import PanelModal, PanelWidget from src.qt.widgets.tag import TagWidget +logger = structlog.get_logger(__name__) + +# TODO: This class shares the majority of its code with tag_search.py. +# It should either be made DRY, or be replaced with the intended and more robust +# Tag Management tab/pane outlined on the Feature Roadmap. + class TagDatabasePanel(PanelWidget): tag_chosen = Signal(int) @@ -24,8 +31,8 @@ class TagDatabasePanel(PanelWidget): def __init__(self, library: Library): super().__init__() self.lib: Library = library + self.is_initialized: bool = False self.first_tag_id = -1 - self.tag_limit = 30 self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) @@ -54,7 +61,6 @@ def __init__(self, library: Library): self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) - self.update_tags() def on_return(self, text: str): if text and self.first_tag_id >= 0: @@ -67,12 +73,13 @@ def on_return(self, text: str): def update_tags(self, query: str | None = None): # TODO: Look at recycling rather than deleting and re-initializing + logger.info("[Tag Manager Modal] Updating Tags") while self.scroll_layout.itemAt(0): self.scroll_layout.takeAt(0).widget().deleteLater() - tags = self.lib.search_tags(FilterState(path=query, page_size=self.tag_limit)) + tags_results = self.lib.search_tags(name=query) - for tag in tags: + for tag in tags_results: container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) @@ -101,3 +108,9 @@ def edit_tag(self, tag: Tag): def edit_tag_callback(self, btp: BuildTagPanel): self.lib.update_tag(btp.build_tag(), btp.subtag_ids, btp.alias_names, btp.alias_ids) self.update_tags(self.search_field.text()) + + def showEvent(self, event: QShowEvent) -> None: # noqa N802 + if not self.is_initialized: + self.update_tags() + self.is_initialized = True + return super().showEvent(event) diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index 1bb25731a..adcfb7d04 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -7,6 +7,7 @@ import structlog from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -17,7 +18,6 @@ QWidget, ) from src.core.library import Library -from src.core.library.alchemy.enums import FilterState from src.core.palette import ColorType, get_tag_color from src.qt.widgets.panel import PanelWidget from src.qt.widgets.tag import TagWidget @@ -32,8 +32,8 @@ def __init__(self, library: Library, exclude: list[int] | None = None): super().__init__() self.lib = library self.exclude = exclude + self.is_initialized: bool = False self.first_tag_id = None - self.tag_limit = 100 self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) @@ -61,11 +61,9 @@ def __init__(self, library: Library, exclude: list[int] | None = None): self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) - self.update_tags() def on_return(self, text: str): if text and self.first_tag_id is not None: - # callback(self.first_tag_id) self.tag_chosen.emit(self.first_tag_id) self.search_field.setText("") self.update_tags() @@ -73,20 +71,17 @@ def on_return(self, text: str): self.search_field.setFocus() self.parentWidget().hide() - def update_tags(self, name: str | None = None): + def update_tags(self, query: str | None = None): + logger.info("[Tag Search Modal] Updating Tags") while self.scroll_layout.count(): self.scroll_layout.takeAt(0).widget().deleteLater() - found_tags = self.lib.search_tags( - FilterState( - path=name, - page_size=self.tag_limit, - ) - ) + tag_results = self.lib.search_tags(name=query) - for tag in found_tags: + for tag in tag_results: if self.exclude is not None and tag.id in self.exclude: continue + c = QWidget() layout = QHBoxLayout(c) layout.setContentsMargins(0, 0, 0, 0) @@ -123,3 +118,9 @@ def update_tags(self, name: str | None = None): self.scroll_layout.addWidget(c) self.search_field.setFocus() + + def showEvent(self, event: QShowEvent) -> None: # noqa N802 + if not self.is_initialized: + self.update_tags() + self.is_initialized = True + return super().showEvent(event) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c7dfa99cb..ad00e9f7c 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -89,6 +89,7 @@ from src.qt.modals.tag_database import TagDatabasePanel from src.qt.resource_manager import ResourceManager from src.qt.widgets.item_thumb import BadgeType, ItemThumb +from src.qt.widgets.migration_modal import JsonMigrationModal from src.qt.widgets.panel import PanelModal from src.qt.widgets.preview_panel import PreviewPanel from src.qt.widgets.progress import ProgressWidget @@ -468,6 +469,7 @@ def create_folders_tags_modal(): self.thumb_renderers: list[ThumbRenderer] = [] self.filter = FilterState() self.init_library_window() + self.migration_modal: JsonMigrationModal = None path_result = self.evaluate_path(self.args.open) # check status of library path evaluating @@ -807,6 +809,8 @@ def run_macro(self, name: MacroID, grid_idx: int): elif name == MacroID.SIDECAR: parsed_items = TagStudioCore.get_gdl_sidecar(ful_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( entry.id, field_id=field_id, @@ -1170,14 +1174,27 @@ def update_libs_list(self, path: Path | str): self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path) -> LibraryStatus: + def open_library(self, path: Path) -> None: """Open a TagStudio library.""" open_message: str = f'Opening Library "{str(path)}"...' self.main_window.landing_widget.set_status_label(open_message) self.main_window.statusbar.showMessage(open_message, 3) self.main_window.repaint() - open_status = self.lib.open_library(path) + open_status: LibraryStatus = self.lib.open_library(path) + + # Migration is required + if open_status.json_migration_req: + self.migration_modal = JsonMigrationModal(path) + self.migration_modal.migration_finished.connect( + lambda: self.init_library(path, self.lib.open_library(path)) + ) + self.main_window.landing_widget.set_status_label("") + self.migration_modal.paged_panel.show() + else: + self.init_library(path, open_status) + + def init_library(self, path: Path, open_status: LibraryStatus): if not open_status.success: self.show_error_message(open_status.message or "Error opening library.") return open_status diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py new file mode 100644 index 000000000..2af7d94ac --- /dev/null +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -0,0 +1,784 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from pathlib import Path + +import structlog +from PySide6.QtCore import QObject, Qt, QThreadPool, Signal +from PySide6.QtWidgets import ( + QApplication, + QGridLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressDialog, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from sqlalchemy import and_, select +from sqlalchemy.orm import Session +from src.core.constants import 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.library import Library as SqliteLibrary +from src.core.library.alchemy.models import Entry, Tag, TagAlias +from src.core.library.json.library import Library as JsonLibrary # 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 +from src.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper +from src.qt.widgets.paged_panel.paged_panel import PagedPanel +from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState + +logger = structlog.get_logger(__name__) + + +class JsonMigrationModal(QObject): + """A modal for data migration from v9.4 JSON to v9.5+ SQLite.""" + + migration_cancelled = Signal() + migration_finished = Signal() + + def __init__(self, path: Path): + super().__init__() + self.done: bool = False + self.path: Path = path + + self.stack: list[PagedPanelState] = [] + self.json_lib: JsonLibrary = None + self.sql_lib: SqliteLibrary = None + self.is_migration_initialized: bool = False + self.discrepancies: list[str] = [] + + self.title: str = f'Save Format Migration: "{self.path}"' + self.warning: str = "(!)" + + self.old_entry_count: int = 0 + self.old_tag_count: int = 0 + self.old_ext_count: int = 0 + self.old_ext_type: bool = None + + self.field_parity: bool = False + self.path_parity: bool = False + self.shorthand_parity: bool = False + self.subtag_parity: bool = False + self.alias_parity: bool = False + self.color_parity: bool = False + + self.init_page_info() + self.init_page_convert() + + self.paged_panel: PagedPanel = PagedPanel((700, 640), self.stack) + + def init_page_info(self) -> None: + """Initialize the migration info page.""" + body_wrapper: PagedBodyWrapper = PagedBodyWrapper() + body_label: QLabel = QLabel( + "Library save files created with TagStudio versions 9.4 and below will " + "need to be migrated to the new v9.5+ format." + "
" + "

What you need to know:

" + "" + ) + body_label.setWordWrap(True) + body_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + body_wrapper.layout().addWidget(body_label) + body_wrapper.layout().setContentsMargins(0, 36, 0, 0) + + cancel_button: QPushButtonWrapper = QPushButtonWrapper("Cancel") + next_button: QPushButtonWrapper = QPushButtonWrapper("Continue") + cancel_button.clicked.connect(self.migration_cancelled.emit) + + self.stack.append( + PagedPanelState( + title=self.title, + body_wrapper=body_wrapper, + buttons=[cancel_button, 1, next_button], + connect_to_back=[cancel_button], + connect_to_next=[next_button], + ) + ) + + def init_page_convert(self) -> None: + """Initialize the migration conversion page.""" + self.body_wrapper_01: PagedBodyWrapper = PagedBodyWrapper() + body_container: QWidget = QWidget() + body_container_layout: QHBoxLayout = QHBoxLayout(body_container) + body_container_layout.setContentsMargins(0, 0, 0, 0) + + tab: str = " " + self.match_text: str = "Matched" + self.differ_text: str = "Discrepancy" + + entries_text: str = "Entries:" + tags_text: str = "Tags:" + shorthand_text: str = tab + "Shorthands:" + subtags_text: str = tab + "Parent Tags:" + aliases_text: str = tab + "Aliases:" + colors_text: str = tab + "Colors:" + ext_text: str = "File Extension List:" + ext_type_text: str = "Extension List Type:" + desc_text: str = ( + "
Start and preview the results of the library migration process. " + 'The converted library will not be used unless you click "Finish Migration". ' + "

" + 'Library data should either have matching values or a feature a "Matched" label. ' + 'Values that do not match will be displayed in red and feature a "(!)" ' + "symbol next to them." + "
" + "This process may take up to several minutes for larger libraries." + "
" + ) + path_parity_text: str = tab + "Paths:" + field_parity_text: str = tab + "Fields:" + + self.entries_row: int = 0 + self.path_row: int = 1 + self.fields_row: int = 2 + self.tags_row: int = 3 + self.shorthands_row: int = 4 + self.subtags_row: int = 5 + self.aliases_row: int = 6 + self.colors_row: int = 7 + self.ext_row: int = 8 + self.ext_type_row: int = 9 + + old_lib_container: QWidget = QWidget() + old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container) + old_lib_title: QLabel = QLabel("

v9.4 Library

") + old_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + old_lib_layout.addWidget(old_lib_title) + + old_content_container: QWidget = QWidget() + self.old_content_layout: QGridLayout = QGridLayout(old_content_container) + self.old_content_layout.setContentsMargins(0, 0, 0, 0) + self.old_content_layout.setSpacing(3) + self.old_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0) + self.old_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0) + 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(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) + self.old_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + + old_entry_count: QLabel = QLabel() + old_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_path_value: QLabel = QLabel() + old_path_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_field_value: QLabel = QLabel() + old_field_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_tag_count: QLabel = QLabel() + old_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_shorthand_count: QLabel = QLabel() + old_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_subtag_value: QLabel = QLabel() + old_subtag_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_alias_value: QLabel = QLabel() + old_alias_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_color_value: QLabel = QLabel() + old_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) + old_ext_count: QLabel = QLabel() + old_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) + old_ext_type: QLabel = QLabel() + old_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.old_content_layout.addWidget(old_entry_count, self.entries_row, 1) + self.old_content_layout.addWidget(old_path_value, self.path_row, 1) + 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_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) + self.old_content_layout.addWidget(old_ext_type, self.ext_type_row, 1) + + 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.aliases_row, 2) + self.old_content_layout.addWidget(QLabel(), self.colors_row, 2) + + old_lib_layout.addWidget(old_content_container) + + new_lib_container: QWidget = QWidget() + new_lib_layout: QVBoxLayout = QVBoxLayout(new_lib_container) + new_lib_title: QLabel = QLabel("

v9.5+ Library

") + new_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + new_lib_layout.addWidget(new_lib_title) + + new_content_container: QWidget = QWidget() + self.new_content_layout: QGridLayout = QGridLayout(new_content_container) + self.new_content_layout.setContentsMargins(0, 0, 0, 0) + self.new_content_layout.setSpacing(3) + self.new_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0) + self.new_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0) + 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(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) + self.new_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0) + + new_entry_count: QLabel = QLabel() + new_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight) + path_parity_value: QLabel = QLabel() + path_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + field_parity_value: QLabel = QLabel() + field_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_tag_count: QLabel = QLabel() + new_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight) + new_shorthand_count: QLabel = QLabel() + new_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight) + subtag_parity_value: QLabel = QLabel() + subtag_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + alias_parity_value: QLabel = QLabel() + alias_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_color_value: QLabel = QLabel() + new_color_value.setAlignment(Qt.AlignmentFlag.AlignRight) + new_ext_count: QLabel = QLabel() + new_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight) + new_ext_type: QLabel = QLabel() + new_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.new_content_layout.addWidget(new_entry_count, self.entries_row, 1) + self.new_content_layout.addWidget(path_parity_value, self.path_row, 1) + 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(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) + self.new_content_layout.addWidget(new_ext_type, self.ext_type_row, 1) + + self.new_content_layout.addWidget(QLabel(), self.entries_row, 2) + self.new_content_layout.addWidget(QLabel(), self.path_row, 2) + 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.aliases_row, 2) + self.new_content_layout.addWidget(QLabel(), self.colors_row, 2) + self.new_content_layout.addWidget(QLabel(), self.ext_row, 2) + self.new_content_layout.addWidget(QLabel(), self.ext_type_row, 2) + + new_lib_layout.addWidget(new_content_container) + + desc_label = QLabel(desc_text) + desc_label.setWordWrap(True) + + body_container_layout.addStretch(2) + body_container_layout.addWidget(old_lib_container) + body_container_layout.addStretch(1) + body_container_layout.addWidget(new_lib_container) + body_container_layout.addStretch(2) + self.body_wrapper_01.layout().addWidget(body_container) + self.body_wrapper_01.layout().addWidget(desc_label) + self.body_wrapper_01.layout().setSpacing(12) + + back_button: QPushButtonWrapper = QPushButtonWrapper("Back") + start_button: QPushButtonWrapper = QPushButtonWrapper("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("Finish Migration") + finish_button.setMinimumWidth(120) + finish_button.setDisabled(True) + finish_button.clicked.connect(self.finish_migration) + finish_button.clicked.connect(self.migration_finished.emit) + + self.stack.append( + PagedPanelState( + title=self.title, + body_wrapper=self.body_wrapper_01, + buttons=[back_button, 1, start_button, 1, finish_button], + connect_to_back=[back_button], + connect_to_next=[finish_button], + ) + ) + + def migrate(self, skip_ui: bool = False): + """Open and migrate the JSON library to SQLite.""" + if not self.is_migration_initialized: + self.paged_panel.update_frame() + self.paged_panel.update() + + # Open the JSON Library + self.json_lib = JsonLibrary() + self.json_lib.open_library(self.path) + + # Update JSON UI + self.update_json_entry_count(len(self.json_lib.entries)) + self.update_json_tag_count(len(self.json_lib.tags)) + self.update_json_ext_count(len(self.json_lib.ext_list)) + self.update_json_ext_type(self.json_lib.is_exclude_list) + + self.migration_progress(skip_ui=skip_ui) + self.is_migration_initialized = True + + def migration_progress(self, skip_ui: bool = False): + """Initialize the progress bar and iterator for the library migration.""" + pb = QProgressDialog( + labelText="", + cancelButtonText="", + minimum=0, + maximum=0, + ) + pb.setCancelButton(None) + self.body_wrapper_01.layout().addWidget(pb) + + iterator = FunctionIterator(self.migration_iterator) + iterator.value.connect( + lambda x: ( + pb.setLabelText(f"

{x}

"), + self.update_sql_value_ui(show_msg_box=False) + if x == "Checking for Parity..." + else (), + self.update_parity_ui() if x == "Checking for Parity..." else (), + ) + ) + r = CustomRunnable(iterator.run) + r.done.connect( + lambda: ( + self.update_sql_value_ui(show_msg_box=not skip_ui), + pb.setMinimum(1), + pb.setValue(1), + ) + ) + QThreadPool.globalInstance().start(r) + + def migration_iterator(self): + """Iterate over the library migration process.""" + try: + # Convert JSON Library to SQLite + yield "Creating SQL Database Tables..." + self.sql_lib = SqliteLibrary() + self.temp_path: Path = ( + self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite" + ) + self.sql_lib.storage_path = self.temp_path + 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 + ) + yield f"Migrating {len(self.json_lib.entries):,d} File Entries..." + self.sql_lib.migrate_json_to_sqlite(self.json_lib) + yield "Checking for Parity..." + check_set = set() + check_set.add(self.check_field_parity()) + check_set.add(self.check_path_parity()) + check_set.add(self.check_shorthand_parity()) + 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 "Migration Complete!" + else: + yield "Migration Complete, Discrepancies Found" + self.done = True + + except Exception as e: + yield f"Error: {type(e).__name__}" + self.done = True + + def update_parity_ui(self): + """Update all parity values UI.""" + 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.aliases_row, self.alias_parity) + self.update_parity_value(self.colors_row, self.color_parity) + self.sql_lib.close() + + def update_sql_value_ui(self, show_msg_box: bool = True): + """Update the SQL value count UI.""" + self.update_sql_value( + self.entries_row, + self.sql_lib.entries_count, + self.old_entry_count, + ) + self.update_sql_value( + self.tags_row, + len(self.sql_lib.tags), + self.old_tag_count, + ) + self.update_sql_value( + self.ext_row, + len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)), + self.old_ext_count, + ) + self.update_sql_value( + self.ext_type_row, + self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST), + self.old_ext_type, + ) + logger.info("Parity check complete!") + if self.discrepancies: + logger.warning("Discrepancies found:") + logger.warning("\n".join(self.discrepancies)) + QApplication.beep() + if not show_msg_box: + return + msg_box = QMessageBox() + msg_box.setWindowTitle("Library Discrepancies Found") + msg_box.setText( + "Discrepancies were found between the original and converted library formats. " + "Please review and choose to whether continue with the migration or to cancel." + ) + msg_box.setDetailedText("\n".join(self.discrepancies)) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + def finish_migration(self): + """Finish the migration upon user approval.""" + final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME + if self.temp_path.exists(): + self.temp_path.rename(final_name) + + def update_json_entry_count(self, value: int): + self.old_entry_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.entries_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_tag_count(self, value: int): + self.old_tag_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_ext_count(self, value: int): + self.old_ext_count = value + label: QLabel = self.old_content_layout.itemAtPosition(self.ext_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_json_ext_type(self, value: bool): + self.old_ext_type = value + label: QLabel = self.old_content_layout.itemAtPosition(self.ext_type_row, 1).widget() # type:ignore + label.setText(self.color_value_default(value)) + + def update_sql_value(self, row: int, value: int | bool, old_value: int | bool): + label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore + warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + label.setText(self.color_value_conditional(old_value, value)) + warning_icon.setText("" if old_value == value else self.warning) + + def update_parity_value(self, row: int, value: bool): + result: str = self.match_text if value else self.differ_text + old_label: QLabel = self.old_content_layout.itemAtPosition(row, 1).widget() # type:ignore + new_label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore + old_warning_icon: QLabel = self.old_content_layout.itemAtPosition(row, 2).widget() # type:ignore + new_warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore + old_label.setText(self.color_value_conditional(self.match_text, result)) + new_label.setText(self.color_value_conditional(self.match_text, result)) + old_warning_icon.setText("" if value else self.warning) + new_warning_icon.setText("" if value else self.warning) + + def color_value_default(self, value: int) -> str: + """Apply the default color to a value.""" + return str(f"{value}") + + def color_value_conditional(self, old_value: int | str, new_value: int | str) -> str: + """Apply a conditional color to a value.""" + red: str = "#e22c3c" + green: str = "#28bb48" + color = green if old_value == new_value else red + return str(f"{new_value}") + + def check_field_parity(self) -> bool: + """Check if all JSON field data matches the new SQL field 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_json_field(value): + if isinstance(value, list): + return set(value) if value else None + 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] = [] + + sql_entry: Entry = session.scalar( + select(Entry).where(Entry.id == 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 + + for sf in sql_entry.fields: + sql_fields.append( + ( + sql_entry.id, + sf.type.key, + sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key), + ) + ) + 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]) + + 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)) + + 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)]), + ) + + self.field_parity = True + return self.field_parity + + def check_path_parity(self) -> bool: + """Check if all JSON file paths match the new SQL paths.""" + with Session(self.sql_lib.engine) as session: + json_paths: list = sorted([x.path / x.filename for x in self.json_lib.entries]) + sql_paths: list = sorted(list(session.scalars(select(Entry.path)))) + self.path_parity = ( + json_paths is not None and sql_paths is not None and (json_paths == sql_paths) + ) + 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 + + 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)) + ) + # 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]) + ) + + logger.info( + "[Subtag Parity]", + tag_id=tag_id, + json_subtags=json_subtags, + sql_subtags=sql_subtags, + ) + + if not ( + sql_subtags is not None + and json_subtags is not None + and (sql_subtags == json_subtags) + ): + self.discrepancies.append( + f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}" + ) + self.subtag_parity = False + return self.subtag_parity + + self.subtag_parity = True + return self.subtag_parity + + def check_ext_type(self) -> bool: + return self.json_lib.is_exclude_list == self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST) + + def check_alias_parity(self) -> bool: + """Check if all JSON aliases match the new SQL aliases.""" + sql_aliases: set[str] = None + json_aliases: set[str] = 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_aliases = set( + session.scalars(select(TagAlias.name).where(TagAlias.tag_id == tag.id)) + ) + json_aliases = set(self.json_lib.get_tag(tag_id).aliases) + + logger.info( + "[Alias Parity]", + tag_id=tag_id, + json_aliases=json_aliases, + sql_aliases=sql_aliases, + ) + if not ( + sql_aliases is not None + and json_aliases is not None + and (sql_aliases == json_aliases) + ): + self.discrepancies.append( + f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}" + ) + self.alias_parity = False + return self.alias_parity + + self.alias_parity = True + return self.alias_parity + + def check_shorthand_parity(self) -> bool: + """Check if all JSON shorthands match the new SQL shorthands.""" + sql_shorthand: str = None + json_shorthand: str = 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 + + logger.info( + "[Shorthand Parity]", + tag_id=tag_id, + json_shorthand=json_shorthand, + sql_shorthand=sql_shorthand, + ) + + if not ( + sql_shorthand is not None + and json_shorthand is not None + and (sql_shorthand == json_shorthand) + ): + self.discrepancies.append( + f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}" + ) + self.shorthand_parity = False + return self.shorthand_parity + + self.shorthand_parity = True + return self.shorthand_parity + + def check_color_parity(self) -> bool: + """Check if all JSON tag colors match the new SQL tag colors.""" + sql_color: str = None + json_color: str = None + + for tag in self.sql_lib.tags: + tag_id = tag.id # Tag IDs start at 0 + 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 != "" + else TagColor.DEFAULT.name + ) + + logger.info( + "[Color Parity]", + tag_id=tag_id, + json_color=json_color, + sql_color=sql_color, + ) + + 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}" + ) + self.color_parity = False + return self.color_parity + + self.color_parity = True + return self.color_parity diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py b/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py new file mode 100644 index 000000000..7e2e87f2e --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_body_wrapper.py @@ -0,0 +1,20 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QVBoxLayout, + QWidget, +) + + +class PagedBodyWrapper(QWidget): + """A state object for paged panels.""" + + def __init__(self): + super().__init__() + layout: QVBoxLayout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_panel.py b/tagstudio/src/qt/widgets/paged_panel/paged_panel.py new file mode 100644 index 000000000..de84d0784 --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_panel.py @@ -0,0 +1,112 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) +from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState + +logger = structlog.get_logger(__name__) + + +class PagedPanel(QWidget): + """A paginated modal panel.""" + + def __init__(self, size: tuple[int, int], stack: list[PagedPanelState]): + super().__init__() + + self._stack: list[PagedPanelState] = stack + self._index: int = 0 + + self.setMinimumSize(*size) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.root_layout = QVBoxLayout(self) + self.root_layout.setObjectName("baseLayout") + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.root_layout.setContentsMargins(0, 0, 0, 0) + + self.content_container = QWidget() + self.content_layout = QVBoxLayout(self.content_container) + self.content_layout.setContentsMargins(12, 12, 12, 12) + + self.title_label = QLabel() + self.title_label.setObjectName("fieldTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.body_container = QWidget() + self.body_container.setObjectName("bodyContainer") + self.body_layout = QVBoxLayout(self.body_container) + self.body_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.body_layout.setContentsMargins(0, 0, 0, 0) + self.body_layout.setSpacing(0) + + self.button_nav_container = QWidget() + self.button_nav_layout = QHBoxLayout(self.button_nav_container) + + self.root_layout.addWidget(self.content_container) + self.content_layout.addWidget(self.title_label) + self.content_layout.addWidget(self.body_container) + self.content_layout.addStretch(1) + self.root_layout.addWidget(self.button_nav_container) + + self.init_connections() + self.update_frame() + + def init_connections(self): + """Initialize button navigation connections.""" + for frame in self._stack: + for button in frame.connect_to_back: + button.clicked.connect(self.back) + for button in frame.connect_to_next: + button.clicked.connect(self.next) + + def back(self): + """Navigate backward in the state stack. Close if out of bounds.""" + if self._index > 0: + self._index = self._index - 1 + self.update_frame() + else: + self.close() + + def next(self): + """Navigate forward in the state stack. Close if out of bounds.""" + if self._index < len(self._stack) - 1: + self._index = self._index + 1 + self.update_frame() + else: + self.close() + + def update_frame(self): + """Update the widgets with the current frame's content.""" + frame: PagedPanelState = self._stack[self._index] + + # Update Title + self.setWindowTitle(frame.title) + self.title_label.setText(f"

{frame.title}

") + + # Update Body Widget + if self.body_layout.itemAt(0): + self.body_layout.itemAt(0).widget().setHidden(True) + self.body_layout.removeWidget(self.body_layout.itemAt(0).widget()) + self.body_layout.addWidget(frame.body_wrapper) + self.body_layout.itemAt(0).widget().setHidden(False) + + # Update Button Widgets + while self.button_nav_layout.count(): + if _ := self.button_nav_layout.takeAt(0).widget(): + _.setHidden(True) + + for item in frame.buttons: + if isinstance(item, QWidget): + self.button_nav_layout.addWidget(item) + item.setHidden(False) + elif isinstance(item, int): + self.button_nav_layout.addStretch(item) diff --git a/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py b/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py new file mode 100644 index 000000000..849dcbc77 --- /dev/null +++ b/tagstudio/src/qt/widgets/paged_panel/paged_panel_state.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from PySide6.QtWidgets import QPushButton +from src.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper + + +class PagedPanelState: + """A state object for paged panels.""" + + def __init__( + self, + title: str, + body_wrapper: PagedBodyWrapper, + buttons: list[QPushButton | int], + connect_to_back=list[QPushButton], + connect_to_next=list[QPushButton], + ): + self.title: str = title + self.body_wrapper: PagedBodyWrapper = body_wrapper + self.buttons: list[QPushButton | int] = buttons + self.connect_to_back: list[QPushButton] = connect_to_back + self.connect_to_next: list[QPushButton] = connect_to_next diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index e74d2dc75..9b0246ff1 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -878,10 +878,6 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): logger.error("Failed to disconnect inner_container.updated") else: - logger.info( - "inner_container is not instance of TagBoxWidget", - container=inner_container, - ) inner_container = TagBoxWidget( field, title, diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 653d3c0e7..c4496e62c 100755 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -89,12 +89,13 @@ def set_field(self, field: TagBoxField): self.field = field 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): self.base_layout.takeAt(0).widget().deleteLater() is_recycled = True - for tag in tags: + for tag in tags_: tag_widget = TagWidget(tag, has_edit=True, has_remove=True) tag_widget.on_click.connect( lambda tag_id=tag.id: ( diff --git a/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json b/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json new file mode 100644 index 000000000..7a1adf941 --- /dev/null +++ b/tagstudio/tests/fixtures/json_library/.TagStudio/ts_library.json @@ -0,0 +1 @@ +{"ts-version":"9.4.1","ext_list":[".json",".xmp",".aae",".rar",".sqlite"],"is_exclude_list":true,"tags":[{"id":0,"name":"Archived JSON","aliases":["Archive"],"color":"Red"},{"id":1,"name":"Favorite JSON","aliases":["Favorited","Favorites"],"color":"Orange"},{"id":1000,"name":"Parent","aliases":[""],"subtag_ids":[1000]},{"id":1001,"name":"Default","aliases":[""]},{"id":1002,"name":"Black","aliases":[""],"subtag_ids":[1001],"color":"black"},{"id":1003,"name":"Dark Gray","aliases":[""],"color":"dark gray"},{"id":1004,"name":"Gray","aliases":["Grey"],"color":"gray"},{"id":1005,"name":"Light Gray","shorthand":"LG","aliases":["Light Grey"],"color":"light gray"},{"id":1006,"name":"White","aliases":[""],"color":"white"},{"id":1007,"name":"Light Pink","shorthand":"LP","aliases":[""],"color":"light pink"},{"id":1008,"name":"Pink","aliases":[""],"color":"pink"},{"id":1009,"name":"Red","aliases":[""],"color":"red"},{"id":1010,"name":"Red Orange","aliases":["𝓗𝓮𝓵𝓵𝓸"],"subtag_ids":[1009,1011],"color":"red orange"},{"id":1011,"name":"Orange","aliases":["Alias with a slash n newline\\nHopefully this isn't on a newline","Alias with \\\\ double backslashes","Alias with / a forward slash"],"color":"orange"},{"id":1012,"name":"Yellow Orange","shorthand":"YO","aliases":[""],"subtag_ids":[1013,1011],"color":"yellow orange"},{"id":1013,"name":"Yellow","aliases":[""],"color":"yellow"},{"id":1014,"name":"Lime","aliases":[""],"color":"lime"},{"id":1015,"name":"Light Green","shorthand":"LG","aliases":[""],"color":"light green"},{"id":1016,"name":"Mint","aliases":[""],"color":"mint"},{"id":1017,"name":"Green","aliases":[""],"color":"green"},{"id":1018,"name":"Teal","aliases":[""],"color":"teal"},{"id":1019,"name":"Cyan","aliases":[""],"color":"cyan"},{"id":1020,"name":"Light Blue","shorthand":"LB","aliases":[""],"subtag_ids":[1021,1006],"color":"light blue"},{"id":1021,"name":"Blue","aliases":[""],"color":"blue"},{"id":1022,"name":"Blue Violet","shorthand":"BV","aliases":[""],"color":"blue violet"},{"id":1023,"name":"Violet","aliases":[""],"color":"violet"},{"id":1024,"name":"Purple","aliases":[""],"subtag_ids":[1009,1021],"color":"purple"},{"id":1025,"name":"Lavender","aliases":[""],"color":"lavender"},{"id":1026,"name":"Berry","aliases":[""],"color":"berry"},{"id":1027,"name":"Magenta","aliases":[""],"color":"magenta"},{"id":1028,"name":"Salmon","aliases":[""],"color":"salmon"},{"id":1029,"name":"Auburn","aliases":[""],"color":"auburn"},{"id":1030,"name":"Dark Brown","aliases":[""],"color":"dark brown"},{"id":1031,"name":"Brown","aliases":[""],"color":"brown"},{"id":1032,"name":"Light Brown","aliases":[""],"color":"light brown"},{"id":1033,"name":"Blonde","aliases":[""],"color":"blonde"},{"id":1034,"name":"Peach","aliases":[""],"color":"peach"},{"id":1035,"name":"Warm Gray","aliases":["Warm Grey"],"color":"warm gray"},{"id":1036,"name":"Cool Gray","aliases":["Cool Grey"],"color":"cool gray"},{"id":1037,"name":"Olive","aliases":["Olive Drab"],"color":"olive"},{"id":1038,"name":"Duplicate","aliases":[""],"color":"white"},{"id":1039,"name":"Duplicate","aliases":[""],"color":"black"},{"id":1040,"name":"Duplicate","aliases":[""],"color":"gray"},{"id":1041,"name":"Child","aliases":[""],"subtag_ids":[1000]},{"id":2000,"name":"Jump to ID 2000","shorthand":"2000","aliases":[""]}],"collations":[],"fields":[],"macros":[],"entries":[{"id":0,"filename":"Sway.mobi","path":"windows_folder\\Books","fields":[{"8":[]},{"6":[]},{"7":[]},{"0":""},{"2":""},{"1":""},{"3":""},{"4":""},{"5":""},{"27":""},{"28":""},{"29":""},{"30":""}]},{"id":1,"filename":"Around the World in 28 Languages.mobi","path":"posix_folder\\Books","fields":[{"0":"This is a title"},{"6":[1000]},{"8":[1]},{"0":"Title 2 🂹"},{"0":"B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿B̽̾̿"}]},{"id":2,"filename":"sample.epub","path":"Books","fields":[{"0":"Test\\nDon't newline"},{"1":"Test\\tDon't tab"}]},{"id":3,"filename":"sample - Copy (15).odt","path":"OpenDocument","fields":[{"0":"🈩"},{"1":"𝓅𝓇𝑒𝓉𝓉𝓎"},{"0":"⒲⒪⒭⒦"},{"0":"ʤʤʤʤʤʤʤʤʤʤʤʤ"},{"0":"ဪ"}]},{"id":4,"filename":"sample - Copy (14).odt","path":"OpenDocument","fields":[{"0":"مرحباً بالفوكسل السماوي"},{"4":"مرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي\nمرحباً بالفوكسل السماوي"}]},{"id":5,"filename":"sample - Copy (13).odt","path":"OpenDocument","fields":[{"4":"Всім привіт, сьогодні ми проводимо тест tagstudio"}]},{"id":6,"filename":"sample - Copy (12).odt","path":"OpenDocument","fields":[{"1":"𝓗𝓮𝓵𝓵𝓸 𝓮𝓿𝓮𝓻𝔂𝓫𝓸𝓭𝔂 𝓽𝓸𝓭𝓪𝔂 𝔀𝓮 𝓪𝓻𝓮 𝓭𝓸𝓲𝓷𝓰 𝓪 𝓽𝓪𝓰𝓼𝓽𝓾𝓭𝓲𝓸 𝓽𝓮𝓼𝓽"}]},{"id":7,"filename":"sample - Copy (11).odt","path":"OpenDocument","fields":[{"0":"なこに (nakonicafe)"},{"0":"☠ jared ☠"},{"4":"𝓗𝓮𝓵𝓵𝓸 𝓮𝓿𝓮𝓻𝔂𝓫𝓸𝓭𝔂 𝓽𝓸𝓭𝓪𝔂 𝔀𝓮 𝓪𝓻𝓮 𝓭𝓸𝓲𝓷𝓰 𝓪 𝓽𝓪𝓰𝓼𝓽𝓾𝓭𝓲𝓸 𝓽𝓮𝓼𝓽"},{"4":"☠ jared ☠"}]},{"id":8,"filename":"sample - Copy (10).odt","path":"OpenDocument","fields":[{"8":[0]},{"0":"This is a title underneath a Meta Tags"},{"4":"This is a Description underneath a Title"},{"7":[1021,1022,1023]}]},{"id":9,"filename":"sample - Copy (9).odt","path":"OpenDocument","fields":[{"8":[1]},{"8":[]}]},{"id":10,"filename":"sample - Copy (8).odt","path":"OpenDocument","fields":[{"8":[1]},{"7":[]},{"7":[]}]},{"id":20,"filename":"9lfqvtp2.bmp","path":".","fields":[{"0":"This is a Title"},{"2":"This is an Artist"},{"3":"This is a URL"},{"4":"This is line 1 of 2 of this Description.\nThis is line 2 of 2 of this Description."},{"5":"This is line 1 of 2 of these Notes.\nThis is line 2 of 2 of these Notes."},{"6":[1021,1009,1013]},{"7":[1022,1010,1012]},{"8":[0,1]},{"21":"This is a Source"},{"27":"This is a Publisher"},{"1":"This is an Author"},{"28":"This is a Guest Artist"},{"29":"This is a Composer"},{"30":"This is line 1 of 20 of a comments box.\nThis is line 2 of 20 of a comments box.\nThis is line 3 of 20 of a comments box.\nThis is line 4 of 20 of a comments box.\nThis is line 5 of 20 of a comments box.\nThis is line 6 of 20 of a comments box.\nThis is line 7 of 20 of a comments box.\nThis is line 8 of 20 of a comments box.\nThis is line 9 of 20 of a comments box.\nThis is line 10 of 20 of a comments box.\nThis is line 11 of 20 of a comments box.\nThis is line 12 of 20 of a comments box.\nThis is line 13 of 20 of a comments box.\nThis is line 14 of 20 of a comments box.\nThis is line 15 of 20 of a comments box.\nThis is line 16 of 20 of a comments box.\nThis is line 17 of 20 of a comments box.\nThis is line 18 of 20 of a comments box.\nThis is line 19 of 20 of a comments box.\nThis is line 20 of 20 of a comments box."}]},{"id":25,"filename":"u6wt6d6o.bmp","path":".","fields":[{"4":"This is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThis is a description box.\nThank you."},{"1":"Author, for the heck of it."}]},{"id":30,"filename":"empty.png","path":"."}]} \ No newline at end of file diff --git a/tagstudio/tests/test_json_migration.py b/tagstudio/tests/test_json_migration.py new file mode 100644 index 000000000..c8ad58e6c --- /dev/null +++ b/tagstudio/tests/test_json_migration.py @@ -0,0 +1,49 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import pathlib +from time import time + +from src.core.enums import LibraryPrefs +from src.qt.widgets.migration_modal import JsonMigrationModal + +CWD = pathlib.Path(__file__) + + +def test_json_migration(): + modal = JsonMigrationModal(CWD.parent / "fixtures" / "json_library") + modal.migrate(skip_ui=True) + + start = time() + while not modal.done and (time() - start < 60): + pass + + # Entries ================================================================== + # Count + assert len(modal.json_lib.entries) == modal.sql_lib.entries_count + # Path Parity + assert modal.check_path_parity() + # Field Parity + assert modal.check_field_parity() + + # Tags ===================================================================== + # Count + assert len(modal.json_lib.tags) == len(modal.sql_lib.tags) + # Shorthand Parity + assert modal.check_shorthand_parity() + # Subtag/Parent Tag Parity + assert modal.check_subtag_parity() + # Alias Parity + assert modal.check_alias_parity() + # Color Parity + assert modal.check_color_parity() + + # Extension Filter List ==================================================== + # Count + assert len(modal.json_lib.ext_list) == len(modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)) + # List Type + assert modal.check_ext_type() + # No Leading Dot + for ext in modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST): + assert ext[0] != "." diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index 8175c4962..26657e9fe 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -85,7 +85,7 @@ def test_library_add_file(library): def test_create_tag(library, generate_tag): # tag already exists - assert not library.add_tag(generate_tag("foo")) + assert not library.add_tag(generate_tag("foo", id=1000)) # new tag name tag = library.add_tag(generate_tag("xxx", id=123)) @@ -98,7 +98,7 @@ def test_create_tag(library, generate_tag): def test_tag_subtag_itself(library, generate_tag): # tag already exists - assert not library.add_tag(generate_tag("foo")) + assert not library.add_tag(generate_tag("foo", id=1000)) # new tag name tag = library.add_tag(generate_tag("xxx", id=123)) @@ -132,19 +132,13 @@ def test_library_search(library, generate_tag, entry_full): def test_tag_search(library): tag = library.tags[0] - assert library.search_tags( - FilterState(tag=tag.name.lower()), - ) + assert library.search_tags(tag.name.lower()) - assert library.search_tags( - FilterState(tag=tag.name.upper()), - ) + assert library.search_tags(tag.name.upper()) - assert library.search_tags(FilterState(tag=tag.name[2:-2])) + assert library.search_tags(tag.name[2:-2]) - assert not library.search_tags( - FilterState(tag=tag.name * 2), - ) + assert not library.search_tags(tag.name * 2) def test_get_entry(library, entry_min):