diff --git a/.gitignore b/.gitignore index ad3495d8..595a3290 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ -tagstudio/tests/fixtures/library/* # Translations *.mo @@ -255,11 +254,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/docs/index.md b/docs/index.md index 0337398e..6558be1b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,9 +45,3 @@ The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features - Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from. - Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `) - Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`. - -## Important Updates - -### [Database Migration](updates/db_migration.md) - -The "Database Migration", "DB Migration", or "SQLite Migration" is an upcoming update to TagStudio which will replace the current JSON [library](library/index.md) with a SQL-based one, and will additionally include some fundamental changes to how some features such as [tags](library/tag.md) will work. diff --git a/docs/library/tag_overrides.md b/docs/library/tag_overrides.md index ee9f6cec..b1f798bd 100644 --- a/docs/library/tag_overrides.md +++ b/docs/library/tag_overrides.md @@ -5,7 +5,7 @@ tags: # Tag Overrides -Tag overrides are the ability to add or remove [parent tags](tag.md#subtags) from a [tag](tag.md) on a per- [entry](entry.md) basis. Relies on the [Database Migration](../updates/db_migration.md) update being complete. +Tag overrides are the ability to add or remove [parent tags](tag.md#subtags) from a [tag](tag.md) on a per- [entry](entry.md) basis. ## Examples diff --git a/docs/updates/db_migration.md b/docs/updates/db_migration.md deleted file mode 100644 index 4e2a7abb..00000000 --- a/docs/updates/db_migration.md +++ /dev/null @@ -1,43 +0,0 @@ -# Database Migration - -The database migration is an upcoming refactor to TagStudio's library data storage system. The database will be migrated from a JSON-based one to a SQLite-based one. Part of this migration will include a reworked schema, which will allow for several new features and changes to how [tags](../library/tag.md) and [fields](../library/field.md) operate. - -## Schema - -![Database Schema](../assets/db_schema.png){ width="600" } - -### `alias` Table - -_Description TBA_ - -### `entry` Table - -_Description TBA_ - -### `entry_attribute` Table - -_Description TBA_ - -### `entry_page` Table - -_Description TBA_ - -### `location` Table - -_Description TBA_ - -### `tag` Table - -_Description TBA_ - -### `tag_relation` Table - -_Description TBA_ - -## Resulting New Features and Changes - -- Multiple Directory Support -- [Tag Categories](../library/tag_categories.md) (Replaces [Tag Fields](../library/field.md#tag_box)) -- [Tag Overrides](../library/tag_overrides.md) -- User-Defined [Fields](../library/field.md) -- Tag Icons diff --git a/tagstudio/src/core/exceptions.py b/tagstudio/src/core/exceptions.py new file mode 100644 index 00000000..10bec533 --- /dev/null +++ b/tagstudio/src/core/exceptions.py @@ -0,0 +1,6 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +class NoRendererError(Exception): ... diff --git a/tagstudio/src/core/library/alchemy/enums.py b/tagstudio/src/core/library/alchemy/enums.py index 1e2b69a9..0036fbbd 100644 --- a/tagstudio/src/core/library/alchemy/enums.py +++ b/tagstudio/src/core/library/alchemy/enums.py @@ -45,6 +45,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 ItemType(enum.Enum): ENTRY = 0 diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 17fc3492..049d9d9e 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 typing import Any, Iterator, Type 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 @@ from sqlalchemy.orm import ( 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: @@ -131,8 +136,10 @@ class Library: storage_path: Path | str | None engine: Engine | None folder: Folder | None + included_files: set[Path] = set() - FILENAME: str = "ts_library.sqlite" + SQL_FILENAME: str = "ts_library.sqlite" + JSON_FILENAME: str = "ts_library.json" def close(self): if self.engine: @@ -140,33 +147,121 @@ class Library: self.library_dir = None self.storage_path = None self.folder = None + self.included_files = set() + + 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 +312,6 @@ class Library: db_version=db_version.value, expected=LibraryPrefs.DB_VERSION.default, ) - # TODO - handle migration return LibraryStatus( success=False, message=( @@ -375,8 +469,12 @@ class Library: 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.") @@ -387,6 +485,8 @@ class Library: 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.""" @@ -492,12 +592,14 @@ class Library: 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 name: query = query.where( @@ -676,7 +778,7 @@ class Library: *, 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", @@ -709,8 +811,11 @@ class Library: 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( @@ -742,6 +847,28 @@ class Library: ) 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, @@ -834,7 +961,7 @@ class Library: 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, ) @@ -861,15 +988,15 @@ class Library: 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: @@ -881,6 +1008,22 @@ class Library: 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 734c6823..8da0c470 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 @@ class Tag(Base): 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 @@ class Entry(Base): 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 0570c2f3..56203196 100644 --- a/tagstudio/src/core/library/json/library.py +++ b/tagstudio/src/core/library/json/library.py @@ -414,17 +414,17 @@ class Library: """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 @@ class Library: 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 @@ class Library: 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/core/utils/refresh_dir.py b/tagstudio/src/core/utils/refresh_dir.py index 87b734ea..11980329 100644 --- a/tagstudio/src/core/utils/refresh_dir.py +++ b/tagstudio/src/core/utils/refresh_dir.py @@ -9,6 +9,19 @@ from src.core.library import Entry, Library logger = structlog.get_logger(__name__) +GLOBAL_IGNORE_SET: set[str] = set( + [ + TS_FOLDER_NAME, + "$RECYCLE.BIN", + ".Trashes", + ".Trash", + "tagstudio_thumbs", + ".fseventsd", + ".Spotlight-V100", + "System Volume Information", + ] +) + @dataclass class RefreshDirTracker: @@ -49,29 +62,45 @@ class RefreshDirTracker: self.files_not_in_library = [] dir_file_count = 0 - for path in lib_path.glob("**/*"): - str_path = str(path) - if path.is_dir(): + for f in lib_path.glob("**/*"): + end_time_loop = time() + # Yield output every 1/30 of a second + if (end_time_loop - start_time_loop) > 0.034: + yield dir_file_count + start_time_loop = time() + + # Skip if the file/path is already mapped in the Library + if f in self.library.included_files: + dir_file_count += 1 continue - if "$RECYCLE.BIN" in str_path or TS_FOLDER_NAME in str_path: + # Ignore if the file is a directory + if f.is_dir(): + continue + + # Ensure new file isn't in a globally ignored folder + skip: bool = False + for part in f.parts: + if part in GLOBAL_IGNORE_SET: + skip = True + break + if skip: continue dir_file_count += 1 - relative_path = path.relative_to(lib_path) + self.library.included_files.add(f) + + relative_path = f.relative_to(lib_path) # TODO - load these in batch somehow if not self.library.has_path_entry(relative_path): self.files_not_in_library.append(relative_path) - # Yield output every 1/30 of a second - if (time() - start_time_loop) > 0.034: - yield dir_file_count - start_time_loop = time() - end_time_total = time() + yield dir_file_count logger.info( "Directory scan time", path=lib_path, duration=(end_time_total - start_time_total), - new_files_count=dir_file_count, + files_not_in_lib=self.files_not_in_library, + files_scanned=dir_file_count, ) diff --git a/tagstudio/src/qt/helpers/file_opener.py b/tagstudio/src/qt/helpers/file_opener.py index 0672ed84..f696d416 100644 --- a/tagstudio/src/qt/helpers/file_opener.py +++ b/tagstudio/src/qt/helpers/file_opener.py @@ -19,9 +19,9 @@ def open_file(path: str | Path, file_manager: bool = False): """Open a file in the default application or file explorer. Args: - path (str): The path to the file to open. - file_manager (bool, optional): Whether to open the file in the file manager - (e.g. Finder on macOS). Defaults to False. + path (str): The path to the file to open. + file_manager (bool, optional): Whether to open the file in the file manager + (e.g. Finder on macOS). Defaults to False. """ path = Path(path) logger.info("Opening file", path=path) @@ -31,10 +31,11 @@ def open_file(path: str | Path, file_manager: bool = False): try: if sys.platform == "win32": - normpath = Path(path).resolve().as_posix() + normpath = str(Path(path).resolve()) if file_manager: command_name = "explorer" - command_arg = '/select,"' + normpath + '"' + command_arg = f'/select,"{normpath}"' + # For some reason, if the args are passed in a list, this will error when the # path has spaces, even while surrounded in double quotes. subprocess.Popen( @@ -92,7 +93,7 @@ class FileOpenerHelper: """Initialize the FileOpenerHelper. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -100,7 +101,7 @@ class FileOpenerHelper: """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = str(filepath) @@ -114,20 +115,19 @@ class FileOpenerHelper: class FileOpenerLabel(QLabel): - def __init__(self, text, parent=None): + def __init__(self, parent=None): """Initialize the FileOpenerLabel. Args: - text (str): The text to display. - parent (QWidget, optional): The parent widget. Defaults to None. + parent (QWidget, optional): The parent widget. Defaults to None. """ - super().__init__(text, parent) + super().__init__(parent) def set_file_path(self, filepath): """Set the filepath to open. Args: - filepath (str): The path to the file to open. + filepath (str): The path to the file to open. """ self.filepath = filepath @@ -138,7 +138,7 @@ class FileOpenerLabel(QLabel): On a right click, show a context menu. Args: - event (QMouseEvent): The mouse press event. + event (QMouseEvent): The mouse press event. """ super().mousePressEvent(event) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 1ed8821d..fcb786ae 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -203,7 +203,7 @@ class BuildTagPanel(PanelWidget): self.color_field.setMaxVisibleItems(10) self.color_field.setStyleSheet("combobox-popup:0;") for color in TagColor: - self.color_field.addItem(color.name, userData=color.value) + self.color_field.addItem(color.name.replace("_", " ").title(), userData=color.value) # self.color_field.setProperty("appearance", "flat") self.color_field.currentIndexChanged.connect( lambda c: ( diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index 1452e642..937b4fb2 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, @@ -16,6 +18,12 @@ 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) @@ -23,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) @@ -53,7 +61,6 @@ class TagDatabasePanel(PanelWidget): 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: @@ -66,6 +73,7 @@ class TagDatabasePanel(PanelWidget): 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() @@ -100,3 +108,9 @@ class TagDatabasePanel(PanelWidget): 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 943d7589..adcfb7d0 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -7,6 +7,7 @@ import math import structlog from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -31,8 +32,8 @@ class TagSearchPanel(PanelWidget): 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) @@ -60,11 +61,9 @@ class TagSearchPanel(PanelWidget): 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,6 +72,7 @@ class TagSearchPanel(PanelWidget): self.parentWidget().hide() 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() @@ -81,6 +81,7 @@ class TagSearchPanel(PanelWidget): 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) @@ -117,3 +118,9 @@ class TagSearchPanel(PanelWidget): 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 20fe4450..ffcd4c35 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -89,6 +89,7 @@ from src.qt.modals.folders_to_tags import FoldersToTagsModal 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 @@ -470,6 +471,7 @@ class QtDriver(DriverMixin, QObject): self.thumb_renderers: list[ThumbRenderer] = [] self.filter = FilterState.show_all() self.init_library_window() + self.migration_modal: JsonMigrationModal = None path_result = self.evaluate_path(self.args.open) # check status of library path evaluating @@ -808,6 +810,8 @@ class QtDriver(DriverMixin, QObject): 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, @@ -1032,25 +1036,41 @@ class QtDriver(DriverMixin, QObject): self.flow_container.layout().update() self.main_window.update() - for idx, (entry, item_thumb) in enumerate( - zip_longest(self.frame_content, self.item_thumbs) - ): + is_grid_thumb = True + # Show loading placeholder icons + for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs): if not entry: item_thumb.hide() continue - filepath = self.lib.library_dir / entry.path - item_thumb = self.item_thumbs[idx] item_thumb.set_mode(ItemType.ENTRY) item_thumb.set_item_id(entry) # TODO - show after item is rendered item_thumb.show() + is_loading = True self.thumb_job_queue.put( ( item_thumb.renderer.render, - (sys.float_info.max, "", base_size, ratio, True, True), + (sys.float_info.max, "", base_size, ratio, is_loading, is_grid_thumb), + ) + ) + + # Show rendered thumbnails + for idx, (entry, item_thumb) in enumerate( + zip_longest(self.frame_content, self.item_thumbs) + ): + 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), ) ) @@ -1162,14 +1182,27 @@ class QtDriver(DriverMixin, QObject): 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 @@ -1179,7 +1212,8 @@ class QtDriver(DriverMixin, QObject): self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) # TODO - make this call optional - self.add_new_files_callback() + if self.lib.entries_count < 10000: + self.add_new_files_callback() self.update_libs_list(path) title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 2fb8d7fb..dfcc89d0 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -113,8 +113,7 @@ class FieldContainer(QWidget): self.copy_callback = callback self.copy_button.clicked.connect(callback) - if callback is not None: - self.copy_button.is_connected = True + self.copy_button.is_connected = callable(callback) def set_edit_callback(self, callback: Callable): if self.edit_button.is_connected: @@ -122,8 +121,7 @@ class FieldContainer(QWidget): self.edit_callback = callback self.edit_button.clicked.connect(callback) - if callback is not None: - self.edit_button.is_connected = True + self.edit_button.is_connected = callable(callback) def set_remove_callback(self, callback: Callable): if self.remove_button.is_connected: @@ -131,11 +129,14 @@ class FieldContainer(QWidget): self.remove_callback = callback self.remove_button.clicked.connect(callback) - self.remove_button.is_connected = True + self.remove_button.is_connected = callable(callback) def set_inner_widget(self, widget: "FieldWidget"): if self.field_layout.itemAt(0): - self.field_layout.itemAt(0).widget().deleteLater() + old: QWidget = self.field_layout.itemAt(0).widget() + self.field_layout.removeWidget(old) + old.deleteLater() + self.field_layout.addWidget(widget) def get_inner_widget(self): diff --git a/tagstudio/src/qt/widgets/media_player.py b/tagstudio/src/qt/widgets/media_player.py new file mode 100644 index 00000000..f2bb6f37 --- /dev/null +++ b/tagstudio/src/qt/widgets/media_player.py @@ -0,0 +1,209 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import typing +from pathlib import Path +from time import gmtime +from typing import Any + +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer +from PySide6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSlider, + QWidget, +) + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + + +class MediaPlayer(QWidget): + """A basic media player widget. + + Gives a basic control set to manage media playback. + """ + + def __init__(self, driver: "QtDriver") -> None: + super().__init__() + self.driver = driver + + self.setFixedHeight(50) + + self.filepath: Path | None = None + self.player = QMediaPlayer() + self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) + + # Used to keep track of play state. + # It would be nice if we could use QMediaPlayer.PlaybackState, + # but this will always show StoppedState when changing + # tracks. Therefore, we wouldn't know if the previous + # state was paused or playing + self.is_paused = False + + # Subscribe to player events from MediaPlayer + self.player.positionChanged.connect(self.player_position_changed) + self.player.mediaStatusChanged.connect(self.media_status_changed) + self.player.playingChanged.connect(self.playing_changed) + self.player.audioOutput().mutedChanged.connect(self.muted_changed) + + # Media controls + self.base_layout = QGridLayout(self) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.base_layout.setSpacing(0) + + self.pslider = QSlider(self) + self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) + self.pslider.setSingleStep(1) + self.pslider.setOrientation(Qt.Orientation.Horizontal) + + self.pslider.sliderReleased.connect(self.slider_released) + self.pslider.valueChanged.connect(self.slider_value_changed) + + self.media_btns_layout = QHBoxLayout() + + policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self.play_pause = QPushButton("", self) + self.play_pause.setFlat(True) + self.play_pause.setSizePolicy(policy) + self.play_pause.clicked.connect(self.toggle_pause) + + self.load_play_pause_icon(playing=False) + + self.media_btns_layout.addWidget(self.play_pause) + + self.mute = QPushButton("", self) + self.mute.setFlat(True) + self.mute.setSizePolicy(policy) + self.mute.clicked.connect(self.toggle_mute) + + self.load_mute_unmute_icon(muted=False) + + self.media_btns_layout.addWidget(self.mute) + + self.position_label = QLabel("0:00") + self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.base_layout.addWidget(self.pslider, 0, 0, 1, 2) + self.base_layout.addLayout(self.media_btns_layout, 1, 0) + self.base_layout.addWidget(self.position_label, 1, 1) + + def format_time(self, ms: int) -> str: + """Format the given time. + + Formats the given time in ms to a nicer format. + + Args: + ms: Time in ms + + Returns: + A formatted time: + + "1:43" + + The formatted time will only include the hour if + the provided time is at least 60 minutes. + """ + time = gmtime(ms / 1000) + return ( + f"{time.tm_hour}:{time.tm_min}:{time.tm_sec:02}" + if time.tm_hour > 0 + else f"{time.tm_min}:{time.tm_sec:02}" + ) + + def toggle_pause(self) -> None: + """Toggle the pause state of the media.""" + if self.player.isPlaying(): + self.player.pause() + self.is_paused = True + else: + self.player.play() + self.is_paused = False + + def toggle_mute(self) -> None: + """Toggle the mute state of the media.""" + if self.player.audioOutput().isMuted(): + self.player.audioOutput().setMuted(False) + else: + self.player.audioOutput().setMuted(True) + + def playing_changed(self, playing: bool) -> None: + self.load_play_pause_icon(playing) + + def muted_changed(self, muted: bool) -> None: + self.load_mute_unmute_icon(muted) + + def stop(self) -> None: + """Clear the filepath and stop the player.""" + self.filepath = None + self.player.stop() + + def play(self, filepath: Path) -> None: + """Set the source of the QMediaPlayer and play.""" + self.filepath = filepath + if not self.is_paused: + self.player.stop() + self.player.setSource(QUrl.fromLocalFile(self.filepath)) + self.player.play() + else: + self.player.setSource(QUrl.fromLocalFile(self.filepath)) + + def load_play_pause_icon(self, playing: bool) -> None: + icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon + self.set_icon(self.play_pause, icon) + + def load_mute_unmute_icon(self, muted: bool) -> None: + icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon + self.set_icon(self.mute, icon) + + def set_icon(self, btn: QPushButton, icon: Any) -> None: + pix_map = QPixmap() + if pix_map.loadFromData(icon): + btn.setIcon(QIcon(pix_map)) + else: + logging.error("failed to load svg file") + + def slider_value_changed(self, value: int) -> None: + current = self.format_time(value) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") + + def slider_released(self) -> None: + was_playing = self.player.isPlaying() + self.player.setPosition(self.pslider.value()) + + # Setting position causes the player to start playing again. + # We should reset back to initial state. + if not was_playing: + self.player.pause() + + def player_position_changed(self, position: int) -> None: + if not self.pslider.isSliderDown(): + # User isn't using the slider, so update position in widgets. + self.pslider.setValue(position) + current = self.format_time(self.player.position()) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") + + if self.player.duration() == position: + self.player.pause() + self.player.setPosition(0) + + def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: + # We can only set the slider duration once we know the size of the media + if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None: + self.pslider.setMinimum(0) + self.pslider.setMaximum(self.player.duration()) + + current = self.format_time(self.player.position()) + duration = self.format_time(self.player.duration()) + self.position_label.setText(f"{current} / {duration}") diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py new file mode 100644 index 00000000..2af7d94a --- /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 00000000..7e2e87f2 --- /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 00000000..de84d078 --- /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 00000000..849dcbc7 --- /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 3bb15b7f..7e8e0c8b 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -53,6 +53,7 @@ from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from src.qt.modals.add_field import AddFieldModal from src.qt.platform_strings import PlatformStrings from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.media_player import MediaPlayer from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag_box import TagBoxWidget from src.qt.widgets.text import TextWidget @@ -154,6 +155,9 @@ class PreviewPanel(QWidget): ) ) + 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) @@ -161,23 +165,27 @@ class PreviewPanel(QWidget): image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) - self.file_label = FileOpenerLabel("filename") + 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(file_label_style) - self.date_created_label = QLabel("dateCreatedLabel") + 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(date_style) - self.date_modified_label = QLabel("dateModifiedLabel") + 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(date_style) - self.dimensions_label = QLabel("dimensionsLabel") + self.dimensions_label = QLabel() + self.dimensions_label.setObjectName("dimensionsLabel") self.dimensions_label.setWordWrap(True) self.dimensions_label.setStyleSheet(properties_style) @@ -258,6 +266,7 @@ class PreviewPanel(QWidget): ) splitter.addWidget(self.image_container) + splitter.addWidget(self.media_player) splitter.addWidget(info_section) splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) @@ -478,7 +487,7 @@ class PreviewPanel(QWidget): 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] + 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) @@ -536,6 +545,8 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.hide() + self.media_player.stop() self.preview_gif.hide() self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -567,6 +578,8 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.stop() + self.media_player.hide() self.preview_gif.hide() # If a new selection is made, update the thumbnail and filepath. @@ -651,6 +664,9 @@ class PreviewPanel(QWidget): rawpy._rawpy.LibRawFileUnsupportedError, ): pass + elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES): + self.media_player.show() + self.media_player.play(filepath) elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES ) and is_readable_video(filepath): @@ -757,6 +773,8 @@ class PreviewPanel(QWidget): self.preview_gif.hide() self.preview_vid.stop() self.preview_vid.hide() + self.media_player.stop() + self.media_player.hide() self.update_date_label() if self.selected != self.driver.selected: self.file_label.setText(f"{len(self.driver.selected)} Items Selected") @@ -854,10 +872,6 @@ class PreviewPanel(QWidget): else: container = self.containers[index] - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) - if isinstance(field, TagBoxField): container.set_title(field.type.name) container.set_inline(False) @@ -875,10 +889,6 @@ class PreviewPanel(QWidget): 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 d3b3c0bf..a1c7301b 100755 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -89,12 +89,13 @@ class TagBoxWidget(FieldWidget): 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/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index be5d8899..f78724ae 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -45,6 +45,7 @@ from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.exceptions import NoRendererError from src.core.media_types import MediaCategories, MediaType from src.core.palette import ColorType, UiColor, get_ui_color from src.core.utils.encoding import detect_char_encoding @@ -470,7 +471,7 @@ class ThumbRenderer(QObject): id3.ID3NoHeaderError, MutagenError, ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=e) + logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) return image def _audio_waveform_thumb( @@ -555,7 +556,7 @@ class ThumbRenderer(QObject): im.resize((size, size), Image.Resampling.BILINEAR) except exceptions.CouldntDecodeError as e: - logger.error("Couldn't render waveform", path=filepath.name, error=e) + logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) return im @@ -581,7 +582,6 @@ class ThumbRenderer(QObject): except ( AttributeError, UnidentifiedImageError, - FileNotFoundError, TypeError, ) as e: if str(e) == "expected string or buffer": @@ -591,7 +591,7 @@ class ThumbRenderer(QObject): ) else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _source_engine(self, filepath: Path) -> Image.Image: @@ -607,15 +607,14 @@ class ThumbRenderer(QObject): except ( AttributeError, UnidentifiedImageError, - FileNotFoundError, TypeError, struct.error, ) as e: if str(e) == "expected string or buffer": - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @classmethod @@ -661,7 +660,7 @@ class ThumbRenderer(QObject): image_data = zip_file.read(file_name) im = Image.open(BytesIO(image_data)) except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @@ -722,7 +721,7 @@ class ThumbRenderer(QObject): ) im = self._apply_overlay_color(bg, UiColor.PURPLE) except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: @@ -753,7 +752,7 @@ class ThumbRenderer(QObject): )[-1] im = theme_fg_overlay(bg, use_alpha=False) except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _image_raw_thumb(self, filepath: Path) -> Image.Image: @@ -772,13 +771,12 @@ class ThumbRenderer(QObject): rgb, decoder_name="raw", ) - except DecompressionBombError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) except ( + DecompressionBombError, rawpy._rawpy.LibRawIOError, rawpy._rawpy.LibRawFileUnsupportedError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _image_thumb(self, filepath: Path) -> Image.Image: @@ -796,13 +794,12 @@ class ThumbRenderer(QObject): new_bg = Image.new("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg - im = ImageOps.exif_transpose(im) except ( UnidentifiedImageError, DecompressionBombError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im @classmethod @@ -955,7 +952,7 @@ class ThumbRenderer(QObject): UnicodeDecodeError, OSError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def _video_thumb(self, filepath: Path) -> Image.Image: @@ -996,7 +993,7 @@ class ThumbRenderer(QObject): DecompressionBombError, OSError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im def render( @@ -1040,6 +1037,28 @@ class ThumbRenderer(QObject): "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio ) + def render_default() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name=self._get_resource_id(_filepath), + color=theme_color, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + return im + + def render_unlinked() -> Image.Image: + if update_on_ratio_change: + self.updated_ratio.emit(1) + im = self._get_icon( + name="broken_link_icon", + color=UiColor.RED, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + return im + if is_loading: final = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) qim = ImageQt.ImageQt(final) @@ -1049,6 +1068,9 @@ class ThumbRenderer(QObject): self.updated_ratio.emit(1) elif _filepath: try: + # Missing Files ================================================ + if not _filepath.exists(): + raise FileNotFoundError ext: str = _filepath.suffix.lower() # Images ======================================================= if MediaCategories.is_ext_in_category( @@ -1122,10 +1144,8 @@ class ThumbRenderer(QObject): ): image = self._source_engine(_filepath) # No Rendered Thumbnail ======================================== - if not _filepath.exists(): - raise FileNotFoundError - elif not image: - raise UnidentifiedImageError + if not image: + raise NoRendererError orig_x, orig_y = image.size new_x, new_y = (adj_size, adj_size) @@ -1161,32 +1181,19 @@ class ThumbRenderer(QObject): final = Image.new("RGBA", image.size, (0, 0, 0, 0)) final.paste(image, mask=mask.getchannel(0)) - except FileNotFoundError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = self._get_icon( - name="broken_link_icon", - color=UiColor.RED, - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) + except FileNotFoundError: + final = render_unlinked() except ( UnidentifiedImageError, DecompressionBombError, ValueError, ChildProcessError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + final = render_default() + except NoRendererError: + final = render_default() - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = self._get_icon( - name=self._get_resource_id(_filepath), - color=theme_color, - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) qim = ImageQt.ImageQt(final) if image: image.close() 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 00000000..7a1adf94 --- /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/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py index 4655d399..a4d3e808 100644 --- a/tagstudio/tests/macros/test_refresh_dir.py +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -15,10 +15,11 @@ def test_refresh_new_files(library, exclude_mode): library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode) library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"]) registry = RefreshDirTracker(library=library) + library.included_files.clear() (library.library_dir / "FOO.MD").touch() # When - assert not list(registry.refresh_dir(library.library_dir)) + assert len(list(registry.refresh_dir(library.library_dir))) == 1 # Then assert registry.files_not_in_library == [pathlib.Path("FOO.MD")] diff --git a/tagstudio/tests/test_json_migration.py b/tagstudio/tests/test_json_migration.py new file mode 100644 index 00000000..c8ad58e6 --- /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 f178b533..065fc0e5 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))