Merge branch 'main' into query-lang

This commit is contained in:
Jann Stute
2024-11-30 22:15:26 +01:00
28 changed files with 1605 additions and 191 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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: <query>`)
- 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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): ...

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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: (

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}'"

View File

@@ -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):

View File

@@ -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}")

View File

@@ -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 = "<b><a style='color: #e22c3c'>(!)</a></b>"
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 <b>9.4 and below</b> will "
"need to be migrated to the new <b>v9.5+</b> format."
"<br>"
"<h2>What you need to know:</h2>"
"<ul>"
"<li>Your existing library save file will <b><i>NOT</i></b> be deleted</li>"
"<li>Your personal files will <b><i>NOT</i></b> be deleted, moved, or modified</li>"
"<li>The new v9.5+ save format can not be opened in earlier versions of TagStudio</li>"
"</ul>"
)
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 = (
"<br>Start and preview the results of the library migration process. "
'The converted library will <i>not</i> be used unless you click "Finish Migration". '
"<br><br>"
'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 "<b>(!)</b>" '
"symbol next to them."
"<br><center><i>"
"This process may take up to several minutes for larger libraries."
"</i></center>"
)
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("<h2>v9.4 Library</h2>")
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("<h2>v9.5+ Library</h2>")
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"<h4>{x}</h4>"),
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"<b><a style='color: #3b87f0'>{value}</a></b>")
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"<b><a style='color: {color}'>{new_value}</a></b>")
def check_field_parity(self) -> bool:
"""Check if all JSON field data matches the new SQL field data."""
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

View File

@@ -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)

View File

@@ -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"<h1>{frame.title}</h1>")
# 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)

View File

@@ -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

View File

@@ -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"<b>{len(self.driver.selected)}</b> 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,

View File

@@ -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: (

View File

@@ -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()

File diff suppressed because one or more lines are too long

View File

@@ -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")]

View File

@@ -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] != "."

View File

@@ -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))