From e5e7b8afc62c1a958c172b35819caa26995f2060 Mon Sep 17 00:00:00 2001 From: yed Date: Mon, 9 Sep 2024 12:06:01 +0700 Subject: [PATCH] refactor!: use SQLite and SQLAlchemy for database backend (#332) * use sqlite + sqlalchemy as a database backend * change entries getter * page filterstate.page_size persistent * add test for entry.id filter * fix closing library * fix tag search, adding field * add field position * add fields reordering * use folder * take field position into consideration * fix adding tag * fix test * try to catch the correct exception, moron * dont expunge subtags * DRY models * rename LibraryField, add is_default property * remove field.position unique constraint --- .github/workflows/apprun.yaml | 51 - .github/workflows/mypy.yaml | 2 +- .github/workflows/pytest.yaml | 64 +- .github/workflows/ruff.yaml | 13 +- .pre-commit-config.yaml | 3 +- pyproject.toml | 28 +- requirements-dev.txt | 8 +- requirements.txt | 2 + tagstudio/src/cli/ts_cli.py | 3817 ----------------- tagstudio/src/core/constants.py | 52 +- tagstudio/src/core/enums.py | 26 +- tagstudio/src/core/json_typing.py | 42 - tagstudio/src/core/library/__init__.py | 1 + .../src/core/library/alchemy/__init__.py | 6 + tagstudio/src/core/library/alchemy/db.py | 48 + tagstudio/src/core/library/alchemy/enums.py | 140 + tagstudio/src/core/library/alchemy/fields.py | 176 + tagstudio/src/core/library/alchemy/joins.py | 20 + tagstudio/src/core/library/alchemy/library.py | 884 ++++ tagstudio/src/core/library/alchemy/models.py | 270 ++ tagstudio/src/core/library/json/__init__.py | 0 tagstudio/src/core/library/json/fields.py | 38 + .../src/core/{ => library/json}/library.py | 164 +- tagstudio/src/core/palette.py | 101 +- tagstudio/src/core/ts_core.py | 224 +- tagstudio/src/core/utils/dupe_files.py | 83 + tagstudio/src/core/utils/fs.py | 11 - tagstudio/src/core/utils/missing_files.py | 71 + tagstudio/src/core/utils/refresh_dir.py | 73 + tagstudio/src/qt/flowlayout.py | 48 +- tagstudio/src/qt/helpers/file_opener.py | 35 +- tagstudio/src/qt/helpers/function_iterator.py | 5 +- tagstudio/src/qt/main_window.py | 4 +- tagstudio/src/qt/modals/add_field.py | 43 +- tagstudio/src/qt/modals/build_tag.py | 140 +- tagstudio/src/qt/modals/delete_unlinked.py | 39 +- tagstudio/src/qt/modals/file_extension.py | 37 +- tagstudio/src/qt/modals/fix_dupes.py | 28 +- tagstudio/src/qt/modals/fix_unlinked.py | 138 +- tagstudio/src/qt/modals/folders_to_tags.py | 198 +- tagstudio/src/qt/modals/merge_dupe_entries.py | 8 +- tagstudio/src/qt/modals/mirror_entities.py | 54 +- tagstudio/src/qt/modals/relink_unlinked.py | 39 +- tagstudio/src/qt/modals/tag_database.py | 50 +- tagstudio/src/qt/modals/tag_search.py | 66 +- tagstudio/src/qt/pagination.py | 17 +- tagstudio/src/qt/resource_manager.py | 12 +- tagstudio/src/qt/ts_qt.py | 1265 ++---- tagstudio/src/qt/widgets/collage_icon.py | 80 +- tagstudio/src/qt/widgets/fields.py | 21 +- tagstudio/src/qt/widgets/item_thumb.py | 316 +- tagstudio/src/qt/widgets/landing.py | 3 +- tagstudio/src/qt/widgets/panel.py | 10 +- tagstudio/src/qt/widgets/preview_panel.py | 851 ++-- tagstudio/src/qt/widgets/tag.py | 80 +- tagstudio/src/qt/widgets/tag_box.py | 164 +- tagstudio/src/qt/widgets/thumb_renderer.py | 35 +- tagstudio/src/qt/widgets/video_player.py | 47 +- tagstudio/tag_studio.py | 33 +- tagstudio/tests/conftest.py | 131 +- .../test_library_search[--nomatch--].json | 1 - .../test_lib/test_library_search[First].json | 6 - .../test_lib/test_library_search[Second].json | 6 - .../test_lib/test_open_library.json | 4 - tagstudio/tests/core/test_lib.py | 18 - tagstudio/tests/core/test_tags.py | 8 - .../library/.TagStudio/ts_library.json | 69 - tagstudio/tests/fixtures/result.dupeguru | 10 + .../tests/fixtures/sidecar_newgrounds.json | 10 + tagstudio/tests/macros/test_dupe_entries.py | 35 + tagstudio/tests/macros/test_folders_tags.py | 9 + tagstudio/tests/macros/test_missing_files.py | 31 + tagstudio/tests/macros/test_refresh_dir.py | 19 + tagstudio/tests/macros/test_sidecar.py | 37 + .../__snapshots__/test_folders_to_tags.ambr | 4 + tagstudio/tests/qt/test_driver.py | 111 + tagstudio/tests/qt/test_flow_widget.py | 18 + tagstudio/tests/qt/test_folders_to_tags.py | 7 + tagstudio/tests/qt/test_item_thumb.py | 19 + tagstudio/tests/qt/test_preview_panel.py | 122 + tagstudio/tests/qt/test_tag_panel.py | 24 + tagstudio/tests/qt/test_tag_search_panel.py | 11 + tagstudio/tests/qt/test_tag_widget.py | 117 + tagstudio/tests/test_filter_state.py | 37 + tagstudio/tests/test_library.py | 407 ++ 85 files changed, 4803 insertions(+), 6752 deletions(-) delete mode 100644 .github/workflows/apprun.yaml delete mode 100644 tagstudio/src/cli/ts_cli.py delete mode 100644 tagstudio/src/core/json_typing.py create mode 100644 tagstudio/src/core/library/__init__.py create mode 100644 tagstudio/src/core/library/alchemy/__init__.py create mode 100644 tagstudio/src/core/library/alchemy/db.py create mode 100644 tagstudio/src/core/library/alchemy/enums.py create mode 100644 tagstudio/src/core/library/alchemy/fields.py create mode 100644 tagstudio/src/core/library/alchemy/joins.py create mode 100644 tagstudio/src/core/library/alchemy/library.py create mode 100644 tagstudio/src/core/library/alchemy/models.py create mode 100644 tagstudio/src/core/library/json/__init__.py create mode 100644 tagstudio/src/core/library/json/fields.py rename tagstudio/src/core/{ => library/json}/library.py (94%) create mode 100644 tagstudio/src/core/utils/dupe_files.py delete mode 100644 tagstudio/src/core/utils/fs.py create mode 100644 tagstudio/src/core/utils/missing_files.py create mode 100644 tagstudio/src/core/utils/refresh_dir.py mode change 100644 => 100755 tagstudio/tag_studio.py delete mode 100644 tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json delete mode 100644 tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json delete mode 100644 tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json delete mode 100644 tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json delete mode 100644 tagstudio/tests/core/test_lib.py delete mode 100644 tagstudio/tests/core/test_tags.py delete mode 100644 tagstudio/tests/fixtures/library/.TagStudio/ts_library.json create mode 100644 tagstudio/tests/fixtures/result.dupeguru create mode 100644 tagstudio/tests/fixtures/sidecar_newgrounds.json create mode 100644 tagstudio/tests/macros/test_dupe_entries.py create mode 100644 tagstudio/tests/macros/test_folders_tags.py create mode 100644 tagstudio/tests/macros/test_missing_files.py create mode 100644 tagstudio/tests/macros/test_refresh_dir.py create mode 100644 tagstudio/tests/macros/test_sidecar.py create mode 100644 tagstudio/tests/qt/__snapshots__/test_folders_to_tags.ambr create mode 100644 tagstudio/tests/qt/test_driver.py create mode 100644 tagstudio/tests/qt/test_flow_widget.py create mode 100644 tagstudio/tests/qt/test_folders_to_tags.py create mode 100644 tagstudio/tests/qt/test_item_thumb.py create mode 100644 tagstudio/tests/qt/test_preview_panel.py create mode 100644 tagstudio/tests/qt/test_tag_panel.py create mode 100644 tagstudio/tests/qt/test_tag_search_panel.py create mode 100644 tagstudio/tests/qt/test_tag_widget.py create mode 100644 tagstudio/tests/test_filter_state.py create mode 100644 tagstudio/tests/test_library.py diff --git a/.github/workflows/apprun.yaml b/.github/workflows/apprun.yaml deleted file mode 100644 index 9894f168..00000000 --- a/.github/workflows/apprun.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: PySide App Test - -on: [ push, pull_request ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: 'pip' - - - name: Install system dependencies - run: | - # dont run update, it is slow - # sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-x11-0 \ - x11-utils \ - libyaml-dev \ - libegl1-mesa \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-randr0 \ - libxcb-render-util0 \ - libxcb-xinerama0 \ - libopengl0 \ - libxcb-cursor0 \ - libpulse0 - - - name: Install dependencies - run: | - pip install -Ur requirements.txt - - - name: Run TagStudio app and check exit code - run: | - xvfb-run --server-args="-screen 0, 1920x1200x24 -ac +extension GLX +render -noreset" python tagstudio/tag_studio.py --ci -o /tmp/ - exit_code=$? - if [ $exit_code -eq 0 ]; then - echo "TagStudio ran successfully" - else - echo "TagStudio failed with exit code $exit_code" - exit 1 - fi diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 374a1c5e..3d7b4c18 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt - pip install mypy==1.10.0 + pip install mypy==1.11.2 mkdir tagstudio/.mypy_cache - uses: tsuyoshicho/action-mypy@v4 diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index e8a458aa..93f54e61 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -1,6 +1,6 @@ name: pytest -on: [push, pull_request] +on: [ push, pull_request ] jobs: pytest: @@ -11,12 +11,64 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install system dependencies + run: | + # dont run update, it is slow + # sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-x11-0 \ + x11-utils \ + libyaml-dev \ + libegl1-mesa \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + libopengl0 \ + libxcb-cursor0 \ + libpulse0 + - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt + python -m pip install --upgrade uv + uv pip install --system -r requirements.txt + uv pip install --system -r requirements-dev.txt - - name: Run tests + - name: Run pytest run: | - pytest tagstudio/tests/ + xvfb-run pytest --cov-report xml --cov=tagstudio + + - name: Store coverage + uses: actions/upload-artifact@v4 + with: + name: 'coverage' + path: 'coverage.xml' + + coverage: + name: Check Code Coverage + runs-on: ubuntu-latest + needs: pytest + + steps: + - name: Load coverage + uses: actions/download-artifact@v4 + with: + name: 'coverage' + + - name: Check Code Coverage + uses: yedpodtrzitko/coverage@main + with: + thresholdAll: 0.5 + thresholdNew: 0.5 + thresholdModified: 0.5 + coverageFile: coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} + sourceDir: tagstudio/src diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index 730ab441..897dbdc5 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -1,11 +1,20 @@ name: Ruff on: [ push, pull_request ] jobs: - ruff: + ruff-format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 with: - version: 0.4.2 + version: 0.6.4 args: 'format --check' + + ruff-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 + with: + version: 0.6.4 + args: 'check' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 291c3bde..6d665667 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.2 + rev: v0.6.4 hooks: - id: ruff-format + - id: ruff diff --git a/pyproject.toml b/pyproject.toml index d60908da..3fe8ffd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,33 @@ [tool.ruff] exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"] +[tool.ruff.lint] +select = ["E", "F", "UP", "B", 'SIM'] +ignore = ["E402", "E501", "F541"] + [tool.mypy] strict_optional = false -disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"] +disable_error_code = ["func-returns-value", "import-untyped"] explicit_package_bases = true warn_unused_ignores = true -exclude = ['tests'] +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "src.qt.main_window" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "src.qt.ui.home_ui" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "src.core.ts_core" +ignore_errors = true + +[tool.pytest.ini_options] +#addopts = "-m 'not qt'" +qt_api = "pyside6" diff --git a/requirements-dev.txt b/requirements-dev.txt index 81de1b33..b6b2c6e6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ -ruff==0.4.2 +ruff==0.6.4 pre-commit==3.7.0 pytest==8.2.0 Pyinstaller==6.6.0 -mypy==1.10.0 -syrupy==4.6.1 +mypy==1.11.2 +syrupy==4.7.1 +pytest-qt==4.4.0 +pytest-cov==5.0.0 diff --git a/requirements.txt b/requirements.txt index a353c70c..18be43fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ numpy==1.26.4 rawpy==0.21.0 pillow-heif==0.16.0 chardet==5.2.0 +structlog==24.4.0 +SQLAlchemy==2.0.34 diff --git a/tagstudio/src/cli/ts_cli.py b/tagstudio/src/cli/ts_cli.py deleted file mode 100644 index fba30860..00000000 --- a/tagstudio/src/cli/ts_cli.py +++ /dev/null @@ -1,3817 +0,0 @@ -# type: ignore -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -"""DEPRECIATED: A basic CLI driver for TagStudio.""" - -import datetime -import math - -# from multiprocessing import Value -import os - -# import subprocess -import sys -import time -from PIL import Image, ImageChops, UnidentifiedImageError -from PIL.Image import DecompressionBombError - -# import pillow_avif -from pathlib import Path -import traceback -import cv2 - -# import climage -# import click -from datetime import datetime as dt -from src.core.ts_core import * -from src.core.utils.web import * -from src.core.utils.fs import * -from src.core.library import * -from src.qt.helpers.file_opener import open_file - -WHITE_FG = "\033[37m" -WHITE_BG = "\033[47m" -BRIGHT_WHITE_FG = "\033[97m" -BRIGHT_WHITE_BG = "\033[107m" -BLACK_FG = "\033[30m" -BRIGHT_CYAN_FG = "\033[96m" -BRIGHT_CYAN_BG = "\033[106m" -BRIGHT_MAGENTA_FG = "\033[95m" -BRIGHT_MAGENTA_BG = "\033[105m" -BRIGHT_GREEN_FG = "\033[92m" -BRIGHT_GREEN_BG = "\033[102m" -YELLOW_FG = "\033[33m" -YELLOW_BG = "\033[43m" -BRIGHT_YELLOW_FG = "\033[93m" -BRIGHT_YELLOW_BG = "\033[103m" -RED_BG = "\033[41m" -BRIGHT_RED_FG = "\033[91m" -BRIGHT_RED_BG = "\033[101m" -MAGENTA_FG = "\033[35m" -MAGENTA_BG = "\033[45m" -RESET = "\033[0m" -SAVE_SCREEN = "\033[?1049h\033[?47h\033[H" -RESTORE_SCREEN = "\033[?47l\033[?1049l" - -ERROR = f"{RED_BG}{BRIGHT_WHITE_FG}[ERROR]{RESET}" -WARNING = f"{RED_BG}{BRIGHT_WHITE_FG}[WARNING]{RESET}" -INFO = f"{BRIGHT_CYAN_BG}{BLACK_FG}[INFO]{RESET}" - - -def clear(): - """Clears the terminal screen.""" - - # Windows - if os.name == "nt": - _ = os.system("cls") - - # Unix - else: - _ = os.system("clear") - - -class CliDriver: - """A basic CLI driver for TagStudio.""" - - def __init__(self, core, args): - self.core: TagStudioCore = core - self.lib = self.core.lib - self.filtered_entries: list[tuple[ItemType, int]] = [] - self.args = args - self.first_open: bool = True - self.first_browse: bool = True - self.is_missing_count_init: bool = False - self.is_new_file_count_init: bool = False - self.is_dupe_entry_count_init: bool = False - self.is_dupe_file_count_init: bool = False - - self.external_preview_size: tuple[int, int] = (960, 960) - epd_path = ( - Path(__file__).parents[2] / "resources/cli/images/external_preview.png" - ) - self.external_preview_default: Image = ( - Image.open(epd_path) - if epd_path.exists() - else Image.new(mode="RGB", size=(self.external_preview_size)) - ) - self.external_preview_default.thumbnail(self.external_preview_size) - epb_path = Path(__file__).parents[3] / "resources/cli/images/no_preview.png" - self.external_preview_broken: Image = ( - Image.open(epb_path) - if epb_path.exists() - else Image.new(mode="RGB", size=(self.external_preview_size)) - ) - self.external_preview_broken.thumbnail(self.external_preview_size) - - self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" - self.base_title: str = f"TagStudio {VERSION}{self.branch} - CLI Mode" - self.title_text: str = self.base_title - self.buffer = {} - - def start(self): - """Enters the CLI.""" - print(SAVE_SCREEN, end="") - try: - self.scr_main_menu() - except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=False) - except KeyboardInterrupt: - # traceback.print_exc() - print("\nForce Quitting TagStudio...") - # if self.lib and self.lib.library_dir: - # self.backup_library() - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=False) - except: - traceback.print_exc() - print("\nPress Enter to Continue...") - input() - # if self.lib and self.lib.library_dir: - # self.backup_library() - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=True) - # except: - # print( - # '\nAn Unknown Exception in TagStudio has Occurred. Press Enter to Continue...') - # input() - # # if self.lib and self.lib.library_dir: - # # self.backup_library() - # # self.cleanup_before_exit() - # # sys.exit() - # self.quit(save=False, backup=True) - - def cleanup_before_exit(self, restore_screen=True): - """Things do be done on application exit.""" - try: - if self.args.external_preview: - self.close_external_preview() - except Exception: - traceback.print_exc() - print("\nCrashed on Cleanup! This is unusual... Press Enter to Continue...") - input() - self.backup_library() - - if restore_screen: - print(f"{RESET}{RESTORE_SCREEN}", end="") - - def exit(self, save: bool, backup: bool): - """Exists TagStudio, and optionally saves and/or backs up data.""" - - if save: - print(f"{INFO} Saving Library to disk...") - self.save_library(display_message=False) - if backup: - print(f"{INFO} Saving Library changes to Backups folder...") - self.backup_library(display_message=False) - - self.cleanup_before_exit() - - try: - sys.exit() - except SystemExit: - sys.exit() - - def format_title(self, str, color=f"{BRIGHT_WHITE_FG}{MAGENTA_BG}") -> str: - """Formats a string with title formatting.""" - # Floating Pill (Requires NerdFont) - # return f'◀ {str} ▶'.center(os.get_terminal_size()[0], " ").replace('◀', '\033[96m\033[0m\033[30m\033[106m').replace('▶', '\033[0m\033[96m\033[0m') - # Solid Background - return f'{color}{str.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]}{RESET}' - - def format_subtitle(self, str, color=BRIGHT_CYAN_FG) -> str: - """Formats a string with subtitle formatting.""" - return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "═")[:os.get_terminal_size()[0]]}{RESET}' - - def format_h1(self, str, color=BRIGHT_MAGENTA_FG) -> str: - """Formats a string with h1 formatting.""" - return f'{color}{("┫ "+str+" ┣").center(os.get_terminal_size()[0], "━")[:os.get_terminal_size()[0]]}{RESET}' - - def format_h2(self, str, color=BRIGHT_GREEN_FG) -> str: - """Formats a string with h2 formatting.""" - return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "·")[:os.get_terminal_size()[0]]}{RESET}' - - def get_file_color(self, ext: str): - if ext.lower().replace(".", "", 1) == "gif": - return BRIGHT_YELLOW_FG - if ext.lower().replace(".", "", 1) in IMAGE_TYPES: - return WHITE_FG - elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: - return BRIGHT_CYAN_FG - elif ext.lower().replace(".", "", 1) in DOC_TYPES: - return BRIGHT_GREEN_FG - else: - return BRIGHT_WHITE_FG - - def get_tag_color(self, color: str) -> str: - if color.lower() == "black": - return "\033[48;2;17;16;24m" + "\033[38;2;183;182;190m" - # return '\033[48;5;233m' + BRIGHT_WHITE_FG - elif color.lower() == "dark gray": - return "\033[48;2;36;35;42m" + "\033[38;2;189;189;191m" - # return '\033[48;5;233m' + BRIGHT_WHITE_FG - elif color.lower() == "gray": - return "\033[48;2;83;82;90m" + "\033[38;2;203;202;210m" - # return '\033[48;5;246m' + BRIGHT_WHITE_FG - elif color.lower() == "light gray": - return "\033[48;2;170;169;176m" + "\033[38;2;34;33;40m" - # return '\033[48;5;250m' + BLACK_FG - elif color.lower() == "white": - return "\033[48;2;242;241;248m" + "\033[38;2;48;47;54m" - # return '\033[48;5;231m' + '\033[38;5;244m' - elif color.lower() == "light pink": - return "\033[48;2;255;143;190m" + "\033[38;2;108;43;57m" - # return '\033[48;5;212m' + '\033[38;5;88m' - elif color.lower() == "pink": - return "\033[48;2;250;74;117m" + "\033[38;2;91;23;35m" - # return '\033[48;5;204m' + '\033[38;5;224m' - elif color.lower() == "magenta": - return "\033[48;2;224;43;132m" + "\033[38;2;91;13;54m" - # return '\033[48;5;197m' + '\033[38;5;224m' - elif color.lower() == "red": - return "\033[48;2;226;44;60m" + "\033[38;2;68;13;18m" - # return '\033[48;5;196m' + '\033[38;5;224m' - elif color.lower() == "red orange": - return "\033[48;2;232;55;38m" + "\033[38;2;97;18;11m" - # return '\033[48;5;202m' + '\033[38;5;221m' - elif color.lower() == "salmon": - return "\033[48;2;246;88;72m" + "\033[38;2;111;27;22m" - # return '\033[48;5;203m' + '\033[38;5;88m' - elif color.lower() == "orange": - return "\033[48;2;237;96;34m" + "\033[38;2;85;30;10m" - # return '\033[48;5;208m' + '\033[38;5;229m' - elif color.lower() == "yellow orange": - return "\033[48;2;250;154;44m" + "\033[38;2;102;51;13m" - # return '\033[48;5;214m' + '\033[38;5;88m' - elif color.lower() == "yellow": - return "\033[48;2;255;214;61m" + "\033[38;2;117;67;18m" - # return '\033[48;5;220m' + '\033[38;5;88m' - elif color.lower() == "mint": - return "\033[48;2;74;237;144m" + "\033[38;2;22;79;62m" - # return '\033[48;5;84m' + '\033[38;5;17m' - elif color.lower() == "lime": - return "\033[48;2;149;227;69m" + "\033[38;2;65;84;21m" - # return '\033[48;5;154m' + '\033[38;5;17m' - elif color.lower() == "light green": - return "\033[48;2;138;236;125m" + "\033[38;2;44;85;38m" - # return '\033[48;5;40m' + '\033[38;5;17m' - elif color.lower() == "green": - return "\033[48;2;40;187;72m" + "\033[38;2;13;56;40m" - # return '\033[48;5;28m' + '\033[38;5;191m' - elif color.lower() == "teal": - return "\033[48;2;23;191;157m" + "\033[38;2;7;58;68m" - # return '\033[48;5;36m' + '\033[38;5;17m' - elif color.lower() == "cyan": - return "\033[48;2;60;222;196m" + "\033[38;2;12;64;66m" - # return '\033[48;5;50m' + '\033[38;5;17m' - elif color.lower() == "light blue": - return "\033[48;2;85;187;246m" + "\033[38;2;18;37;65m" - # return '\033[48;5;75m' + '\033[38;5;17m' - elif color.lower() == "blue": - return "\033[48;2;59;99;240m" + "\033[38;2;158;192;249m" - # return '\033[48;5;27m' + BRIGHT_WHITE_FG - elif color.lower() == "blue violet": - return "\033[48;2;93;88;241m" + "\033[38;2;149;176;249m" - # return '\033[48;5;63m' + BRIGHT_WHITE_FG - elif color.lower() == "violet": - return "\033[48;2;120;60;239m" + "\033[38;2;187;157;247m" - # return '\033[48;5;57m' + BRIGHT_WHITE_FG - elif color.lower() == "purple": - return "\033[48;2;155;79;240m" + "\033[38;2;73;24;98m" - # return '\033[48;5;135m' + BRIGHT_WHITE_FG - elif color.lower() == "peach": - return "\033[48;2;241;198;156m" + "\033[38;2;97;63;47m" - # return '\033[48;5;223m' + '\033[38;5;88m' - elif color.lower() == "brown": - return "\033[48;2;130;50;22m" + "\033[38;2;205;157;131m" - # return '\033[48;5;130m' + BRIGHT_WHITE_FG - elif color.lower() == "lavender": - return "\033[48;2;173;142;239m" + "\033[38;2;73;43;101m" - # return '\033[48;5;141m' + '\033[38;5;17m' - elif color.lower() == "blonde": - return "\033[48;2;239;198;100m" + "\033[38;2;109;70;30m" - # return '\033[48;5;221m' + '\033[38;5;88m' - elif color.lower() == "auburn": - return "\033[48;2;161;50;32m" + "\033[38;2;217;138;127m" - # return '\033[48;5;88m' + '\033[38;5;216m' - elif color.lower() == "light brown": - return "\033[48;2;190;91;45m" + "\033[38;2;76;41;14m" - elif color.lower() == "dark brown": - return "\033[48;2;76;35;21m" + "\033[38;2;183;129;113m" - # return '\033[48;5;172m' + BRIGHT_WHITE_FG - elif color.lower() == "cool gray": - return "\033[48;2;81;87;104m" + "\033[38;2;158;161;195m" - # return '\033[48;5;102m' + BRIGHT_WHITE_FG - elif color.lower() == "warm gray": - return "\033[48;2;98;88;80m" + "\033[38;2;192;171;146m" - # return '\033[48;5;59m' + BRIGHT_WHITE_FG - elif color.lower() == "olive": - return "\033[48;2;76;101;46m" + "\033[38;2;180;193;122m" - # return '\033[48;5;58m' + '\033[38;5;193m' - elif color.lower() == "berry": - return "\033[48;2;159;42;167m" + "\033[38;2;204;143;220m" - else: - return "" - - def copy_field_to_buffer(self, entry_field) -> None: - """Copies an Entry Field object into the internal buffer.""" - self.buffer = dict(entry_field) - - def paste_field_from_buffer(self, entry_id) -> None: - """Merges or adds the Entry Field object in the internal buffer to the Entry.""" - if self.buffer: - # entry: Entry = self.lib.entries[entry_index] - # entry = self.lib.get_entry(entry_id) - field_id: int = self.lib.get_field_attr(self.buffer, "id") - content = self.lib.get_field_attr(self.buffer, "content") - - # NOTE: This code is pretty much identical to the match_conditions code - # found in the core. Could this be made generic? Especially for merging Entries. - if self.lib.get_field_obj(int(field_id))["type"] == "tag_box": - existing_fields: list[int] = self.lib.get_field_index_in_entry( - entry_id, field_id - ) - if existing_fields: - self.lib.update_entry_field( - entry_id, existing_fields[0], content, "append" - ) - else: - self.lib.add_field_to_entry(entry_id, field_id) - self.lib.update_entry_field(entry_id, -1, content, "append") - - if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS: - if not self.lib.does_field_content_exist(entry_id, field_id, content): - self.lib.add_field_to_entry(entry_id, field_id) - self.lib.update_entry_field(entry_id, -1, content, "replace") - - # existing_fields: list[int] = self.lib.get_field_index_in_entry(entry_index, field_id) - # if existing_fields: - # self.lib.update_entry_field(entry_index, existing_fields[0], content, 'append') - # else: - # self.lib.add_field_to_entry(entry_index, field_id) - # self.lib.update_entry_field(entry_index, -1, content, 'replace') - - def init_external_preview(self) -> None: - """Initialized the external preview image file.""" - if self.lib and self.lib.library_dir: - external_preview_path: Path = ( - self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg" - ) - if not external_preview_path.is_file(): - temp = self.external_preview_default - temp.save(external_preview_path) - open_file(external_preview_path) - - def set_external_preview_default(self) -> None: - """Sets the external preview to its default image.""" - if self.lib and self.lib.library_dir: - external_preview_path: Path = ( - self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg" - ) - if external_preview_path.is_file(): - temp = self.external_preview_default - temp.save(external_preview_path) - - def set_external_preview_broken(self) -> None: - """Sets the external preview image file to the 'broken' placeholder.""" - if self.lib and self.lib.library_dir: - external_preview_path: Path = ( - self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg" - ) - if external_preview_path.is_file(): - temp = self.external_preview_broken - temp.save(external_preview_path) - - def close_external_preview(self) -> None: - """Destroys and closes the external preview image file.""" - if self.lib and self.lib.library_dir: - external_preview_path: Path = ( - self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg" - ) - if external_preview_path.is_file(): - os.remove(external_preview_path) - - def scr_create_library(self, path=None): - """Screen for creating a new TagStudio library.""" - - subtitle = "Create Library" - - clear() - print(f"{self.format_title(self.title_text)}") - print(self.format_subtitle(subtitle)) - print("") - - if not path: - print("Enter Library Folder Path: \n> ", end="") - path = input() - - path = Path(path) - - if path.exists(): - print("") - print( - f'{INFO} Are you sure you want to create a new Library at "{path}"? (Y/N)\n> ', - end="", - ) - con = input().lower() - if con == "y" or con == "yes": - result = self.lib.create_library(path) - if result == 0: - print( - f'{INFO} Created new TagStudio Library at: "{path}"\nPress Enter to Return to Main Menu...' - ) - input() - # self.open_library(path) - elif result == 1: - print( - f'{ERROR} Could not create Library. Path: "{path}" is pointing inside an existing TagStudio Folder.\nPress Enter to Return to Main Menu...' - ) - input() - elif result == 2: - print( - f'{ERROR} Could not write inside path: "{path}"\nPress Enter to Return to Main Menu...' - ) - input() - else: - print( - f'{ERROR} Invalid Path: "{path}"\nPress Enter to Return to Main Menu...' - ) - input() - # if Core.open_library(path) == 1: - # self.library_name = path - # self.scr_library_home() - # else: - # print(f'[ERROR]: No existing TagStudio library found at \'{path}\'') - # self.scr_main_menu() - - def open_library(self, path): - """Opens a TagStudio library.""" - - return_code = self.lib.open_library(path) - if return_code == 1: - # self.lib = self.core.library - if self.args.external_preview: - self.init_external_preview() - - if len(self.lib.entries) <= 1000: - print( - f"{INFO} Checking for missing files in Library '{self.lib.library_dir}'..." - ) - self.lib.refresh_missing_files() - # else: - # print( - # f'{INFO} Automatic missing file refreshing is turned off for large libraries (1,000+ Entries)') - self.title_text: str = self.base_title + "" - self.scr_library_home() - else: - clear() - print(f"{ERROR} No existing TagStudio library found at '{path}'") - self.scr_main_menu(clear_scr=False) - - def close_library(self, save=True): - """ - Saves (by default) and clears the current Library as well as related operations. - Does *not* direct the navigation back to the main menu, that's not my job. - """ - if save: - self.lib.save_library_to_disk() - if self.args.external_preview: - self.close_external_preview() - self.lib.clear_internal_vars() - - def backup_library(self, display_message: bool = True) -> bool: - """Saves a backup copy of the Library file to disk. Returns True if successful.""" - if self.lib and self.lib.library_dir: - filename = self.lib.save_library_backup_to_disk() - location = self.lib.library_dir / TS_FOLDER_NAME / "backups" / filename - if display_message: - print(f'{INFO} Backup of Library saved at "{location}".') - return True - return False - - def save_library(self, display_message: bool = True) -> bool: - """Saves the Library file to disk. Returns True if successful.""" - if self.lib and self.lib.library_dir: - self.lib.save_library_to_disk() - if display_message: - print(f"{INFO} Library saved to disk.") - return True - return False - - def get_char_limit(self, text: str) -> int: - """ - Returns an estimated value for how many characters of a block of text should be allowed to display before being truncated. - """ - # char_limit: int = ( - # (os.get_terminal_size()[0] * os.get_terminal_size()[1]) // 6) - # char_limit -= (text.count('\n') + text.count('\r') * (os.get_terminal_size()[0] // 1.0)) - # char_limit = char_limit if char_limit > 0 else min(40, len(text)) - - char_limit: int = os.get_terminal_size()[0] * (os.get_terminal_size()[1] // 5) - char_limit -= (text.count("\n") + text.count("\r")) * ( - os.get_terminal_size()[0] // 2 - ) - char_limit = char_limit if char_limit > 0 else min((64), len(text)) - - # print(f'Char Limit: {char_limit}, Len: {len(text)}') - return char_limit - - def truncate_text(self, text: str) -> str: - """Returns a truncated string for displaying, calculated with `get_char_limit()`.""" - if len(text) > self.get_char_limit(text): - # print(f'Char Limit: {self.get_char_limit(text)}, Len: {len(text)}') - return f"{text[:int(self.get_char_limit(text) - 1)]} {WHITE_FG}[...]{RESET}" - else: - return text - - def print_fields(self, index) -> None: - """Prints an Entry's formatted fields to the screen.""" - entry = self.lib.entries[index] - - if entry and self.args.debug: - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ", end="") - print(entry.id_) - - if entry and entry.fields: - for i, field in enumerate(entry.fields): - # Buffer between box fields below other fields if this isn't the first field - if ( - i != 0 - and self.lib.get_field_attr(field, "type") in BOX_FIELDS - and self.lib.get_field_attr(entry.fields[i - 1], "type") - not in BOX_FIELDS - ): - print("") - # Format the field title differently for box fields. - if self.lib.get_field_attr(field, "type") in BOX_FIELDS: - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', - end="\n", - ) - else: - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', - end="", - ) - if self.lib.get_field_attr(field, "type") == "tag_box": - char_count: int = 0 - for tag_id in self.lib.get_field_attr(field, "content"): - tag = self.lib.get_tag(tag_id) - # Properly wrap Tags on screen - char_count += len(f" {tag.display_name(self.lib)} ") + 1 - if char_count > os.get_terminal_size()[0]: - print("") - char_count = len(f" {tag.display_name(self.lib)} ") + 1 - print( - f"{self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}", - end="", - ) - # If the tag isn't the last one, print a space for the next one. - if tag_id != self.lib.get_field_attr(field, "content")[-1]: - print(" ", end="") - else: - print("") - elif self.lib.get_field_attr(field, "type") in TEXT_FIELDS: - # Normalize line endings in any text content. - text: str = self.lib.get_field_attr(field, "content").replace( - "\r", "\n" - ) - print(self.truncate_text(text)) - elif self.lib.get_field_attr(field, "type") == "datetime": - try: - # TODO: Localize this and/or add preferences. - date = dt.strptime( - self.lib.get_field_attr(field, "content"), - "%Y-%m-%d %H:%M:%S", - ) - print(date.strftime("%D - %r")) - except: - print(self.lib.get_field_attr(field, "content")) - else: - print(self.lib.get_field_attr(field, "content")) - - # Buffer between box fields above other fields if this isn't the last field - if ( - entry.fields[i] != entry.fields[-1] - and self.lib.get_field_attr(field, "type") in BOX_FIELDS - ): - print("") - else: - # print(f'{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG} (Run \'edit\', then \'add \' to add some!){RESET}') - print(f"{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG}") - - def print_thumbnail( - self, index, filepath="", ignore_fields=False, max_width=-1 - ) -> None: - """ - Prints an Entry's formatted thumbnail to the screen. - Takes in either an Entry index or a direct filename. - """ - entry = None if index < 0 else self.lib.entries[index] - if entry: - filepath = self.lib.library_dir / entry.path / entry.filename - external_preview_path: Path = None - if self.args.external_preview: - external_preview_path = ( - self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg" - ) - # thumb_width = min( - # os.get_terminal_size()[0]//2, - # math.floor(os.get_terminal_size()[1]*0.5)) - # thumb_width = math.floor(os.get_terminal_size()[1]*0.5) - - # if entry: - file_type = os.path.splitext(filepath)[1].lower()[1:] - if file_type in (IMAGE_TYPES + VIDEO_TYPES): - # TODO: Make the image-grabbing part try to get thumbnails. - - # Lots of calculations to determine an image width that works well. - w, h = (1, 1) - final_img_path = filepath - if file_type in IMAGE_TYPES: - try: - raw = Image.open(filepath) - w, h = raw.size - # NOTE: Temporary way to hack a non-terminal preview. - if self.args.external_preview: - raw = raw.convert("RGB") - # raw.thumbnail((512, 512)) - raw.thumbnail(self.external_preview_size) - raw.save(external_preview_path) - except ( - UnidentifiedImageError, - FileNotFoundError, - DecompressionBombError, - ) as e: - print(f'{ERROR} Could not load image "{filepath} due to {e}"') - if self.args.external_preview: - self.set_external_preview_broken() - elif file_type in VIDEO_TYPES: - try: - video = cv2.VideoCapture(filepath) - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - final_frame = Image.fromarray(frame) - w, h = final_frame.size - final_frame.save( - self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg", quality=50 - ) - final_img_path = self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg" - # NOTE: Temporary way to hack a non-terminal preview. - if self.args.external_preview and entry: - final_frame.thumbnail(self.external_preview_size) - final_frame.save(external_preview_path) - except SystemExit: - sys.exit() - except: - print(f'{ERROR} Could not load video thumbnail for "{filepath}"') - if self.args.external_preview and entry: - self.set_external_preview_broken() - pass - - img_ratio: float = w / h - term_ratio_norm: float = ( - os.get_terminal_size()[1] / os.get_terminal_size()[0] - ) * 2 - base_mod: float = 0.7 - field_cnt_mod: float = 0 - desc_len_mod: float = 0 - tag_cnt_mod: float = 0 - if entry and entry.fields and not ignore_fields: - field_cnt_mod = 1.5 * len(entry.fields) - for f in entry.fields: - if self.lib.get_field_attr(f, "type") == "tag_box": - tag_cnt_mod += 0.5 * len(self.lib.get_field_attr(f, "content")) - elif self.lib.get_field_attr(f, "type") == "text_box": - desc_len_mod += 0.07 * len( - self.truncate_text(self.lib.get_field_attr(f, "content")) - ) - desc_len_mod += 1.7 * self.truncate_text( - self.lib.get_field_attr(f, "content") - ).count("\n") - desc_len_mod += 1.7 * self.truncate_text( - self.lib.get_field_attr(f, "content") - ).count("\r") - try: - thumb_width = min( - math.floor( - ( - os.get_terminal_size()[0] - * img_ratio - * term_ratio_norm - * base_mod - ) - - ( - (field_cnt_mod + desc_len_mod + tag_cnt_mod) - * (img_ratio * 0.7) - ) - ), - os.get_terminal_size()[0], - ) - if max_width > 0: - thumb_width = max_width if thumb_width > max_width else thumb_width - # image = climage.convert(final_img_path, is_truecolor=True, is_256color=False, - # is_16color=False, is_8color=False, width=thumb_width) - # Center Alignment Hack - spacing = (os.get_terminal_size()[0] - thumb_width) // 2 - if not self.args.external_preview or not entry: - print(" " * spacing, end="") - print(image.replace("\n", ("\n" + " " * spacing))) - - if file_type in VIDEO_TYPES: - os.remove(self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg") - except: - if not self.args.external_preview or not entry: - print( - f"{ERROR} Could not display preview. Is there enough screen space?" - ) - - def print_columns(self, content: list[object], add_enum: False) -> None: - """ - Prints content in a column format. - Content: A list of tuples list[(element, formatting)] - """ - try: - if content: - # This is an estimate based on the existing screen formatting. - margin: int = 7 - enum_padding: int = 0 - term_width: int = os.get_terminal_size()[0] - - num_width: int = len(str(len(content) + 1)) - if add_enum: - enum_padding = num_width + 2 - - longest_width: int = ( - len(max(content, key=lambda x: len(x[0]))[0]) + 1 - ) # + Padding - column_count: int = term_width // (longest_width + enum_padding + 3) - column_count: int = column_count if column_count > 0 else 1 - max_display: int = column_count * (os.get_terminal_size()[1] - margin) - displayable: int = min(max_display, len(content)) - - # Recalculate based on displayable items - num_width = len(str(len(content[:max_display]) + 1)) - if add_enum: - enum_padding = num_width + 2 - longest_width = ( - len(max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 - ) - column_count = term_width // (longest_width + enum_padding + 3) - column_count = column_count if column_count > 0 else 1 - max_display = column_count * (os.get_terminal_size()[1] - margin) - # displayable: int = min(max_display, len(content)) - - num_width = len(str(len(content[:max_display]) + 1)) - if add_enum: - enum_padding = num_width + 2 - # longest_width = len(max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 - # column_count = term_width // (longest_width + enum_padding + 3) - # column_count = column_count if column_count > 0 else 1 - # max_display = column_count * (os.get_terminal_size()[1]-margin) - - # print(num_width) - # print(term_width) - # print(longest_width) - # print(columns) - # print(max(content, key = lambda x : len(x[0]))) - # print(len(max(content, key = lambda x : len(x[0]))[0])) - - # # Prints out the list in a left-to-right tabular column form with color formatting. - # for i, element in enumerate(content): - # if i != 0 and i % (columns-1) == 0: - # print('') - # if add_enum: - # print(f'{element[1]}[{str(i+1).zfill(num_width)}] {element[0]} {RESET}', end='') - # else: - # print(f'{element[1]} {element[0]} {RESET}', end='') - # print(' ' * (longest_width - len(element[0])), end='') - - # Prints out the list in a top-down tabular column form with color formatting. - # This is my greatest achievement. - row_count: int = math.floor(len(content) / column_count) - table_size: int = row_count * column_count - table_size = table_size if table_size > 0 else 1 - # print(f'Rows:{max_rows}, Cols:{max_columns}') - row_num = 1 - col_num = 1 - for i, element in enumerate(content): - if i < max_display: - if row_count > 1: - row_number = i // column_count - index = (i * row_count) - (row_number * (table_size - 1)) - # col_number = index // math.ceil(len(content) / max_columns) - offset: int = 0 - if displayable % table_size == 1: - offset = ( - 1 - if (index >= row_count) - and (row_number != row_count) - else 0 - ) - elif displayable % table_size != 0: - if 1 < col_num <= displayable % table_size: - offset += col_num - 1 - elif col_num > 1 and col_num > displayable % table_size: - offset = displayable % table_size - - if ( - col_num > 1 - and (os.get_terminal_size()[1] - margin) < row_count - ): - offset -= ( - row_count - (os.get_terminal_size()[1] - margin) - ) * (col_num - 1) + (col_num - 1) - - # print(f'{row_count}/{(os.get_terminal_size()[1]-margin)}', end='') - - index += offset - # print(offset, end='') - # print(f'{row_num}-{col_num}', end='') - else: - index = i - if i != 0 and i % column_count == 0: - row_num += 1 - col_num = 1 - print("") - if index < len(content): - col_num += 1 - col_num = col_num if col_num <= column_count else 1 - if add_enum: - print( - f"{content[index][1]}[{str(index+1).zfill(num_width)}] {content[index][0]} {RESET}", - end="", - ) - else: - print( - f"{content[index][1]} {content[index][0]} {RESET}", - end="", - ) - if row_count > 0: - print( - " " * (longest_width - len(content[index][0])), - end="", - ) - else: - print(" ", end="") - else: - print( - "\n" - + self.format_h2(f"[{len(content) - max_display} More...]"), - end="", - ) - # print(WHITE_FG + '\n' + f'[{len(content) - max_display} More...]'.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]+RESET) - # print(f'\n{WHITE_FG}[{{RESET}', end='') - break - # print(f'Rows:{row_count}, Cols:{column_count}') - print("") - - except Exception: - traceback.print_exc() - print("\nPress Enter to Continue...") - input() - pass - - def run_macro(self, name: str, entry_id: int): - """Runs a specific Macro on an Entry given a Macro name.""" - # entry: Entry = self.lib.get_entry_from_index(entry_id) - entry = self.lib.get_entry(entry_id) - path = self.lib.library_dir / entry.path / entry.filename - source = path.split(os.sep)[1].lower() - if name == "sidecar": - self.lib.add_generic_data_to_entry( - self.core.get_gdl_sidecar(path, source), entry_id - ) - elif name == "autofill": - self.run_macro("sidecar", entry_id) - self.run_macro("build-url", entry_id) - self.run_macro("match", entry_id) - self.run_macro("clean-url", entry_id) - self.run_macro("sort-fields", entry_id) - elif name == "build-url": - data = {"source": self.core.build_url(entry_id, source)} - self.lib.add_generic_data_to_entry(data, entry_id) - elif name == "sort-fields": - order: list[int] = ( - [0] - + [1, 2] - + [9, 17, 18, 19, 20] - + [10, 14, 11, 12, 13, 22] - + [4, 5] - + [8, 7, 6] - + [3, 21] - ) - self.lib.sort_fields(entry_id, order) - elif name == "match": - self.core.match_conditions(entry_id) - elif name == "scrape": - self.core.scrape(entry_id) - elif name == "clean-url": - # entry = self.lib.get_entry_from_index(entry_id) - if entry.fields: - for i, field in enumerate(entry.fields, start=0): - if self.lib.get_field_attr(field, "type") == "text_line": - self.lib.update_entry_field( - entry_id=entry_id, - field_index=i, - content=strip_web_protocol( - self.lib.get_field_attr(field, "content") - ), - mode="replace", - ) - - def create_collage(self) -> str: - """Generates and saves an image collage based on Library Entries.""" - - run: bool = True - keep_aspect: bool = False - data_only_mode: bool = False - data_tint_mode: bool = False - - mode: int = self.scr_choose_option( - subtitle="Choose Collage Mode(s)", - choices=[ - ( - "Normal", - "Creates a standard square image collage made up of Library media files.", - ), - ( - "Data Tint", - "Tints the collage with a color representing data about the Library Entries/files.", - ), - ( - "Data Only", - "Ignores media files entirely and only outputs a collage of Library Entry/file data.", - ), - ("Normal & Data Only", "Creates both Normal and Data Only collages."), - ], - prompt="", - required=True, - ) - - if mode == 1: - data_tint_mode = True - - if mode == 2: - data_only_mode = True - - if mode in [0, 1, 3]: - keep_aspect = self.scr_choose_option( - subtitle="Choose Aspect Ratio Option", - choices=[ - ( - "Stretch to Fill", - "Stretches the media file to fill the entire collage square.", - ), - ( - "Keep Aspect Ratio", - "Keeps the original media file's aspect ratio, filling the rest of the square with black bars.", - ), - ], - prompt="", - required=True, - ) - - if mode in [1, 2, 3]: - # TODO: Choose data visualization options here. - pass - - full_thumb_size: int = 1 - - if mode in [0, 1, 3]: - full_thumb_size = self.scr_choose_option( - subtitle="Choose Thumbnail Size", - choices=[ - ("Tiny (32px)", ""), - ("Small (64px)", ""), - ("Medium (128px)", ""), - ("Large (256px)", ""), - ("Extra Large (512px)", ""), - ], - prompt="", - required=True, - ) - - thumb_size: int = ( - 32 - if (full_thumb_size == 0) - else 64 - if (full_thumb_size == 1) - else 128 - if (full_thumb_size == 2) - else 256 - if (full_thumb_size == 3) - else 512 - if (full_thumb_size == 4) - else 32 - ) - - # if len(com) > 1 and com[1] == 'keep-aspect': - # keep_aspect = True - # elif len(com) > 1 and com[1] == 'data-only': - # data_only_mode = True - # elif len(com) > 1 and com[1] == 'data-tint': - # data_tint_mode = True - grid_size = math.ceil(math.sqrt(len(self.lib.entries))) ** 2 - grid_len = math.floor(math.sqrt(grid_size)) - thumb_size = thumb_size if not data_only_mode else 1 - img_size = thumb_size * grid_len - - print( - f"Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})" - ) - if keep_aspect: - print("Keeping original aspect ratios.") - if data_only_mode: - print("Visualizing Entry Data") - - if not data_only_mode: - time.sleep(5) - - collage = Image.new("RGB", (img_size, img_size)) - filename = ( - elf.lib.library_dir - / TS_FOLDER_NAME - / COLLAGE_FOLDER_NAME - / f'collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png' - ) - - i = 0 - for x in range(0, grid_len): - for y in range(0, grid_len): - try: - if i < len(self.lib.entries) and run: - # entry: Entry = self.lib.get_entry_from_index(i) - entry = self.lib.entries[i] - filepath = self.lib.library_dir / entry.path / entry.filename - color: str = "" - - if data_tint_mode or data_only_mode: - color = "#000000" # Black (Default) - - if entry.fields: - has_any_tags: bool = False - has_content_tags: bool = False - has_meta_tags: bool = False - for field in entry.fields: - if ( - self.lib.get_field_attr(field, "type") - == "tag_box" - ): - if self.lib.get_field_attr(field, "content"): - has_any_tags = True - if ( - self.lib.get_field_attr(field, "id") - == 7 - ): - has_content_tags = True - elif ( - self.lib.get_field_attr(field, "id") - == 8 - ): - has_meta_tags = True - if has_content_tags and has_meta_tags: - color = "#28bb48" # Green - elif has_any_tags: - color = "#ffd63d" # Yellow - # color = '#95e345' # Yellow-Green - else: - # color = '#fa9a2c' # Yellow-Orange - color = "#ed8022" # Orange - else: - color = "#e22c3c" # Red - - if data_only_mode: - pic: Image = Image.new( - "RGB", (thumb_size, thumb_size), color - ) - collage.paste(pic, (y * thumb_size, x * thumb_size)) - if not data_only_mode: - print( - f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}{RESET}" - ) - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() - - if filepath.suffix.lower() in IMAGE_TYPES: - try: - with Image.open( - self.lib.library_dir - / entry.path - / entry.filename - ) as pic: - if keep_aspect: - pic.thumbnail((thumb_size, thumb_size)) - else: - pic = pic.resize((thumb_size, thumb_size)) - if data_tint_mode and color: - pic = pic.convert(mode="RGB") - pic = ImageChops.hard_light( - pic, - Image.new( - "RGB", - (thumb_size, thumb_size), - color, - ), - ) - collage.paste( - pic, (y * thumb_size, x * thumb_size) - ) - except DecompressionBombError as e: - print( - f"[ERROR] One of the images was too big ({e})" - ) - - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - with Image.fromarray(frame, mode="RGB") as pic: - if keep_aspect: - pic.thumbnail((thumb_size, thumb_size)) - else: - pic = pic.resize((thumb_size, thumb_size)) - if data_tint_mode and color: - pic = ImageChops.hard_light( - pic, - Image.new( - "RGB", (thumb_size, thumb_size), color - ), - ) - collage.paste(pic, (y * thumb_size, x * thumb_size)) - except UnidentifiedImageError: - print(f"\n{ERROR} Couldn't read {entry.path / entry.filename}") - except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - clear() - print(f"{INFO} Collage operation cancelled.") - clear_scr = False - except: - print(f"{ERROR} {entry.path / entry.filename}") - traceback.print_exc() - print("Continuing...") - i = i + 1 - - if run: - self.lib.verify_ts_folders() - collage.save(filename) - return filename - return "" - - def global_commands(self, com: list[str]) -> tuple[bool, str]: - """ - Executes from a set of global commands.\n - Returns a (bool,str) tuple containing (was command executed?, optional command message) - """ - was_executed: bool = False - message: str = "" - com_name = com[0].lower() - - # Backup Library ======================================================= - if com_name == "backup": - self.backup_library(display_message=False) - was_executed = True - message = f"{INFO} Backed up Library to disk." - # Create Collage ======================================================= - elif com_name == "collage": - filename = self.create_collage() - if filename: - was_executed = True - message = f'{INFO} Saved collage to "{filename}".' - # Save Library ========================================================= - elif com_name in ("save", "write", "w"): - self.save_library(display_message=False) - was_executed = True - message = f"{INFO} Library saved to disk." - # Toggle Debug ========================================================= - elif com_name == "toggle-debug": - self.args.debug = not self.args.debug - was_executed = True - message = ( - f"{INFO} Debug Mode Active." - if self.args.debug - else f"{INFO} Debug Mode Deactivated." - ) - # Toggle External Preview ============================================== - elif com_name == "toggle-external-preview": - self.args.external_preview = not self.args.external_preview - if self.args.external_preview: - self.init_external_preview() - else: - self.close_external_preview() - was_executed = True - message = ( - f"{INFO} External Preview Enabled." - if self.args.external_preview - else f"{INFO} External Preview Disabled." - ) - # Quit ================================================================= - elif com_name in ("quit", "q"): - self.exit(save=True, backup=False) - was_executed = True - # Quit without Saving ================================================== - elif com_name in ("quit!", "q!"): - self.exit(save=False, backup=False) - was_executed = True - - return (was_executed, message) - - def scr_browse_help(self, prev) -> None: - """A Help screen for commands available during Library Browsing.""" - pass - - def scr_main_menu(self, clear_scr=True): - """The CLI main menu.""" - - while True: - if self.args.open and self.first_open: - self.first_open = False - self.open_library(self.args.open) - - if clear_scr: - clear() - clear_scr = True - print(f"{self.format_title(self.title_text)}") - print("") - print(f"\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}") - print(f"\t\tOpen Library: {WHITE_FG}open | o {RESET}") - print(f"\t\tCreate New Library: {WHITE_FG}new | n {RESET}") - # print(f'\t\tHelp: {WHITE_FG}help | h{RESET}') - print("") - print(f"\t\tQuit TagStudio: {WHITE_FG}quit | q{RESET}") - print("") - print( - f"\t💡TIP: {WHITE_FG}TagStudio can be launched with the --open (or -o) option followed\n\t\tby to immediately open a library!{RESET}" - ) - print("") - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - if com[0].lower() == "open" or com[0].lower() == "o": - if len(com) > 1: - self.open_library(com[1]) - elif com[0].lower() == "new" or com[0].lower() == "n": - if len(com) > 1: - self.scr_create_library(com[1]) - # elif (com[0].lower() == 'toggle-debug'): - # self.args.debug = not self.args.debug - # elif com[0].lower() in ['quit', 'q', 'close', 'c']: - # sys.exit() - # elif com[0].lower() in ['quit!', 'q!']: - # sys.exit() - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - - def scr_library_home(self, clear_scr=True): - """Home screen for an opened Library.""" - - while True: - subtitle = f"Library '{self.lib.library_dir}'" - if self.lib.is_legacy_library: - subtitle += " (Legacy Format)" - if self.args.debug: - subtitle += " (Debug Mode Active)" - # Directory Info ------------------------------------------------------- - file_count: str = ( - f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dir' to update){RESET}" - if self.lib.dir_file_count == -1 - else f"{WHITE_FG}{self.lib.dir_file_count}{RESET}" - ) - - new_file_count: str = ( - f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dir' to update){RESET}" - if ( - self.lib.files_not_in_library == [] - and not self.is_new_file_count_init - ) - else f"{WHITE_FG}{len(self.lib.files_not_in_library)}{RESET}" - ) - - # Issues --------------------------------------------------------------- - missing_file_count: str = ( - f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh missing' to update){RESET}" - if (self.lib.missing_files == [] and not self.is_missing_count_init) - else f"{BRIGHT_RED_FG}{len(self.lib.missing_files)}{RESET}" - ) - missing_file_count = ( - f"{BRIGHT_GREEN_FG}0{RESET}" - if (self.is_missing_count_init and len(self.lib.missing_files) == 0) - else missing_file_count - ) - - dupe_entry_count: str = ( - f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dupe entries' to update){RESET}" - if (self.lib.dupe_entries == [] and not self.is_dupe_entry_count_init) - else f"{BRIGHT_RED_FG}{len(self.lib.dupe_entries)}{RESET}" - ) - dupe_entry_count = ( - f"{BRIGHT_GREEN_FG}0{RESET}" - if (self.is_dupe_entry_count_init and len(self.lib.dupe_entries) == 0) - else dupe_entry_count - ) - - dupe_file_count: str = ( - f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dupe files' to update){RESET}" - if (self.lib.dupe_files == [] and not self.is_dupe_file_count_init) - else f"{BRIGHT_RED_FG}{len(self.lib.dupe_files)}{RESET}" - ) - dupe_file_count = ( - f"{BRIGHT_GREEN_FG}0{RESET}" - if (self.is_dupe_file_count_init and len(self.lib.dupe_files) == 0) - else dupe_file_count - ) - # fixed_file_count: str = 'N/A (Run \'fix missing\' to refresh)' if self.lib.fixed_files == [ - # ] else len(self.lib.fixed_files) - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(self.base_title)) - print(self.format_subtitle(subtitle)) - print("") - - if self.args.browse and self.first_browse: - self.first_browse = False - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - else: - print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Library Info - {RESET}") - print(f"\t Entries: {WHITE_FG}{len(self.lib.entries)}{RESET}") - # print(f'\tCollations: {WHITE_FG}0{RESET}') - print(f"\t Tags: {WHITE_FG}{len(self.lib.tags)}{RESET}") - print(f"\t Fields: {WHITE_FG}{len(self.lib.default_fields)}{RESET}") - # print(f'\t Macros: {WHITE_FG}0{RESET}') - print("") - print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Directory Info - {RESET}") - print(f"\t Media Files: {file_count} (0 KB)") - print(f"\tNot in Library: {new_file_count} (0 KB)") - # print(f'\t Sidecar Files: 0 (0 KB)') - # print(f'\t Total Files: 0 (0 KB)') - print("") - print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Issues - {RESET}") - print(f"\t Missing Files: {missing_file_count}") - print(f"\tDuplicate Entries: {dupe_entry_count}") - print(f"\t Duplicate Files: {dupe_file_count}") - # print(f' Fixed Files: {WHITE_FG}{fixed_file_count}{RESET}') - print("") - print(f"\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}") - - print(f"\tBrowse Library: {WHITE_FG}browse | b{RESET}") - print(f"\tSearch Library: {WHITE_FG}search | s < query >{RESET}") - print( - f"\tList Info: {WHITE_FG}list | ls < dir | entires | tags | fields | macros | new | missing >{RESET}" - ) - print(f"\tAdd New Files to Library: {WHITE_FG}add new{RESET}") - print( - f"\tRefresh Info: {WHITE_FG}refresh | r < dir | missing | dupe entries | dupe files >{RESET}" - ) - print( - f"\tFix Issues: {WHITE_FG}fix < missing | dupe entries | dupe files > {RESET}" - ) - # print(f'\tHelp: {WHITE_FG}help | h{RESET}') - - print("") - print(f"\tSave Library: {WHITE_FG}save | backup{RESET}") - print(f"\tClose Library: {WHITE_FG}close | c{RESET}") - print(f"\tQuit TagStudio: {WHITE_FG}quit | q{RESET}") - # print(f'Quit Without Saving: {WHITE_FG}quit! | q!{RESET}') - print("") - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Refresh ============================================================== - if (com[0].lower() == "refresh" or com[0].lower() == "r") and len( - com - ) > 1: - if com[1].lower() == "files" or com[1].lower() == "dir": - print( - f"{INFO} Scanning for files in '{self.lib.library_dir}'..." - ) - self.lib.refresh_dir() - self.is_new_file_count_init = True - elif com[1].lower() == "missing": - print( - f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." - ) - self.lib.refresh_missing_files() - self.is_missing_count_init = True - elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": - if len(com) > 2: - if com[2].lower() == "entries" or com[2].lower() == "e": - print( - f"{INFO} Checking for duplicate entries in Library '{self.lib.library_dir}'..." - ) - self.lib.refresh_dupe_entries() - self.is_dupe_entry_count_init = True - elif com[2].lower() == "files" or com[2].lower() == "f": - print( - f"{WHITE_FG}Enter the filename for your DupeGuru results file:\n> {RESET}", - end="", - ) - dg_results_file = Path(input()) - print( - f"{INFO} Checking for duplicate files in Library '{self.lib.library_dir}'..." - ) - self.lib.refresh_dupe_files(dg_results_file) - self.is_dupe_file_count_init = True - else: - clear() - print( - f'{ERROR} Specify which duplicates to refresh (files, entries, all) \'{" ".join(com)}\'' - ) - clear_scr = False - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # List ================================================================= - elif (com[0].lower() == "list" or com[0].lower() == "ls") and len( - com - ) > 1: - if com[1].lower() == "entries": - for i, e in enumerate(self.lib.entries, start=0): - title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path / os.path.sep / self.lib.entries[i].filename}" - print( - self.format_subtitle( - title, - color=self.get_file_color( - os.path.splitext( - self.lib.entries[i].filename - )[1] - ), - ) - ) - self.print_fields(i) - print("") - time.sleep(0.05) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "new": - for i in self.lib.files_not_in_library: - print(i) - time.sleep(0.1) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "missing": - for i in self.lib.missing_files: - print(i) - time.sleep(0.1) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "fixed": - for i in self.lib.fixed_files: - print(i) - time.sleep(0.1) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "files" or com[1].lower() == "dir": - # NOTE: This doesn't actually print the directory files, it just prints - # files that are attached to Entries. Should be made consistent. - # print(self.lib.file_to_entry_index_map.keys()) - for key in self.lib.filename_to_entry_id_map.keys(): - print(key) - time.sleep(0.05) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": - if len(com) > 2: - if com[2].lower() == "entries" or com[2].lower() == "e": - for dupe in self.lib.dupe_entries: - print( - self.lib.entries[dupe[0]].path - / self.lib.entries[dupe[0]].filename - ) - for d in dupe[1]: - print( - f"\t-> {(self.lib.entries[d].path / self.lib.entries[d].filename)}" - ) - time.sleep(0.1) - print("Press Enter to Continue...") - input() - elif com[2].lower() == "files" or com[2].lower() == "f": - for dupe in self.lib.dupe_files: - print(dupe) - time.sleep(0.1) - print("Press Enter to Continue...") - input() - elif com[1].lower() == "tags": - self.scr_list_tags(tag_ids=self.lib.search_tags("")) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # Top ====================================================== - # Tags ----------------------------------------------------- - elif com[0].lower() == "top": - if len(com) > 1 and com[1].lower() == "tags": - self.lib.count_tag_entry_refs() - self.scr_top_tags() - # Browse =========================================================== - elif com[0].lower() == "browse" or com[0].lower() == "b": - if len(com) > 1: - if com[1].lower() == "entries": - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - else: - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - # Search =========================================================== - elif com[0].lower() == "search" or com[0].lower() == "s": - if len(com) > 1: - self.filtered_entries = self.lib.search_library( - " ".join(com[1:]) - ) - self.scr_browse_entries_gallery(0) - else: - self.scr_browse_entries_gallery(0) - # self.scr_library_home(clear_scr=False) - # Add New Entries ================================================== - elif " ".join(com) == "add new": - if not self.is_new_file_count_init: - print( - f"{INFO} Scanning for files in '{self.lib.library_dir}' (This may take a while)..." - ) - # if not self.lib.files_not_in_library: - self.lib.refresh_dir() - # self.is_new_file_count_init = False - new_ids: list[int] = self.lib.add_new_files_as_entries() - print( - f"{INFO} Running configured Macros on {len(new_ids)} new Entries..." - ) - for id in new_ids: - self.run_macro("autofill", id) - # print(f'{INFO} Scanning for files in \'{self.lib.library_dir}\' (This may take a while)...') - # self.lib.refresh_dir() - self.is_new_file_count_init = True - # self.scr_library_home() - # Fix ============================================================== - elif (com[0].lower() == "fix") and len(com) > 1: - if com[1].lower() == "missing": - subtitle = f"Fix Missing Files" - choices: list[(str, str)] = [ - ( - "Search with Manual & Automated Repair", - f"""Searches the Library directory ({self.lib.library_dir}) for files with the same name as the missing one(s), and automatically repairs Entries which only point to one matching file. If there are multiple filename matches for one Entry, a manual selection screen appears after any automatic repairing.\nRecommended if you moved files and don\'t have use strictly unique filenames in your Library directory.""", - ), - ( - "Search with Automated Repair Only", - "Same as above, only skipping the manual step.", - ), - ( - "Remove Entries", - """Removes Entries from the Library which point to missing files.\nOnly use if you know why a file is missing, and/or don\'t wish to keep that Entry\'s data.""", - ), - ] - prompt: str = "Choose how you want to repair Entries that point to missing files." - selection: int = self.scr_choose_option( - subtitle=subtitle, choices=choices, prompt=prompt - ) - - if selection >= 0 and not self.is_missing_count_init: - print( - f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." - ) - self.lib.refresh_missing_files() - - if selection == 0: - print( - f"{INFO} Attempting to resolve {len(self.lib.missing_files)} missing files in '{self.lib.library_dir}' (This will take long for several results)..." - ) - self.lib.fix_missing_files() - - fixed_indices = [] - if self.lib.missing_matches: - clear() - for unresolved in self.lib.missing_matches: - res = self.scr_choose_missing_match( - self.lib.get_entry_id_from_filepath( - unresolved - ), - clear_scr=False, - ) - if res is not None and int(res) >= 0: - clear() - print( - f"{INFO} Updated {self.lib.entries[self.lib.get_entry_id_from_filepath(unresolved)].path} -> {self.lib.missing_matches[unresolved][res]}" - ) - self.lib.entries[ - self.lib.get_entry_id_from_filepath( - unresolved - ) - ].path = self.lib.missing_matches[ - unresolved - ][res] - fixed_indices.append(unresolved) - elif res and int(res) < 0: - clear() - print( - f"{INFO} Skipped match resolution selection.." - ) - if self.args.external_preview: - self.set_external_preview_default() - self.lib.remove_missing_matches(fixed_indices) - elif selection == 1: - print( - f"{INFO} Attempting to resolve missing files in '{self.lib.library_dir}' (This may take a LOOOONG while)..." - ) - self.lib.fix_missing_files() - elif selection == 2: - print( - f"{WARNING} Remove all Entries pointing to missing files? (Y/N)\n>{RESET} ", - end="", - ) - confirmation = input() - if ( - confirmation.lower() == "y" - or confirmation.lower() == "yes" - ): - deleted = [] - for i, missing in enumerate(self.lib.missing_files): - print( - f"Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries" - ) - try: - id = self.lib.get_entry_id_from_filepath( - missing - ) - print( - f"Removing Entry ID {id}:\n\t{missing}" - ) - self.lib.remove_entry(id) - self.driver.purge_item_from_navigation( - ItemType.ENTRY, id - ) - deleted.append(missing) - except KeyError: - print( - f'{ERROR} "{id}" was reported as missing, but is not in the file_to_entry_id map.' - ) - for d in deleted: - self.lib.missing_files.remove(d) - # for missing in self.lib.missing_files: - # try: - # index = self.lib.get_entry_index_from_filename(missing) - # print(f'Removing Entry at Index [{index+1}/{len(self.lib.entries)}]:\n\t{missing}') - # self.lib.remove_entry(index) - # except KeyError: - # print( - # f'{ERROR} \"{index}\" was reported as missing, but is not in the file_to_entry_index map.') - - if selection >= 0: - print( - f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." - ) - self.lib.refresh_missing_files() - self.is_missing_count_init = True - - # Fix Duplicates =============================================================== - elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": - if len(com) > 2: - # Fix Duplicate Entries ---------------------------------------------------- - if com[2].lower() == "entries" or com[2].lower() == "e": - subtitle = f"Fix Duplicate Entries" - choices: list[(str, str)] = [ - ( - "Merge", - f"Each Entry pointing to the same file will have their data merged into a single remaining Entry.", - ) - ] - prompt: str = "Choose how you want to address groups of Entries which point to the same file." - selection: int = self.scr_choose_option( - subtitle=subtitle, - choices=choices, - prompt=prompt, - ) - - if selection == 0: - if self.is_dupe_entry_count_init: - print( - f"{WARNING} Are you sure you want to merge {len(self.lib.dupe_entries)} Entries? (Y/N)\n> ", - end="", - ) - else: - print( - f"{WARNING} Are you sure you want to merge any duplicate Entries? (Y/N)\n> ", - end="", - ) - confirmation = input() - if ( - confirmation.lower() == "y" - or confirmation.lower() == "yes" - ): - if not self.is_dupe_entry_count_init: - print( - f"{INFO} Checking for duplicate entries in Library '{self.lib.library_dir}'..." - ) - self.lib.refresh_dupe_entries() - self.lib.merge_dupe_entries() - self.is_dupe_entry_count_init = False - # Fix Duplicate Entries ---------------------------------------------------- - elif com[2].lower() == "files" or com[2].lower() == "f": - subtitle = f"Fix Duplicate Files" - choices: list[(str, str)] = [ - ( - "Mirror", - f"""For every predetermined duplicate file, mirror those files\' Entries with each other.\nMirroring involves merging all Entry field data together and then duplicating it across each Entry.\nThis process does not delete any Entries or files.""", - ) - ] - prompt: str = """Choose how you want to address handling data for files considered to be duplicates by an application such as DupeGuru. It\'s recommended that you mirror data here, then manually delete the duplicate files based on your own best judgement. Afterwards run \"fix missing\" and choose the \"Remove Entries\" option.""" - selection: int = self.scr_choose_option( - subtitle=subtitle, - choices=choices, - prompt=prompt, - ) - - if selection == 0: - if self.is_dupe_file_count_init: - print( - f"{WARNING} Are you sure you want to mirror Entry fields for {len(self.lib.dupe_files)} duplicate files? (Y/N)\n> ", - end="", - ) - else: - print( - f"{WARNING} Are you sure you want to mirror any Entry felids for duplicate files? (Y/N)\n> ", - end="", - ) - confirmation = input() - if ( - confirmation.lower() == "y" - or confirmation.lower() == "yes" - ): - print( - f"{INFO} Mirroring {len(self.lib.dupe_files)} Entries for duplicate files..." - ) - for i, dupe in enumerate( - self.lib.dupe_files - ): - entry_id_1 = ( - self.lib.get_entry_id_from_filepath( - dupe[0] - ) - ) - entry_id_2 = ( - self.lib.get_entry_id_from_filepath( - dupe[1] - ) - ) - self.lib.mirror_entry_fields( - [entry_id_1, entry_id_2] - ) - clear() - else: - clear() - print( - f'{ERROR} Invalid duplicate type "{" ".join(com[2:])}".' - ) - clear_scr = False - else: - clear() - print( - f'{ERROR} Specify which duplicates to fix (entries, files, etc) "{" ".join(com)}".' - ) - clear_scr = False - else: - clear() - print( - f'{ERROR} Invalid fix selection "{" ".join(com[1:])}". Try "fix missing", "fix dupe entries", etc.' - ) - clear_scr = False - # # Save to Disk ========================================================= - # elif com[0].lower() in ['save', 'write', 'w']: - # self.lib.save_library_to_disk() - # clear() - # print( - # f'{INFO} Library saved to disk.') - # clear_scr = False - # # Save Backup to Disk ========================================================= - # elif (com[0].lower() == 'backup'): - # self.backup_library() - # clear_scr = False - # Close ============================================================ - elif com[0].lower() == "close" or com[0].lower() == "c": - # self.core.clear_internal_vars() - self.close_library() - # clear() - return - # Unknown Command ================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # self.scr_library_home(clear_scr=False) - - def scr_browse_entries_gallery(self, index, clear_scr=True, refresh=True): - """Gallery View for browsing Library Entries.""" - - branch = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - while True: - # try: - if refresh: - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title)) - - if self.filtered_entries: - # entry = self.lib.get_entry_from_index( - # self.filtered_entries[index]) - entry = self.lib.get_entry(self.filtered_entries[index][1]) - filename = self.lib.library_dir / entry.path / entry.filename - # if self.lib.is_legacy_library: - # title += ' (Legacy Format)' - h1 = f"[{index + 1}/{len(self.filtered_entries)}] {filename}" - - # print(self.format_subtitle(subtitle)) - print( - self.format_h1(h1, self.get_file_color(filename.suffix.lower())) - ) - print("") - - if not filename.is_file(): - print( - f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" - ) - print("") - if self.args.external_preview: - self.set_external_preview_broken() - else: - self.print_thumbnail(self.filtered_entries[index][1]) - - self.print_fields(self.filtered_entries[index][1]) - else: - if self.lib.entries: - print( - self.format_h1( - "No Entry Results for Query", color=BRIGHT_RED_FG - ) - ) - self.set_external_preview_default() - else: - print( - self.format_h1("No Entries in Library", color=BRIGHT_RED_FG) - ) - self.set_external_preview_default() - print("") - - print("") - print( - self.format_subtitle( - "Prev Next Goto <#> Open File Search List Tags", - BRIGHT_MAGENTA_FG, - ) - ) - print( - self.format_subtitle( - "Add, Remove, Edit Remove Close Quit", - BRIGHT_MAGENTA_FG, - ) - ) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - # except IndexError: - # clear() - # print(f'{INFO} No matches found for query') - # # self.scr_library_home(clear_scr=False) - # # clear_scr=False - # return - - # Previous ============================================================= - if ( - com[0].lower() == "prev" - or com[0].lower() == "p" - or com[0].lower() == "previous" - ): - if len(com) > 1: - try: - # self.scr_browse_entries_gallery( - # (index - int(com[1])) % len(self.filtered_entries)) - # return - index = (index - int(com[1])) % len( - self.filtered_entries - ) - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print(f"{ERROR} Invalid \"Previous\" Index: '{com[1]}'") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - # self.scr_browse_entries_gallery( - # (index - 1) % len(self.filtered_entries)) - # return - index = (index - 1) % len(self.filtered_entries) - # Next ================================================================= - elif com[0].lower() == "next" or com[0].lower() == "n": - if len(com) > 1: - try: - # NOTE: Will returning this as-is instead of after screw up the try-catch? - index = (index + int(com[1])) % len( - self.filtered_entries - ) - # self.scr_browse_entries_gallery( - # (index + int(com[1])) % len(self.filtered_entries)) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print(f"{ERROR} Invalid \"Next\" Index: '{com[1]}'") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - # self.scr_browse_entries_gallery( - # (index + 1) % len(self.filtered_entries)) - # return - index = (index + 1) % len(self.filtered_entries) - # Goto ================================================================= - elif (com[0].lower() == "goto" or com[0].lower() == "g") and len( - com - ) > 1: - try: - if int(com[1]) - 1 < 0: - raise IndexError - if int(com[1]) > len(self.filtered_entries): - raise IndexError - # self.scr_browse_entries_gallery(int(com[1])-1) - # return - index = int(com[1]) - 1 - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print(f"{ERROR} Invalid \"Goto\" Index: '{com[1]}'") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # Search =============================================================== - elif com[0].lower() == "search" or com[0].lower() == "s": - if len(com) > 1: - self.filtered_entries = self.lib.search_library( - " ".join(com[1:]) - ) - # self.scr_browse_entries_gallery(0) - index = 0 - else: - self.filtered_entries = self.lib.search_library() - # self.scr_browse_entries_gallery(0) - index = 0 - # running = False - # return - # self.scr_library_home(clear_scr=False) - # return - # # Toggle Debug =========================================================== - # elif (com[0].lower() == 'toggle-debug'): - # self.args.debug = not self.args.debug - # Open with Default Application ======================================== - elif com[0].lower() == "open" or com[0].lower() == "o": - if len(com) > 1: - if com[1].lower() == "location" or com[1].lower() == "l": - open_file(filename, True) - else: - open_file(filename) - # refresh=False - # self.scr_browse_entries_gallery(index) - # Add Field ============================================================ - elif com[0].lower() == "add" or com[0].lower() == "a": - if len(com) > 1: - id_list = self.lib.filter_field_templates( - " ".join(com[1:]).lower() - ) - if id_list: - final_ids = [] - if len(id_list) == 1: - final_ids.append(id_list[0]) - else: - final_ids = self.scr_select_field_templates(id_list) - - for id in final_ids: - if id >= 0: - self.lib.add_field_to_entry( - self.filtered_entries[index][1], id - ) - # self.scr_browse_entries_gallery(index) - # return - # else: - # clear() - # print(f'{ERROR} Invalid selection.') - # return self.scr_browse_entries_gallery(index, clear_scr=False) - - else: - clear() - print(f"{INFO} Please specify a field to add.") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Remove Field ========================================================= - elif com[0].lower() == "remove" or com[0].lower() == "rm": - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry( - self.filtered_entries[index][1] - ).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int( - self.lib.get_field_attr(f, "id") - ) in self.lib.filter_field_templates( - " ".join(com[1:]).lower() - ): - field_indices.append(i) - - try: - final_field_index = -1 - # if len(field_indices) == 1: - # final_index = field_indices[0] - # NOTE: The difference between this loop and Edit is that it always asks - # you to specify the field, even if there is only one option. - if len(field_indices) >= 1: - print(field_indices) - print(entry_fields) - print( - [ - self.lib.get_field_attr( - entry_fields[x], "id" - ) - for x in field_indices - ] - ) - final_field_index = field_indices[ - self.scr_select_field_templates( - [ - self.lib.get_field_attr( - entry_fields[x], "id" - ) - for x in field_indices - ], - allow_multiple=False, - mode="remove", - return_index=True, - )[0] - ] - else: - clear() - print( - f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' - ) - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - pass - - if final_field_index >= 0: - self.lib.get_entry( - self.filtered_entries[index][1] - ).fields.pop(final_field_index) - # self.lib.entries[self.filtered_entries[index]].fields.pop( - # final_field_index) - else: - clear() - print(f"{INFO} Please specify a field to remove.") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Edit Field =========================================================== - elif com[0].lower() == "edit" or com[0].lower() == "e": - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry( - self.filtered_entries[index][1] - ).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int( - self.lib.get_field_attr(f, "id") - ) in self.lib.filter_field_templates( - " ".join(com[1:]).lower() - ): - field_indices.append(i) - - try: - final_field_index = -1 - if len(field_indices) == 1: - final_field_index = field_indices[0] - elif len(field_indices) > 1: - print(field_indices) - print(entry_fields) - print( - [ - self.lib.get_field_attr( - entry_fields[x], "id" - ) - for x in field_indices - ] - ) - final_field_index = field_indices[ - self.scr_select_field_templates( - [ - self.lib.get_field_attr( - entry_fields[x], "id" - ) - for x in field_indices - ], - allow_multiple=False, - mode="edit", - return_index=True, - )[0] - ] - else: - clear() - print( - f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' - ) - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - pass - - if final_field_index >= 0: - if ( - self.lib.get_field_attr( - entry_fields[final_field_index], "type" - ) - == "tag_box" - ): - self.scr_edit_entry_tag_box( - self.filtered_entries[index][1], - field_index=final_field_index, - ) - elif ( - self.lib.get_field_attr( - entry_fields[final_field_index], "type" - ) - == "text_line" - ): - self.scr_edit_entry_text( - self.filtered_entries[index][1], - field_index=final_field_index, - allow_newlines=False, - ) - elif ( - self.lib.get_field_attr( - entry_fields[final_field_index], "type" - ) - == "text_box" - ): - self.scr_edit_entry_text( - self.filtered_entries[index][1], - field_index=final_field_index, - ) - else: - clear() - print( - f'{INFO} Sorry, this type of field ({self.lib.get_field_attr(entry_fields[final_field_index], "type")}) isn\'t editable yet.' - ) - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - clear() - print(f"{INFO} Please specify a field to edit.") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Copy Field =========================================================== - elif com[0].lower() == "copy" or com[0].lower() == "cp": - # NOTE: Nearly identical code to the Edit section. - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry( - self.filtered_entries[index][1] - ).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int( - self.lib.get_field_attr(f, "id") - ) in self.lib.filter_field_templates( - " ".join(com[1:]).lower() - ): - field_indices.append(i) - - # try: - final_field_index = -1 - if len(field_indices) == 1: - final_field_index = field_indices[0] - elif len(field_indices) > 1: - print(field_indices) - print(entry_fields) - print( - [ - self.lib.get_field_attr(entry_fields[x], "id") - for x in field_indices - ] - ) - final_field_index = field_indices[ - self.scr_select_field_templates( - [ - self.lib.get_field_attr( - entry_fields[x], "id" - ) - for x in field_indices - ], - allow_multiple=False, - mode="edit", - return_index=True, - )[0] - ] - else: - clear() - print( - f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' - ) - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - # except: - # pass - - if final_field_index >= 0: - self.copy_field_to_buffer( - entry.fields[final_field_index] - ) - # refresh = False - else: - clear() - print(f"{INFO} Please specify a field to copy.") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Paste Field =========================================================== - elif com[0].lower() == "paste" or com[0].lower() == "ps": - self.paste_field_from_buffer(self.filtered_entries[index][1]) - # self.scr_browse_entries_gallery(index) - # return - # Run Macro ============================================================ - elif len(com) > 1 and com[0].lower() == "run": - if len(com) > 2 and com[1].lower() == "macro": - macro_name = (com[2]).lower() - if len(com) > 3: - # Run on all filtered Entries - if ( - com[-1].lower() == "--all" - or com[-1].lower() == "-a" - ): - clear() - print( - f'{INFO} Running Macro "{macro_name}" on {len(self.filtered_entries)} Entries...' - ) - for type, id in self.filtered_entries: - self.run_macro(name=macro_name, entry_id=id) - # self.scr_browse_entries_gallery(index) - else: - # Run on current Entry - self.run_macro( - name=macro_name, - entry_id=self.filtered_entries[index][1], - ) - # self.scr_browse_entries_gallery(index) - # return - else: - clear() - print(f"{ERROR} Please specify a Macro to run.") - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # List Tags ============================================================ - elif (com[0].lower() == "list" or com[0].lower() == "ls") and len( - com - ) > 1: - if com[1].lower() == "tags": - clear() - self.scr_list_tags(tag_ids=self.lib.search_tags("")) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # self.scr_browse_entries_gallery(index, clear_scr=False) - - # return - # # Save to Disk ========================================================= - # elif (com[0].lower() == 'save' or com[0].lower() == 'write' or com[0].lower() == 'w'): - # self.lib.save_library_to_disk() - # clear() - # print( - # f'{INFO} Library saved to disk.') - # # self.scr_browse_entries_gallery(index, clear_scr=False) - # clear_scr = False - # # return - # # Save Backup to Disk ========================================================= - # elif (com[0].lower() == 'backup'): - # clear() - # self.backup_library() - # clear_scr = False - # Close View =========================================================== - elif com[0].lower() == "close" or com[0].lower() == "c": - if self.args.external_preview: - self.set_external_preview_default() - # self.scr_library_home() - clear() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - elif com: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - - def scr_choose_option( - self, - subtitle: str, - choices: list, - prompt: str = "", - required=False, - clear_scr=True, - ) -> int: - """ - Screen for choosing one of a given set of generic options. - Takes in a list of (str,str) tuples which consist of (option name, option description), - with the description being optional. - Returns the index of the selected choice (starting at 0), or -1 if the choice was '0', 'Cancel', or 'C'. - """ - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - # invalid_input: bool = False - - while True: - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - # if invalid_input: - # print(self.format_h1( - # str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) - # invalid_input = False - print("") - if prompt: - print(prompt) - print("") - print("") - - for i, choice in enumerate(choices, start=1): - print( - f"{BRIGHT_WHITE_BG}{BLACK_FG}[{str(i).zfill(len(str(len(choices))))}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {choice[0]} {RESET}" - ) - if choice[1]: - print(f"{WHITE_FG}{choice[1]}{RESET}") - print("") - - if not required: - print("") - print( - f"{BRIGHT_WHITE_BG}{BLACK_FG}[0]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} Cancel {RESET}" - ) - - print("") - if not required: - print( - self.format_subtitle("<#> 0 or Cancel Quit", BRIGHT_CYAN_FG) - ) - else: - print(self.format_subtitle("<#> Quit", BRIGHT_CYAN_FG)) - print("> ", end="") - - com: list[str] = input().strip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - com_name = com[0].lower() - - try: - # # Quit ========================================================= - # if com.lower() == 'quit' or com.lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ========================================== - # elif com.lower() == 'quit!' or com.lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Cancel ======================================================= - if com_name in ("cancel", "c", "0") and not required: - clear() - return -1 - # Selection ==================================================== - elif com_name.isdigit() and 0 < int(com_name) <= len(choices): - clear() - return int(com_name) - 1 - else: - # invalid_input = True - # print(self.format_h1(str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) - clear() - print(f"{ERROR} Please Enter a Valid Selection Number/Option.") - clear_scr = False - except (TypeError, ValueError): - clear() - print(f"{ERROR} Please Enter a Valid Selection Number/Option.") - clear_scr = False - - def scr_choose_missing_match(self, index, clear_scr=True, refresh=True) -> int: - """ - Screen for manually resolving a missing file. - Returns the index of the choice made (starting at 0), or -1 if skipped. - """ - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - subtitle = f"Resolve Missing File Conflict" - - while True: - entry = self.lib.get_entry_from_index(index) - filename = self.lib.library_dir / entry.path / entry.filename - - if refresh: - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - print("") - print(self.format_h1(filename, BRIGHT_RED_FG), end="\n\n") - - self.print_fields(index) - - for i, match in enumerate(self.lib.missing_matches[filename]): - print(self.format_h1(f"[{i+1}] {match}"), end="\n\n") - fn = self.lib.library_dir / match / entry.filename - self.print_thumbnail( - index=-1, - filepath=fn, - max_width=( - os.get_terminal_size()[1] - // len(self.lib.missing_matches[filename]) - - 2 - ), - ) - if fn in self.lib.filename_to_entry_id_map.keys(): - self.print_fields(self.lib.get_entry_id_from_filepath(fn)) - print("") - print( - self.format_subtitle( - "<#> 0 to Skip Open Files Quit", BRIGHT_CYAN_FG - ) - ) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Refresh ============================================================== - if com[0].lower() == "refresh" or com[0].lower() == "r": - # if (com[0].lower() == 'refresh' or com[0].lower() == 'r') and len(com) > 1: - # if com[1].lower() == 'files' or com[1].lower() == 'dir': - # clear() - # return self.scr_choose_missing_match(index) - # else: - # clear() - # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_library_home(clear_scr=False) - # clear_scr=False - pass - # Open ============================================================= - elif com[0].lower() == "open" or com[0].lower() == "o": - for match in self.lib.missing_matches[filename]: - fn = self.lib.library_dir / match / entry.filename - open_file(fn) - refresh = False - # clear() - # return self.scr_choose_missing_match(index, clear_scr=False) - # # Quit ============================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ============================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Selection/Other ================================================== - else: - try: - i = int(com[0]) - 1 - if i < len(self.lib.missing_matches[filename]): - if i < -1: - return -1 - else: - return i - else: - raise IndexError - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (ValueError, IndexError): - clear() - print(f'{ERROR} Invalid command \'{" ".join(com)}\'') - # return self.scr_choose_missing_match(index, clear_scr=False) - clear_scr = False - - def scr_resolve_dupe_files(self, index, clear_scr=True): - """Screen for manually resolving duplicate files.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - subtitle = f"Resolve Duplicate Files" - - while True: - dupe = self.lib.dupe_files[index] - - if dupe[0].exists() and dupe[1].exists(): - # entry = self.lib.get_entry_from_index(index_1) - entry_1_index = self.lib.get_entry_id_from_filepath(dupe[0]) - entry_2_index = self.lib.get_entry_id_from_filepath(dupe[1]) - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - - print("") - print(f"{WHITE_BG}{BLACK_FG} Similarity: {RESET} ", end="") - print(f"{dupe[2]}%") - - # File 1 - print("") - print(self.format_h1(dupe[0], BRIGHT_RED_FG), end="\n\n") - print(f"{WHITE_BG}{BLACK_FG} File Size: {RESET} ", end="") - print(f"0 KB") - print(f"{WHITE_BG}{BLACK_FG} Resolution: {RESET} ", end="") - print(f"0x0") - if entry_1_index is not None: - print("") - self.print_fields(entry_1_index) - else: - print(f"{BRIGHT_RED_FG}No Library Entry for file.{RESET}") - - # File 2 - print("") - print(self.format_h1(dupe[1], BRIGHT_RED_FG), end="\n\n") - print(f"{WHITE_BG}{BLACK_FG} File Size: {RESET} ", end="") - print(f"0 KB") - print(f"{WHITE_BG}{BLACK_FG} Resolution: {RESET} ", end="") - print(f"0x0") - if entry_2_index is not None: - print("") - self.print_fields(entry_2_index) - else: - print(f"{BRIGHT_RED_FG}No Library Entry for file.{RESET}") - - # for i, match in enumerate(self.lib.missing_matches[filename]): - # print(self.format_h1(f'[{i+1}] {match}'), end='\n\n') - # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' - # self.print_thumbnail(self.lib.get_entry_from_filename(fn), - # max_width=(os.get_terminal_size()[1]//len(self.lib.missing_matches[filename])-2)) - # self.print_fields(self.lib.get_entry_from_filename(fn)) - print("") - print( - self.format_subtitle( - "Mirror Delete <#> Skip Close Open Files Quit", - BRIGHT_CYAN_FG, - ) - ) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Refresh ========================================================== - if (com[0].lower() == "refresh" or com[0].lower() == "r") and len( - com - ) > 1: - # if com[1].lower() == 'files' or com[1].lower() == 'dir': - # clear() - # return self.scr_resolve_dupe_files(index, clear_scr=True) - # else: - # clear() - # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_library_home(clear_scr=False) - pass - # Open ============================================================= - elif com[0].lower() == "open" or com[0].lower() == "o": - # for match in self.lib.missing_matches[filename]: - # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' - # open_file(fn) - open_file(dupe[0]) - open_file(dupe[1]) - # clear() - # return self.scr_resolve_dupe_files(index, clear_scr=False) - # Mirror Entries =================================================== - elif com[0].lower() == "mirror" or com[0].lower() == "mir": - return com - # Skip ============================================================ - elif com[0].lower() == "skip": - return com - # Skip ============================================================ - elif ( - com[0].lower() == "close" - or com[0].lower() == "cancel" - or com[0].lower() == "c" - ): - return ["close"] - # Delete =========================================================== - elif com[0].lower() == "delete" or com[0].lower() == "del": - if len(com) > 1: - if com[1] == "1": - return ["del", 1] - elif com[1] == "2": - return ["del", 2] - else: - # return self.scr_resolve_dupe_files(index) - pass - else: - clear() - print( - f"{ERROR} Please specify which file (ex. delete 1, delete 2) to delete file." - ) - # return self.scr_resolve_dupe_files(index, clear_scr=False) - clear_scr = False - # # Quit ============================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ============================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Other ============================================================ - else: - # try: - # i = int(com[0]) - 1 - # if i < len(self.lib.missing_matches[filename]): - # return i - # else: - # raise IndexError - # except SystemExit: - # sys.exit() - # except: - clear() - print(f'{ERROR} Invalid command \'{" ".join(com)}\'') - # return self.scr_resolve_dupe_files(index, clear_scr=False) - clear_scr = False - - def scr_edit_entry_tag_box(self, entry_index, field_index, clear_scr=True): - """Screen for editing an Entry tag-box field.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - entry = self.lib.entries[entry_index] - filename = self.lib.library_dir / entry.path / entry.filename - field_name = self.lib.get_field_attr(entry.fields[field_index], "name") - subtitle = f'Editing "{field_name}" Field' - h1 = f"{filename}" - - while True: - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - print( - self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1])) - ) - print("") - - if not filename.is_file(): - print( - f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" - ) - print("") - else: - self.print_thumbnail(entry_index) - - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ") - for i, tag_id in enumerate( - entry.fields[field_index][list(entry.fields[field_index].keys())[0]] - ): - tag = self.lib.get_tag(tag_id) - print( - f"{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}" - ) - # if tag_id != field[field_id][-1]: - # print(' ', end='') - print("") - - print( - self.format_subtitle( - "Add Remove <#> Open File Close/Done Quit" - ) - ) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Open with Default Application ======================================== - if com[0].lower() == "open" or com[0].lower() == "o": - open_file(filename) - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Close View =========================================================== - elif ( - com[0].lower() == "close" - or com[0].lower() == "c" - or com[0].lower() == "done" - ): - # self.scr_browse_entries_gallery() - clear() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Add Tag ============================================================== - elif com[0].lower() == "add": - if len(com) > 1: - tag_list = self.lib.search_tags( - " ".join(com[1:]), include_cluster=True - ) - t: list[int] = [] - if len(tag_list) > 1: - t = self.scr_select_tags(tag_list) - else: - t = tag_list # Single Tag - if t: - self.lib.update_entry_field( - entry_index, field_index, content=t, mode="append" - ) - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Remove Tag =========================================================== - elif com[0].lower() == "remove" or com[0].lower() == "rm": - if len(com) > 1: - try: - selected_tag_ids: list[int] = [] - for c in com[1:]: - if (int(c) - 1) < 0: - raise IndexError - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')[int(c)-1]) - selected_tag_ids.append( - self.lib.get_field_attr( - entry.fields[field_index], "content" - )[int(c) - 1] - ) - # i = int(com[1]) - 1 - - # tag = entry.fields[field_index][list( - # entry.fields[field_index].keys())[0]][i] - self.lib.update_entry_field( - entry_index, - field_index, - content=selected_tag_ids, - mode="remove", - ) - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - clear() - print(f"{ERROR} Invalid Tag Selection '{com[1:]}'") - clear_scr = False - # self.scr_edit_entry_tag_box( - # entry_index, field_index, clear_scr=False) - # return - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_edit_entry_tag_box( - # entry_index, field_index, clear_scr=False) - # return - clear_scr = False - - def scr_select_tags(self, tag_ids: list[int], clear_scr=True) -> list[int]: - """Screen for selecting and returning one or more Tags. Used for Entry editing.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - subtitle = f"Select Tag(s) to Add" - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_GREEN_BG}")) - print(self.format_subtitle(subtitle, BRIGHT_GREEN_FG)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - print("") - - tag_tuple_list = [] - for tag_id in tag_ids: - tag = self.lib.get_tag(tag_id) - tag_tuple_list.append( - (tag.display_name(self.lib), self.get_tag_color(tag.color)) - ) - - self.print_columns(tag_tuple_list, add_enum=True) - print("") - - print(self.format_subtitle("Enter #(s) Cancel", BRIGHT_GREEN_FG)) - print("> ", end="") - - com: list[str] = input().rstrip().split(" ") - selected_ids: list[int] = [] - try: - for c in com: - selected_ids.append(tag_ids[int(c) - 1]) - except SystemExit: - self.cleanup_before_exit() - sys.exit() - except: - print(f"{ERROR} Invalid Tag Selection") - - return selected_ids - - # TODO: This can be replaced by the new scr_choose_option method. - def scr_select_field_templates( - self, - field_ids: list[int], - allow_multiple=True, - mode="add", - return_index=False, - clear_scr=True, - ) -> list[int]: - """ - Screen for selecting and returning one or more Field Templates. Used for Entry editing. - Allow Multiple: Lets the user select multiple items, returned in a list. If false, returns a list of only the first selected item. - Mode: 'add', 'edit', 'remove' - Changes prompt text and colors. - Return Index: Instead of returning the Field IDs that were selected, this returns the indices of the selected items from the given list. - """ - - branch = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" - title = ( - f"TagStudio {VERSION}{branch} - CLI Mode - Library '{self.lib.library_dir}'" - ) - subtitle = f"Select Field(s) to Add" - plural = "(s)" - - if not allow_multiple: - plural = "" - - fg_text_color = BLACK_FG - fg_color = BRIGHT_GREEN_FG - bg_color = BRIGHT_GREEN_BG - if mode == "edit": - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - subtitle = f"Select Field{plural} to Edit" - elif mode == "remove": - fg_color = BRIGHT_RED_FG - bg_color = BRIGHT_RED_BG - # fg_text_color = BRIGHT_WHITE_FG - subtitle = f"Select Field{plural} to Remove" - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) - print(self.format_subtitle(subtitle, fg_color)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - print("") - - for i, field_id in enumerate(field_ids): - name = self.lib.get_field_obj(field_id)["name"] - type = self.lib.get_field_obj(field_id)["type"] - if i < (os.get_terminal_size()[1] - 7): - print( - f"{BRIGHT_WHITE_BG}{BLACK_FG}[{i+1}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {name} ({type}) {RESET}" - ) - else: - print(f"{WHITE_FG}[...]{RESET}") - break - print("") - - print(self.format_subtitle(f"Enter #{plural} Cancel", fg_color)) - print("> ", end="") - - com: list[str] = input().split(" ") - selected_ids: list[int] = [] - try: - for c in com: - if int(c) > 0: - if return_index: - selected_ids.append(int(c) - 1) - else: - selected_ids.append(field_ids[int(c) - 1]) - except SystemExit: - self.cleanup_before_exit() - sys.exit() - except: - print(f"{ERROR} Invalid Tag Selection") - - if not allow_multiple and selected_ids: - return [selected_ids[0]] - return selected_ids - - def scr_edit_entry_text( - self, entry_index, field_index, allow_newlines=True, clear_scr=True - ): - """Screen for editing an Entry text_line field.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - entry = self.lib.entries[entry_index] - filename = self.lib.library_dir / entry.path / entry.filename - field_name = self.lib.get_field_attr(entry.fields[field_index], "name") - subtitle = f'Editing "{field_name}" Field' - h1 = f"{filename}" - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - print(self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1]))) - print("") - - if not filename.is_file(): - print( - f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" - ) - print("") - else: - self.print_thumbnail(entry_index, ignore_fields=True) - - print( - self.format_title( - "Opened with Default Text Editor", f"{BLACK_FG}{BRIGHT_CYAN_BG}" - ) - ) - # print('') - # print( - # f'{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ') - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) - # for i, tag_id in enumerate(entry.fields[field_index][list(entry.fields[field_index].keys())[0]]): - # tag = self.lib.get_tag_from_id(tag_id) - # print( - # f'{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}') - # print('') - - # print(self.format_subtitle( - # 'Add Remove <#> Open File Close/Done Quit')) - - # new_content: str = click.edit(self.lib.get_field_attr( - # entry.fields[field_index], 'content')) - new_content: str = "" # NOTE: Removing - if new_content is not None: - if not allow_newlines: - new_content = new_content.replace("\r", "").replace("\n", "") - self.lib.update_entry_field( - entry_index, - field_index, - new_content.rstrip("\n").rstrip("\r"), - "replace", - ) - - def scr_list_tags( - self, query: str = "", tag_ids: list[int] = None, clear_scr=True - ) -> None: - """A screen for listing out and performing CRUD operations on Library Tags.""" - # NOTE: While a screen that just displays the first 40 or so random tags on your screen - # isn't really that useful, this is just a temporary measure to provide a launchpad - # screen for necessary commands such as adding and editing tags. - # A more useful screen presentation might look like a list of ranked occurrences, but - # that can be figured out and implemented later. - tag_ids = tag_ids or [] - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - while True: - h1 = f"{len(self.lib.tags)} Tags" - - if tag_ids: - if len(tag_ids) < len(self.lib.search_tags("")): - h1 = f"[{len(tag_ids)}/{len(self.lib.tags)}] Tags" - if query: - h1 += f" connected to '{query}'" - else: - h1 = f"No Tags" - if query: - h1 += f" connected to '{query}'" - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title)) - print(self.format_h1(h1)) - print("") - - tag_tuple_list = [] - for tag_id in tag_ids: - tag = self.lib.get_tag(tag_id) - if self.args.debug: - tag_tuple_list.append( - (tag.debug_name(), self.get_tag_color(tag.color)) - ) - else: - tag_tuple_list.append( - (tag.display_name(self.lib), self.get_tag_color(tag.color)) - ) - - self.print_columns(tag_tuple_list, add_enum=True) - - print("") - print( - self.format_subtitle( - "Create Edit <#> Delete <#> Search Close/Done", - BRIGHT_MAGENTA_FG, - ) - ) - print("> ", end="") - - com: list[str] = input().strip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - com_name = com[0].lower() - # Search Tags ========================================================== - if com_name in ("search", "s"): - if len(com) > 1: - new_query: str = " ".join(com[1:]) - # self.scr_list_tags(prev_scr, query=new_query, - # tag_ids=self.lib.filter_tags(new_query, include_cluster=True)) - query = new_query - tag_ids = self.lib.search_tags(new_query, include_cluster=True) - # return - else: - # self.scr_list_tags(prev_scr, tag_ids=self.lib.filter_tags('')) - tag_ids = self.lib.search_tags("") - # return - # Edit Tag =========================================================== - elif com_name in ("edit", "e"): - if len(com) > 1: - try: - index = int(com[1]) - 1 - if index < 0: - raise IndexError - self.scr_manage_tag(tag_ids[index]) - - # Refilter in case edits change results - tag_ids = self.lib.search_tags(query, include_cluster=True) - # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (ValueError, IndexError): - clear() - print(f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'') - clear_scr = False - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - - # Create Tag ============================================================ - elif com_name in ("create", "mk"): - tag = Tag( - id=0, - name="New Tag", - shorthand="", - aliases=[], - subtags_ids=[], - color="", - ) - self.scr_manage_tag(self.lib.add_tag_to_library(tag), mode="create") - - tag_ids = self.lib.search_tags(query, include_cluster=True) - - # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) - # return - # Delete Tag =========================================================== - elif com_name in ("delete", "del"): - if len(com) > 1: - if len(com) > 1: - try: - index = int(com[1]) - 1 - if index < 0: - raise IndexError - deleted = self.scr_delete_tag(tag_ids[index]) - if deleted: - tag_ids.remove(tag_ids[index]) - tag_ids = self.lib.search_tags( - query, include_cluster=True - ) - # self.scr_list_tags( - # prev_scr, query=query, tag_ids=tag_ids) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - clear() - print( - f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'' - ) - clear_scr = False - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - # Close View =========================================================== - elif com_name in ("close", "c", "done"): - # prev_scr() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - clear_scr = False - - def scr_top_tags(self, clear_scr=True) -> None: - """A screen that lists out the top tags for the library.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - while True: - h1 = f"Top Tags" - - # if tag_ids: - # if len(tag_ids) < len(self.lib.filter_tags('')): - # h1 = f'[{len(tag_ids)}/{len(self.lib.tags)}] Tags' - # if query: - # h1 += f' connected to \'{query}\'' - # else: - # h1 = f'No Tags' - # if query: - # h1 += f' connected to \'{query}\'' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title)) - print(self.format_h1(h1)) - print("") - - tag_tuple_list = [] - for tag_id, count in self.lib.tag_entry_refs: - tag = self.lib.get_tag(tag_id) - if self.args.debug: - tag_tuple_list.append( - (f"{tag.debug_name()} - {count}", self.get_tag_color(tag.color)) - ) - else: - tag_tuple_list.append( - ( - f"{tag.display_name(self.lib)} - {count}", - self.get_tag_color(tag.color), - ) - ) - - self.print_columns(tag_tuple_list, add_enum=True) - - print("") - print(self.format_subtitle("Close/Done", BRIGHT_MAGENTA_FG)) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Close View =================================================== - if ( - com[0].lower() == "close" - or com[0].lower() == "c" - or com[0].lower() == "done" - ): - return - # Unknown Command ============================================== - elif com[0]: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - - def scr_manage_tag(self, tag_id: int, mode="edit", clear_scr=True): - """Screen for editing fields of a Tag object.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - while True: - tag: Tag = self.lib.get_tag(tag_id) - subtitle = ( - f'Editing Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' - ) - # h1 = f'{self.lib.tags[tag_index].display_name()}' - - fg_text_color = BLACK_FG - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - if mode == "create": - subtitle = ( - f'Creating Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' - ) - fg_color = BRIGHT_GREEN_FG - bg_color = BRIGHT_GREEN_BG - # elif mode == 'remove': - # # TODO: Uhh is this ever going to get used? Delete this when you know. - # subtitle = f'Removing Tag \"{self.lib.get_tag_from_id(tag_id).display_name(self.lib)}\"' - # fg_color = BRIGHT_RED_FG - # bg_color = BRIGHT_RED_BG - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) - print(self.format_subtitle(subtitle, fg_color)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - if self.args.debug: - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ", end="") - print(tag.id) - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ", end="") - print(tag.name) - - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ", end="") - print(tag.shorthand) - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ", end="\n") - for a in tag.aliases: - print(f"{a}") - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ", end="\n") - char_count: int = 0 - for id in tag.subtag_ids: - st = self.lib.get_tag(id) - # Properly wrap Tags on screen - char_count += len(f" {st.display_name(self.lib)} ") + 1 - if char_count > os.get_terminal_size()[0]: - print("") - char_count = len(f" {st.display_name(self.lib)} ") + 1 - print( - f"{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}", - end="", - ) - # If the tag isn't the last one, print a space for the next one. - if id != tag.subtag_ids[-1]: - print(" ", end="") - else: - print("") - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ", end="") - print(f"{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}") - - print("") - print(self.format_subtitle("Edit Close/Done", fg_color)) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Edit Tag Field ======================================================= - if com[0].lower() == "edit" or com[0].lower() == "e": - if len(com) > 1: - selection: str = " ".join(com[1:]).lower() - if "id".startswith(selection) and self.args.debug: - clear() - print(f"{ERROR} Tag IDs are not editable.") - clear_scr = False - elif "name".startswith(selection): - new_name: str = self.scr_edit_text( - text=tag.name, field_name="Name", allow_newlines=False - ) - new_tag: Tag = Tag( - id=tag.id, - name=new_name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=tag.color, - ) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif "shorthand".startswith(selection): - new_shorthand: str = self.scr_edit_text( - text=tag.shorthand, - field_name="Shorthand", - allow_newlines=False, - ) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=new_shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=tag.color, - ) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif "aliases".startswith(selection): - new_aliases: list[str] = self.scr_edit_text( - text="\n".join(tag.aliases), - field_name="Aliases", - note=f"# Tag Aliases Below Are Separated By Newlines", - allow_newlines=True, - ).split("\n") - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=new_aliases, - subtags_ids=tag.subtag_ids, - color=tag.color, - ) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif "subtags".startswith(selection): - new_subtag_ids: list[int] = self.scr_edit_generic_tag_box( - tag_ids=tag.subtag_ids, tag_box_name="Subtags" - ) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=new_subtag_ids, - color=tag.color, - ) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif "color".startswith(selection): - new_color: str = self.scr_tag_color_dropdown( - fallback=tag.color, colors=TAG_COLORS - ) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=new_color, - ) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - else: - clear() - print(f'{ERROR} Unknown Tag field "{" ".join(com[1:])}".') - # self.scr_manage_tag(tag_id, mode, clear_scr=False) - # return - clear_scr = False - # Close View =========================================================== - elif ( - com[0].lower() == "close" - or com[0].lower() == "done" - or com[0].lower() == "c" - ): - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # return self.scr_browse_entries_gallery(index, clear_scr=False) - - def scr_delete_tag(self, tag_id: int, clear_scr=True) -> bool: - """Screen for confirming the deletion of a Tag.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - tag: Tag = self.lib.get_tag(tag_id) - subtitle = f'Confirm Deletion of Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' - # h1 = f'{self.lib.tags[tag_index].display_name()}' - entry_ref_count, subtag_ref_count = self.lib.get_tag_ref_count(tag_id) - - fg_text_color = BLACK_FG - fg_color = BRIGHT_RED_FG - bg_color = BRIGHT_RED_BG - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) - print(self.format_subtitle(subtitle, fg_color)) - print("") - - print( - f"{INFO} {BRIGHT_WHITE_FG}This Tag is in {fg_color}{entry_ref_count}{RESET}{BRIGHT_WHITE_FG} Entries{RESET} ", - end="", - ) - print("") - - print( - f"{INFO} {BRIGHT_WHITE_FG}This Tag is a Subtag for {fg_color}{subtag_ref_count}{RESET}{BRIGHT_WHITE_FG} Tags{RESET} ", - end="", - ) - print("") - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ", end="") - print(tag.name) - - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ", end="") - print(tag.shorthand) - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ", end="\n") - for a in tag.aliases: - print(f"{a}") - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ", end="\n") - char_count: int = 0 - for id in tag.subtag_ids: - st = self.lib.get_tag(id) - # Properly wrap Tags on screen - char_count += len(f" {st.display_name(self.lib)} ") + 1 - if char_count > os.get_terminal_size()[0]: - print("") - char_count = len(f" {st.display_name(self.lib)} ") + 1 - print( - f"{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}", - end="", - ) - # If the tag isn't the last one, print a space for the next one. - if id != tag.subtag_ids[-1]: - print(" ", end="") - else: - print("") - - print("") - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ", end="") - print(f"{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}") - - print("") - print(self.format_subtitle("Yes Cancel", fg_color)) - print("> ", end="") - - com: str = input().rstrip() - - if com.lower() == "yes" or com.lower() == "y": - self.lib.remove_tag(tag_id) - return True - - return False - - def scr_edit_text( - self, - text: str, - field_name: str, - note: str = "", - allow_newlines=True, - clear_scr=True, - ) -> str: - """ - Screen for editing generic text. Currently used in Tag editing.\n - `text`: The text to be edited and returned.\n - `field_name`: The name to display of what is being edited.\n - `note`: An optional help message to display on screen for users..\n - `allow_newlines`: Determines if the text should be allowed to contain newlines.\n - """ - # NOTE: This code is derived from scr_edit_entry_text, just without the - # specific entry stuff like filenames and preview images. There may be - # a good way to combine the methods in the future, but for now here's this. - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - subtitle = f'Editing "{field_name}"' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - print("") - - print( - self.format_title( - "Opened with Default Text Editor", f"{BLACK_FG}{BRIGHT_CYAN_BG}" - ) - ) - - # new_text: str = click.edit(text) - new_text: str = input() - if new_text is not None: - if not allow_newlines: - new_text = new_text.replace("\r", "").replace("\n", "") - else: - new_text = new_text.rstrip("\n").rstrip("\r") - return new_text - return text - - def scr_tag_color_dropdown( - self, fallback: str, colors: list[str], clear_scr=True - ) -> str: - """ - Screen for selecting and returning a string of a color name. Used in Tag editing. - Fallback: The value to return if an invalid selection by the user was made. - """ - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - subtitle = f"Select Color" - - fg_text_color = BLACK_FG - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) - print(self.format_subtitle(subtitle, fg_color)) - print("") - - color_tuple_list = [] - for color in colors: - color_tuple_list.append((color.title(), self.get_tag_color(color))) - - self.print_columns(color_tuple_list, add_enum=True) - print("") - - # for i, color in enumerate(colors): - # if i < (os.get_terminal_size()[1] - 7): - # print( - # f'{self.get_tag_color(color)}[{i+1}]{RESET} {self.get_tag_color(color)} {color.title()} {RESET}') - # else: - # print(f'{WHITE_FG}[...]{RESET}') - # break - # print('') - - print(self.format_subtitle(f"Enter # Cancel", fg_color)) - print("> ", end="") - - selected: str = input() - try: - if selected.isdigit() and 0 < int(selected) <= len(colors): - selected = colors[int(selected) - 1] - return selected - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - print(f"{ERROR} Invalid Tag Selection") - - return fallback - - def scr_edit_generic_tag_box( - self, tag_ids: list[int], tag_box_name: str, clear_scr=True - ) -> list[int]: - """Screen for editing a generic tag_box. Used in Tag subtag modification.""" - - title = f"{self.base_title} - Library '{self.lib.library_dir}'" - - while True: - subtitle = f"Editing {tag_box_name}" - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) - print(self.format_subtitle(subtitle)) - print("") - - print(f"{BRIGHT_WHITE_BG}{BLACK_FG} {tag_box_name}: {RESET} ") - for i, id in enumerate(tag_ids): - tag = self.lib.get_tag(id) - print( - f"{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}" - ) - print("") - - print( - self.format_subtitle( - "Add Remove <#> Close/Done Quit" - ) - ) - print("> ", end="") - - com: list[str] = input().lstrip().rstrip().split(" ") - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr = False - else: - # Add Tag ============================================================== - if com[0].lower() == "add": - if len(com) > 1: - tag_list = self.lib.search_tags( - " ".join(com[1:]), include_cluster=True - ) - selected_ids: list[int] = [] - if len(tag_list) > 1: - selected_ids = self.scr_select_tags(tag_list) - else: - selected_ids = tag_list # Single Tag - if selected_ids: - for id in selected_ids: - if id in tag_ids: - selected_ids.remove(id) - return self.scr_edit_generic_tag_box( - tag_ids + selected_ids, tag_box_name - ) - tag_ids = tag_ids + selected_ids - # else: - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) - # Remove Tag =========================================================== - elif com[0].lower() == "remove" or com[0].lower() == "rm": - if len(com) > 1: - try: - # selected_tag_ids: list[int] = [] - # for c in com[1:]: - # if (int(c)-1) < 0: - # raise IndexError - # selected_tag_ids.append(tag_ids[int(c[1])-1]) - selected_id = tag_ids[int(com[1]) - 1] - tag_ids.remove(selected_id) - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - clear() - print(f"{ERROR} Invalid Tag Selection '{com[1:]}'") - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) - clear_scr = False - # Close View =========================================================== - elif ( - com[0].lower() == "close" - or com[0].lower() == "c" - or com[0].lower() == "done" - ): - # clear() - # pass - return tag_ids - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) - clear_scr = False - - # return tag_ids diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 7efee56f..64ff6214 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,3 +1,5 @@ +from enum import Enum + VERSION: str = "9.3.2" # Major.Minor.Patch VERSION_BRANCH: str = "" # Usually "" or "Pre-Release" @@ -120,49 +122,13 @@ ALL_FILE_TYPES: list[str] = ( + SHORTCUT_TYPES ) -BOX_FIELDS = ["tag_box", "text_box"] -TEXT_FIELDS = ["text_line", "text_box"] -DATE_FIELDS = ["datetime"] - -TAG_COLORS = [ - "", - "black", - "dark gray", - "gray", - "light gray", - "white", - "light pink", - "pink", - "red", - "red orange", - "orange", - "yellow orange", - "yellow", - "lime", - "light green", - "mint", - "green", - "teal", - "cyan", - "light blue", - "blue", - "blue violet", - "violet", - "purple", - "lavender", - "berry", - "magenta", - "salmon", - "auburn", - "dark brown", - "brown", - "light brown", - "blonde", - "peach", - "warm gray", - "cool gray", - "olive", -] TAG_FAVORITE = 1 TAG_ARCHIVED = 0 + + +class LibraryPrefs(Enum): + IS_EXCLUDE_LIST = True + EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] + PAGE_SIZE: int = 500 + DB_VERSION: int = 1 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 7610a2cc..8907ba17 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -19,21 +19,15 @@ class Theme(str, enum.Enum): COLOR_DISABLED_BG = "#65440D12" -class SearchMode(int, enum.Enum): - """Operational modes for item searching.""" - - AND = 0 - OR = 1 +class OpenStatus(enum.IntEnum): + NOT_FOUND = 0 + SUCCESS = 1 + CORRUPTED = 2 -class FieldID(int, enum.Enum): - TITLE = 0 - AUTHOR = 1 - ARTIST = 2 - DESCRIPTION = 4 - NOTES = 5 - TAGS = 6 - CONTENT_TAGS = 7 - META_TAGS = 8 - DATE_PUBLISHED = 14 - SOURCE = 21 +class MacroID(enum.Enum): + AUTOFILL = "autofill" + SIDECAR = "sidecar" + BUILD_URL = "build_url" + MATCH = "match" + CLEAN_URL = "clean_url" diff --git a/tagstudio/src/core/json_typing.py b/tagstudio/src/core/json_typing.py deleted file mode 100644 index 29ffdc35..00000000 --- a/tagstudio/src/core/json_typing.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import TypedDict -from typing_extensions import NotRequired - - -class JsonLibary(TypedDict("", {"ts-version": str})): - # "ts-version": str - tags: "list[JsonTag]" - collations: "list[JsonCollation]" - fields: list # TODO - macros: "list[JsonMacro]" - entries: "list[JsonEntry]" - ext_list: list[str] - is_exclude_list: bool - ignored_extensions: NotRequired[list[str]] # deprecated - - -class JsonBase(TypedDict): - id: int - - -class JsonTag(JsonBase, total=False): - name: str - aliases: list[str] - color: str - shorthand: str - subtag_ids: list[int] - - -class JsonCollation(JsonBase, total=False): - title: str - e_ids_and_pages: list[list[int]] - sort_order: str - cover_id: int - - -class JsonEntry(JsonBase, total=False): - filename: str - path: str - fields: list[dict] # TODO - - -class JsonMacro(JsonBase, total=False): ... # TODO diff --git a/tagstudio/src/core/library/__init__.py b/tagstudio/src/core/library/__init__.py new file mode 100644 index 00000000..98b662e5 --- /dev/null +++ b/tagstudio/src/core/library/__init__.py @@ -0,0 +1 @@ +from .alchemy import * # noqa diff --git a/tagstudio/src/core/library/alchemy/__init__.py b/tagstudio/src/core/library/alchemy/__init__.py new file mode 100644 index 00000000..993e1aa0 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/__init__.py @@ -0,0 +1,6 @@ +from .models import Entry +from .library import Library +from .models import Tag +from .enums import ItemType + +__all__ = ["Entry", "Library", "Tag", "ItemType"] diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py new file mode 100644 index 00000000..f1a23f5d --- /dev/null +++ b/tagstudio/src/core/library/alchemy/db.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import structlog +from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text +from sqlalchemy.orm import DeclarativeBase + +logger = structlog.getLogger(__name__) + + +class PathType(TypeDecorator): + impl = String + cache_ok = True + + def process_bind_param(self, value: Path, dialect: Dialect): + if value is not None: + return Path(value).as_posix() + return None + + def process_result_value(self, value: str, dialect: Dialect): + if value is not None: + return Path(value) + return None + + +class Base(DeclarativeBase): + type_annotation_map = {Path: PathType} + + +def make_engine(connection_string: str) -> Engine: + return create_engine(connection_string) + + +def make_tables(engine: Engine) -> None: + logger.info("creating db tables") + Base.metadata.create_all(engine) + + # tag IDs < 1000 are reserved + # create tag and delete it to bump the autoincrement sequence + # TODO - find a better way + with engine.connect() as conn: + conn.execute(text("INSERT INTO tags (id, name, color) VALUES (999, 'temp', 1)")) + conn.execute(text("DELETE FROM tags WHERE id = 999")) + conn.commit() + + +def drop_tables(engine: Engine) -> None: + logger.info("dropping db tables") + Base.metadata.drop_all(engine) diff --git a/tagstudio/src/core/library/alchemy/enums.py b/tagstudio/src/core/library/alchemy/enums.py new file mode 100644 index 00000000..f7595e62 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/enums.py @@ -0,0 +1,140 @@ +import enum +from dataclasses import dataclass +from pathlib import Path + + +class TagColor(enum.IntEnum): + DEFAULT = 1 + BLACK = 2 + DARK_GRAY = 3 + GRAY = 4 + LIGHT_GRAY = 5 + WHITE = 6 + LIGHT_PINK = 7 + PINK = 8 + RED = 9 + RED_ORANGE = 10 + ORANGE = 11 + YELLOW_ORANGE = 12 + YELLOW = 13 + LIME = 14 + LIGHT_GREEN = 15 + MINT = 16 + GREEN = 17 + TEAL = 18 + CYAN = 19 + LIGHT_BLUE = 20 + BLUE = 21 + BLUE_VIOLET = 22 + VIOLET = 23 + PURPLE = 24 + LAVENDER = 25 + BERRY = 26 + MAGENTA = 27 + SALMON = 28 + AUBURN = 29 + DARK_BROWN = 30 + BROWN = 31 + LIGHT_BROWN = 32 + BLONDE = 33 + PEACH = 34 + WARM_GRAY = 35 + COOL_GRAY = 36 + OLIVE = 37 + + +class SearchMode(enum.IntEnum): + """Operational modes for item searching.""" + + AND = 0 + OR = 1 + + +class ItemType(enum.Enum): + ENTRY = 0 + COLLATION = 1 + TAG_GROUP = 2 + + +@dataclass +class FilterState: + """Represent a state of the Library grid view.""" + + # these should remain + page_index: int | None = None + page_size: int | None = None + search_mode: SearchMode = SearchMode.AND # TODO - actually implement this + + # these should be erased on update + # tag name + tag: str | None = None + # tag ID + tag_id: int | None = None + + # entry id + id: int | None = None + # whole path + path: Path | str | None = None + # file name + name: str | None = None + + # a generic query to be parsed + query: str | None = None + + def __post_init__(self): + # strip values automatically + if query := (self.query and self.query.strip()): + # parse the value + if ":" in query: + kind, _, value = query.partition(":") + else: + # default to tag search + kind, value = "tag", query + + if kind == "tag_id": + self.tag_id = int(value) + elif kind == "tag": + self.tag = value + elif kind == "path": + self.path = value + elif kind == "name": + self.name = value + elif kind == "id": + self.id = int(self.id) if str(self.id).isnumeric() else self.id + + else: + self.tag = self.tag and self.tag.strip() + self.tag_id = ( + int(self.tag_id) if str(self.tag_id).isnumeric() else self.tag_id + ) + self.path = self.path and str(self.path).strip() + self.name = self.name and self.name.strip() + self.id = int(self.id) if str(self.id).isnumeric() else self.id + + if self.page_index is None: + self.page_index = 0 + if self.page_size is None: + self.page_size = 500 + + @property + def summary(self): + """Show query summary""" + return ( + self.query or self.tag or self.name or self.tag_id or self.path or self.id + ) + + @property + def limit(self): + return self.page_size + + @property + def offset(self): + return self.page_size * self.page_index + + +class FieldTypeEnum(enum.Enum): + TEXT_LINE = "Text Line" + TEXT_BOX = "Text Box" + TAGS = "Tags" + DATETIME = "Datetime" + BOOLEAN = "Checkbox" diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py new file mode 100644 index 00000000..6b079b0b --- /dev/null +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship, declared_attr + +from .db import Base +from .enums import FieldTypeEnum + +if TYPE_CHECKING: + from .models import Entry, Tag, ValueType + + +class BaseField(Base): + __abstract__ = True + + @declared_attr + def id(cls) -> Mapped[int]: + return mapped_column(primary_key=True, autoincrement=True) + + @declared_attr + def type_key(cls) -> Mapped[str]: + return mapped_column(ForeignKey("value_type.key")) + + @declared_attr + def type(cls) -> Mapped[ValueType]: + return relationship(foreign_keys=[cls.type_key], lazy=False) # type: ignore + + @declared_attr + def entry_id(cls) -> Mapped[int]: + return mapped_column(ForeignKey("entries.id")) + + @declared_attr + def entry(cls) -> Mapped[Entry]: + return relationship(foreign_keys=[cls.entry_id]) # type: ignore + + @declared_attr + def position(cls) -> Mapped[int]: + return mapped_column(default=0) + + def __hash__(self): + return hash(self.__key()) + + def __key(self): + raise NotImplementedError + + value: Any + + +class BooleanField(BaseField): + __tablename__ = "boolean_fields" + + value: Mapped[bool] + + def __key(self): + return (self.type, self.value) + + def __eq__(self, value) -> bool: + if isinstance(value, BooleanField): + return self.__key() == value.__key() + raise NotImplementedError + + +class TextField(BaseField): + __tablename__ = "text_fields" + + value: Mapped[str | None] + + def __key(self) -> tuple: + return self.type, self.value + + def __eq__(self, value) -> bool: + if isinstance(value, TextField): + return self.__key() == value.__key() + elif isinstance(value, (TagBoxField, DatetimeField)): + return False + raise NotImplementedError + + +class TagBoxField(BaseField): + __tablename__ = "tag_box_fields" + + tags: Mapped[set[Tag]] = relationship(secondary="tag_fields") + + def __key(self): + return ( + self.entry_id, + self.type_key, + ) + + @property + def value(self) -> None: + """For interface compatibility with other field types.""" + return None + + def __eq__(self, value) -> bool: + if isinstance(value, TagBoxField): + return self.__key() == value.__key() + raise NotImplementedError + + +class DatetimeField(BaseField): + __tablename__ = "datetime_fields" + + value: Mapped[str | None] + + def __key(self): + return (self.type, self.value) + + def __eq__(self, value) -> bool: + if isinstance(value, DatetimeField): + return self.__key() == value.__key() + raise NotImplementedError + + +@dataclass +class DefaultField: + id: int + name: str + type: FieldTypeEnum + is_default: bool = field(default=False) + + +class _FieldID(Enum): + """Only for bootstrapping content of DB table""" + + TITLE = DefaultField( + id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True + ) + AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE) + ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE) + URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE) + DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE) + NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX) + TAGS = DefaultField(id=6, name="Tags", type=FieldTypeEnum.TAGS) + TAGS_CONTENT = DefaultField( + id=7, name="Content Tags", type=FieldTypeEnum.TAGS, is_default=True + ) + TAGS_META = DefaultField( + id=8, name="Meta Tags", type=FieldTypeEnum.TAGS, is_default=True + ) + COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE) + DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME) + DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME) + DATE_MODIFIED = DefaultField( + id=12, name="Date Modified", type=FieldTypeEnum.DATETIME + ) + DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME) + DATE_PUBLISHED = DefaultField( + id=14, name="Date Published", type=FieldTypeEnum.DATETIME + ) + # ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox) + # FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox) + BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE) + COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE) + SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE) + MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE) + SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE) + DATE_UPLOADED = DefaultField( + id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME + ) + DATE_RELEASED = DefaultField( + id=23, name="Date Released", type=FieldTypeEnum.DATETIME + ) + VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE) + ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE) + MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE) + PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE) + GUEST_ARTIST = DefaultField( + id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE + ) + COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE) + COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE) diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py new file mode 100644 index 00000000..b3f4b711 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -0,0 +1,20 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +class TagSubtag(Base): + __tablename__ = "tag_subtags" + + parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) + child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) + + +class TagField(Base): + __tablename__ = "tag_fields" + + field_id: Mapped[int] = mapped_column( + ForeignKey("tag_box_fields.id"), primary_key=True + ) + tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py new file mode 100644 index 00000000..72b3e4d3 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/library.py @@ -0,0 +1,884 @@ +from datetime import datetime, UTC +import shutil +from os import makedirs +from pathlib import Path +from typing import Iterator, Any, Type +from uuid import uuid4 + +import structlog +from sqlalchemy import ( + and_, + or_, + select, + create_engine, + Engine, + func, + update, + URL, + exists, + delete, +) +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import ( + Session, + contains_eager, + selectinload, + make_transient, +) +from typing import TYPE_CHECKING + +from .db import make_tables +from .enums import TagColor, FilterState, FieldTypeEnum +from .fields import ( + DatetimeField, + TagBoxField, + TextField, + _FieldID, + BaseField, +) +from .joins import TagSubtag, TagField +from .models import Entry, Preferences, Tag, TagAlias, ValueType, Folder +from ...constants import ( + LibraryPrefs, + TS_FOLDER_NAME, + TAG_ARCHIVED, + TAG_FAVORITE, + BACKUP_FOLDER_NAME, +) + +if TYPE_CHECKING: + from ...utils.dupe_files import DupeRegistry + from ...utils.missing_files import MissingRegistry + +LIBRARY_FILENAME: str = "ts_library.sqlite" + +logger = structlog.get_logger(__name__) + +import re +import unicodedata + + +def slugify(input_string: str) -> str: + # Convert to lowercase and normalize unicode characters + slug = unicodedata.normalize("NFKD", input_string.lower()) + + # Remove non-word characters (except hyphens and spaces) + slug = re.sub(r"[^\w\s-]", "", slug).strip() + + # Replace spaces with hyphens + slug = re.sub(r"[-\s]+", "-", slug) + + return slug + + +def get_default_tags() -> tuple[Tag, ...]: + archive_tag = Tag( + id=TAG_ARCHIVED, + name="Archived", + aliases={TagAlias(name="Archive")}, + color=TagColor.RED, + ) + + favorite_tag = Tag( + id=TAG_FAVORITE, + name="Favorite", + aliases={ + TagAlias(name="Favorited"), + TagAlias(name="Favorites"), + }, + color=TagColor.YELLOW, + ) + + return archive_tag, favorite_tag + + +class Library: + """Class for the Library object, and all CRUD operations made upon it.""" + + library_dir: Path + storage_path: Path | str + engine: Engine | None + folder: Folder | None + + ignored_extensions: list[str] + + missing_tracker: "MissingRegistry" + dupe_tracker: "DupeRegistry" + + def open_library( + self, library_dir: Path | str, storage_path: str | None = None + ) -> None: + if isinstance(library_dir, str): + library_dir = Path(library_dir) + + self.library_dir = library_dir + if storage_path == ":memory:": + self.storage_path = storage_path + else: + self.verify_ts_folders(self.library_dir) + self.storage_path = self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME + + connection_string = URL.create( + drivername="sqlite", + database=str(self.storage_path), + ) + + logger.info("opening library", connection_string=connection_string) + self.engine = create_engine(connection_string) + 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() + + for pref in LibraryPrefs: + try: + session.add(Preferences(key=pref.name, value=pref.value)) + session.commit() + except IntegrityError: + logger.debug("preference already exists", pref=pref) + session.rollback() + + for field in _FieldID: + try: + session.add( + ValueType( + key=field.name, + name=field.value.name, + type=field.value.type, + position=field.value.id, + is_default=field.value.is_default, + ) + ) + session.commit() + except IntegrityError: + logger.debug("ValueType already exists", field=field) + session.rollback() + + # check if folder matching current path exists already + self.folder = session.scalar( + select(Folder).where(Folder.path == self.library_dir) + ) + if not self.folder: + folder = Folder( + path=self.library_dir, + uuid=str(uuid4()), + ) + session.add(folder) + session.expunge(folder) + + session.commit() + self.folder = folder + + # load ignored extensions + self.ignored_extensions = self.prefs(LibraryPrefs.EXTENSION_LIST) + + @property + def default_fields(self) -> list[BaseField]: + with Session(self.engine) as session: + types = session.scalars( + select(ValueType).where( + # check if field is default + ValueType.is_default.is_(True) + ) + ) + return [x.as_field for x in types] + + def delete_item(self, item): + logger.info("deleting item", item=item) + with Session(self.engine) as session: + session.delete(item) + session.commit() + + def remove_field_tag(self, entry: Entry, tag_id: int, field_key: str) -> bool: + assert isinstance(field_key, str), f"field_key is {type(field_key)}" + with Session(self.engine) as session: + # find field matching entry and field_type + field = session.scalars( + select(TagBoxField).where( + and_( + TagBoxField.entry_id == entry.id, + TagBoxField.type_key == field_key, + ) + ) + ).first() + + if not field: + logger.error("no field found", entry=entry, field=field) + return False + + try: + # find the record in `TagField` table and delete it + tag_field = session.scalars( + select(TagField).where( + and_( + TagField.tag_id == tag_id, + TagField.field_id == field.id, + ) + ) + ).first() + if tag_field: + session.delete(tag_field) + session.commit() + + return True + except IntegrityError as e: + logger.exception(e) + session.rollback() + return False + + def get_entry(self, entry_id: int) -> Entry | None: + """Load entry without joins.""" + with Session(self.engine) as session: + entry = session.scalar(select(Entry).where(Entry.id == entry_id)) + if not entry: + return None + session.expunge(entry) + make_transient(entry) + return entry + + @property + def entries_count(self) -> int: + with Session(self.engine) as session: + return session.scalar(select(func.count(Entry.id))) + + def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: + """Load entries without joins.""" + with Session(self.engine) as session: + stmt = select(Entry) + if with_joins: + # load Entry with all joins and all tags + stmt = ( + stmt.outerjoin(Entry.text_fields) + .outerjoin(Entry.datetime_fields) + .outerjoin(Entry.tag_box_fields) + ) + stmt = stmt.options( + contains_eager(Entry.text_fields), + contains_eager(Entry.datetime_fields), + contains_eager(Entry.tag_box_fields).selectinload(TagBoxField.tags), + ) + + stmt = stmt.distinct() + + entries = session.execute(stmt).scalars() + if with_joins: + entries = entries.unique() + + for entry in entries: + yield entry + session.expunge(entry) + + @property + def tags(self) -> list[Tag]: + with Session(self.engine) as session: + # load all tags and join subtags + tags_query = select(Tag).options(selectinload(Tag.subtags)) + tags = session.scalars(tags_query).unique() + tags_list = list(tags) + + for tag in tags_list: + session.expunge(tag) + + return list(tags_list) + + def verify_ts_folders(self, library_dir: Path) -> None: + """Verify/create folders required by TagStudio.""" + if library_dir is None: + raise ValueError("No path set.") + + if not library_dir.exists(): + raise ValueError("Invalid library directory.") + + full_ts_path = library_dir / TS_FOLDER_NAME + if not full_ts_path.exists(): + logger.info("creating library directory", dir=full_ts_path) + full_ts_path.mkdir(parents=True, exist_ok=True) + + def add_entries(self, items: list[Entry]) -> list[int]: + """Add multiple Entry records to the Library.""" + assert items + + with Session(self.engine) as session: + # add all items + session.add_all(items) + session.flush() + + new_ids = [item.id for item in items] + + session.expunge_all() + + session.commit() + + return new_ids + + def remove_entries(self, entry_ids: list[int]) -> None: + """Remove Entry items matching supplied IDs from the Library.""" + with Session(self.engine) as session: + session.query(Entry).where(Entry.id.in_(entry_ids)).delete() + session.commit() + + def has_path_entry(self, path: Path) -> bool: + """Check if item with given path is in library already.""" + with Session(self.engine) as session: + return session.query(exists().where(Entry.path == path)).scalar() + + def search_library( + self, + search: FilterState, + ) -> tuple[int, list[Entry]]: + """Filter library by search query. + + :return: number of entries matching the query and one page of results. + """ + assert isinstance(search, FilterState) + assert self.engine + + with Session(self.engine, expire_on_commit=False) as session: + statement = select(Entry) + + if search.tag: + statement = ( + statement.join(Entry.tag_box_fields) + .join(TagBoxField.tags) + .where( + or_( + Tag.name.ilike(search.tag), + Tag.shorthand.ilike(search.tag), + ) + ) + ) + elif search.tag_id: + statement = ( + statement.join(Entry.tag_box_fields) + .join(TagBoxField.tags) + .where(Tag.id == search.tag_id) + ) + + elif search.id: + statement = statement.where(Entry.id == search.id) + elif search.name: + statement = select(Entry).where( + and_( + Entry.path.ilike(f"%{search.name}%"), + # dont match directory name (ie. has following slash) + ~Entry.path.ilike(f"%{search.name}%/%"), + ) + ) + elif search.path: + statement = statement.where(Entry.path.ilike(f"%{search.path}%")) + + extensions = self.prefs(LibraryPrefs.EXTENSION_LIST) + is_exclude_list = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST) + + if not search.id: # if `id` is set, we don't need to filter by extensions + if extensions and is_exclude_list: + statement = statement.where( + Entry.path.notilike(f"%.{','.join(extensions)}") + ) + elif extensions: + statement = statement.where( + Entry.path.ilike(f"%.{','.join(extensions)}") + ) + + statement = statement.options( + selectinload(Entry.text_fields), + selectinload(Entry.datetime_fields), + selectinload(Entry.tag_box_fields) + .joinedload(TagBoxField.tags) + .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), + ) + + query_count = select(func.count()).select_from(statement.alias("entries")) + count_all: int = session.execute(query_count).scalar() + + statement = statement.limit(search.limit).offset(search.offset) + + logger.info( + "searching library", + filter=search, + query_full=str( + statement.compile(compile_kwargs={"literal_binds": True}) + ), + ) + + entries_ = list(session.scalars(statement).unique()) + + session.expunge_all() + + return count_all, entries_ + + def search_tags( + self, + search: FilterState, + ) -> list[Tag]: + """Return a list of Tag records matching the query.""" + + with Session(self.engine) as session: + query = select(Tag) + query = query.options( + selectinload(Tag.subtags), + selectinload(Tag.aliases), + ) + + if search.tag: + query = query.where( + or_( + Tag.name.ilike(search.tag), + Tag.shorthand.ilike(search.tag), + ) + ) + + tags = session.scalars(query) + + res = list(tags) + + logger.info( + "searching tags", + search=search, + statement=str(query), + results=len(res), + ) + + session.expunge_all() + return res + + def get_all_child_tag_ids(self, tag_id: int) -> list[int]: + """Recursively traverse a Tag's subtags and return a list of all children tags.""" + + all_subtags: set[int] = {tag_id} + + with Session(self.engine) as session: + tag = session.scalar(select(Tag).where(Tag.id == tag_id)) + if tag is None: + raise ValueError(f"No tag found with id {tag_id}.") + + subtag_ids = tag.subtag_ids + + all_subtags.update(subtag_ids) + + for sub_id in subtag_ids: + all_subtags.update(self.get_all_child_tag_ids(sub_id)) + + return list(all_subtags) + + def update_entry_path(self, entry_id: int | Entry, path: Path) -> None: + if isinstance(entry_id, Entry): + entry_id = entry_id.id + + with Session(self.engine) as session: + update_stmt = ( + update(Entry) + .where( + and_( + Entry.id == entry_id, + ) + ) + .values(path=path) + ) + + session.execute(update_stmt) + session.commit() + + def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None: + with Session(self.engine) as session: + field_ = session.scalars( + select(TagBoxField).where(TagBoxField.id == field.id) + ).one() + + tag = session.scalars(select(Tag).where(Tag.id == tag.id)).one() + + field_.tags.remove(tag) + session.add(field_) + session.commit() + + def update_field_position( + self, + field_class: Type[BaseField], + field_type: str, + entry_ids: list[int] | int, + ): + if isinstance(entry_ids, int): + entry_ids = [entry_ids] + + with Session(self.engine) as session: + for entry_id in entry_ids: + rows = list( + session.scalars( + select(field_class) + .where( + and_( + field_class.entry_id == entry_id, + field_class.type_key == field_type, + ) + ) + .order_by(field_class.id) + ) + ) + + # Reassign `order` starting from 0 + for index, row in enumerate(rows): + row.position = index + session.add(row) + session.flush() + if rows: + session.commit() + + def remove_entry_field( + self, + field: BaseField, + entry_ids: list[int], + ) -> None: + FieldClass = type(field) + + logger.info( + "remove_entry_field", + field=field, + entry_ids=entry_ids, + field_type=field.type, + cls=FieldClass, + pos=field.position, + ) + + with Session(self.engine) as session: + # remove all fields matching entry and field_type + delete_stmt = delete(FieldClass).where( + and_( + FieldClass.position == field.position, + FieldClass.type_key == field.type_key, + FieldClass.entry_id.in_(entry_ids), + ) + ) + + session.execute(delete_stmt) + + session.commit() + + # recalculate the remaining positions + # self.update_field_position(type(field), field.type, entry_ids) + + def update_entry_field( + self, + entry_ids: list[int] | int, + field: BaseField, + content: str | datetime | set[Tag], + ): + if isinstance(entry_ids, int): + entry_ids = [entry_ids] + + FieldClass = type(field) + + with Session(self.engine) as session: + update_stmt = ( + update(FieldClass) + .where( + and_( + FieldClass.position == field.position, + FieldClass.type == field.type, + FieldClass.entry_id.in_(entry_ids), + ) + ) + .values(value=content) + ) + + session.execute(update_stmt) + session.commit() + + @property + def field_types(self) -> dict[str, ValueType]: + with Session(self.engine) as session: + return {x.key: x for x in session.scalars(select(ValueType)).all()} + + def get_value_type(self, field_key: str) -> ValueType: + with Session(self.engine) as session: + field = session.scalar(select(ValueType).where(ValueType.key == field_key)) + session.expunge(field) + return field + + def add_entry_field_type( + self, + entry_ids: list[int] | int, + *, + field: ValueType | None = None, + field_id: _FieldID | str | None = None, + value: str | datetime | list[str] | None = None, + ) -> bool: + logger.info( + "add_field_to_entry", + entry_ids=entry_ids, + field_type=field, + field_id=field_id, + value=value, + ) + # supply only instance or ID, not both + assert bool(field) != (field_id is not None) + + if isinstance(entry_ids, int): + entry_ids = [entry_ids] + + if not field: + if isinstance(field_id, _FieldID): + field_id = field_id.name + field = self.get_value_type(field_id) + + field_model: TextField | DatetimeField | TagBoxField + if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX): + field_model = TextField( + type_key=field.key, + value=value or "", + ) + elif field.type == FieldTypeEnum.TAGS: + field_model = TagBoxField( + type_key=field.key, + ) + + if value: + assert isinstance(value, list) + for tag in value: + field_model.tags.add(Tag(name=tag)) + + elif field.type == FieldTypeEnum.DATETIME: + field_model = DatetimeField( + type_key=field.key, + value=value, + ) + else: + raise NotImplementedError(f"field type not implemented: {field.type}") + + with Session(self.engine) as session: + try: + for entry_id in entry_ids: + field_model.entry_id = entry_id + session.add(field_model) + session.flush() + + session.commit() + except IntegrityError as e: + logger.exception(e) + session.rollback() + return False + # TODO - trigger error signal + + # recalculate the positions of fields + self.update_field_position( + field_class=type(field_model), + field_type=field.key, + entry_ids=entry_ids, + ) + return True + + def add_tag(self, tag: Tag, subtag_ids: list[int] | None = None) -> Tag | None: + with Session(self.engine, expire_on_commit=False) as session: + try: + session.add(tag) + session.flush() + + for subtag_id in subtag_ids or []: + subtag = TagSubtag( + parent_id=tag.id, + child_id=subtag_id, + ) + session.add(subtag) + + session.commit() + + session.expunge(tag) + return tag + + except IntegrityError as e: + logger.exception(e) + session.rollback() + return None + + def add_field_tag( + self, + entry: Entry, + tag: Tag, + field_key: str = _FieldID.TAGS.name, + create_field: bool = False, + ) -> bool: + assert isinstance(field_key, str), f"field_key is {type(field_key)}" + + with Session(self.engine) as session: + # find field matching entry and field_type + field = session.scalars( + select(TagBoxField).where( + and_( + TagBoxField.entry_id == entry.id, + TagBoxField.type_key == field_key, + ) + ) + ).first() + + if not field and not create_field: + logger.error("no field found", entry=entry, field_key=field_key) + return False + + try: + if not field: + field = TagBoxField( + type_key=field_key, + entry_id=entry.id, + position=0, + ) + session.add(field) + session.flush() + + # create record for `TagField` table + if not tag.id: + session.add(tag) + session.flush() + + tag_field = TagField( + tag_id=tag.id, + field_id=field.id, + ) + + session.add(tag_field) + session.commit() + logger.info( + "tag added to field", tag=tag, field=field, entry_id=entry.id + ) + + return True + except IntegrityError as e: + logger.exception(e) + session.rollback() + + return False + + def save_library_backup_to_disk(self) -> Path: + assert isinstance(self.library_dir, Path) + makedirs( + str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True + ) + + filename = ( + f'ts_library_backup_{datetime.now(UTC).strftime("%Y_%m_%d_%H%M%S")}.sqlite' + ) + + target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename + + shutil.copy2( + self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME, + target_path, + ) + + return target_path + + def get_tag(self, tag_id: int) -> Tag: + with Session(self.engine) as session: + tags_query = select(Tag).options(selectinload(Tag.subtags)) + tag = session.scalar(tags_query.where(Tag.id == tag_id)) + + session.expunge(tag) + for subtag in tag.subtags: + session.expunge(subtag) + + return tag + + def add_subtag(self, base_id: int, new_tag_id: int) -> bool: + # open session and save as parent tag + with Session(self.engine) as session: + tag = TagSubtag( + parent_id=base_id, + child_id=new_tag_id, + ) + + try: + session.add(tag) + session.commit() + return True + except IntegrityError: + session.rollback() + logger.exception("IntegrityError") + return False + + def update_tag(self, tag: Tag, subtag_ids: list[int]) -> None: + """ + Edit a Tag in the Library. + """ + # TODO - maybe merge this with add_tag? + + if tag.shorthand: + tag.shorthand = slugify(tag.shorthand) + + if tag.aliases: + # TODO + ... + + # save the tag + with Session(self.engine) as session: + try: + # update the existing tag + session.add(tag) + session.flush() + + # load all tag's subtag to know which to remove + prev_subtags = session.scalars( + select(TagSubtag).where(TagSubtag.parent_id == tag.id) + ).all() + + for subtag in prev_subtags: + if subtag.child_id not in subtag_ids: + session.delete(subtag) + else: + # no change, remove from list + subtag_ids.remove(subtag.child_id) + + # create remaining items + for subtag_id in subtag_ids: + # add new subtag + subtag = TagSubtag( + parent_id=tag.id, + child_id=subtag_id, + ) + session.add(subtag) + + session.commit() + except IntegrityError: + session.rollback() + logger.exception("IntegrityError") + + def prefs(self, key: LibraryPrefs) -> Any: + # load given item from Preferences table + with Session(self.engine) as session: + return session.scalar( + select(Preferences).where(Preferences.key == key.name) + ).value + + def set_prefs(self, key: LibraryPrefs, value: Any) -> None: + # set given item in Preferences table + with Session(self.engine) as session: + # load existing preference and update value + pref = session.scalar( + select(Preferences).where(Preferences.key == key.name) + ) + pref.value = value + session.add(pref) + session.commit() + # TODO - try/except + + def mirror_entry_fields(self, *entries: Entry) -> None: + """Mirror fields among multiple Entry items.""" + fields = {} + # load all fields + existing_fields = {field.type_key for field in entries[0].fields} + for entry in entries: + for entry_field in entry.fields: + fields[entry_field.type_key] = entry_field + + # assign the field to all entries + for entry in entries: + for field_key, field in fields.items(): + if field_key not in existing_fields: + self.add_entry_field_type( + entry_ids=entry.id, + field_id=field.type_key, + value=field.value, + ) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py new file mode 100644 index 00000000..3c4e0402 --- /dev/null +++ b/tagstudio/src/core/library/alchemy/models.py @@ -0,0 +1,270 @@ +from pathlib import Path +from typing import Optional + +from sqlalchemy import JSON, ForeignKey, Integer, event +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base, PathType +from .enums import TagColor +from .fields import ( + DatetimeField, + TagBoxField, + TextField, + FieldTypeEnum, + _FieldID, + BaseField, + BooleanField, +) +from .joins import TagSubtag +from ...constants import TAG_FAVORITE, TAG_ARCHIVED + + +class TagAlias(Base): + __tablename__ = "tag_aliases" + + id: Mapped[int] = mapped_column(primary_key=True) + + name: Mapped[str] + + tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id")) + tag: Mapped["Tag"] = relationship(back_populates="aliases") + + def __init__(self, name: str, tag: Optional["Tag"] = None): + self.name = name + + if tag: + self.tag = tag + + super().__init__() + + +class Tag(Base): + __tablename__ = "tags" + __table_args__ = {"sqlite_autoincrement": True} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + name: Mapped[str] = mapped_column(unique=True) + shorthand: Mapped[str | None] + color: Mapped[TagColor] + icon: Mapped[str | None] + + aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag") + + parent_tags: Mapped[set["Tag"]] = relationship( + secondary=TagSubtag.__tablename__, + primaryjoin="Tag.id == TagSubtag.child_id", + secondaryjoin="Tag.id == TagSubtag.parent_id", + back_populates="subtags", + ) + + subtags: Mapped[set["Tag"]] = relationship( + secondary=TagSubtag.__tablename__, + primaryjoin="Tag.id == TagSubtag.parent_id", + secondaryjoin="Tag.id == TagSubtag.child_id", + back_populates="parent_tags", + ) + + @property + def subtag_ids(self) -> list[int]: + return [tag.id for tag in self.subtags] + + @property + def alias_strings(self) -> list[str]: + return [alias.name for alias in self.aliases] + + def __init__( + self, + name: str, + 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() + self.parent_tags = parent_tags or set() + self.subtags = subtags or set() + self.color = color + self.icon = icon + self.shorthand = shorthand + assert not self.id + self.id = id + super().__init__() + + def __str__(self) -> str: + return f"" + + def __repr__(self) -> str: + return self.__str__() + + +class Folder(Base): + __tablename__ = "folders" + + # TODO - implement this + id: Mapped[int] = mapped_column(primary_key=True) + path: Mapped[Path] = mapped_column(PathType, unique=True) + uuid: Mapped[str] = mapped_column(unique=True) + + +class Entry(Base): + __tablename__ = "entries" + + id: Mapped[int] = mapped_column(primary_key=True) + + folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id")) + folder: Mapped[Folder] = relationship("Folder") + + path: Mapped[Path] = mapped_column(PathType, unique=True) + + text_fields: Mapped[list[TextField]] = relationship( + back_populates="entry", + cascade="all, delete", + ) + datetime_fields: Mapped[list[DatetimeField]] = relationship( + back_populates="entry", + cascade="all, delete", + ) + tag_box_fields: Mapped[list[TagBoxField]] = relationship( + back_populates="entry", + cascade="all, delete", + ) + + @property + def fields(self) -> list[BaseField]: + fields: list[BaseField] = [] + fields.extend(self.tag_box_fields) + fields.extend(self.text_fields) + fields.extend(self.datetime_fields) + fields = sorted(fields, key=lambda field: field.type.position) + return fields + + @property + def tags(self) -> set[Tag]: + tag_set: set[Tag] = set() + for tag_box_field in self.tag_box_fields: + tag_set.update(tag_box_field.tags) + return tag_set + + @property + def is_favorited(self) -> bool: + for tag_box_field in self.tag_box_fields: + if tag_box_field.type_key == _FieldID.TAGS_META.name: + for tag in tag_box_field.tags: + if tag.id == TAG_FAVORITE: + return True + return False + + @property + def is_archived(self) -> bool: + for tag_box_field in self.tag_box_fields: + if tag_box_field.type_key == _FieldID.TAGS_META.name: + for tag in tag_box_field.tags: + if tag.id == TAG_ARCHIVED: + return True + return False + + def __init__( + self, + path: Path, + folder: Folder, + fields: list[BaseField], + ) -> None: + self.path = path + self.folder = folder + + for field in fields: + if isinstance(field, TextField): + self.text_fields.append(field) + elif isinstance(field, DatetimeField): + self.datetime_fields.append(field) + elif isinstance(field, TagBoxField): + self.tag_box_fields.append(field) + else: + raise ValueError(f"Invalid field type: {field}") + + def has_tag(self, tag: Tag) -> bool: + return tag in self.tags + + def remove_tag(self, tag: Tag, field: TagBoxField | None = None) -> None: + """ + Removes a Tag from the Entry. If given a field index, the given Tag will + only be removed from that index. If left blank, all instances of that + Tag will be removed from the Entry. + """ + if field: + field.tags.remove(tag) + return + + for tag_box_field in self.tag_box_fields: + tag_box_field.tags.remove(tag) + + +class ValueType(Base): + """Define Field Types in the Library. + + Example: + key: content_tags (this field is slugified `name`) + name: Content Tags (this field is human readable name) + kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox) + is_default: Should the field be present in new Entry? + order: position of the field widget in the Entry form + + """ + + __tablename__ = "value_type" + + key: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE) + is_default: Mapped[bool] + position: Mapped[int] + + # add relations to other tables + text_fields: Mapped[list[TextField]] = relationship( + "TextField", back_populates="type" + ) + datetime_fields: Mapped[list[DatetimeField]] = relationship( + "DatetimeField", back_populates="type" + ) + tag_box_fields: Mapped[list[TagBoxField]] = relationship( + "TagBoxField", back_populates="type" + ) + boolean_fields: Mapped[list[BooleanField]] = relationship( + "BooleanField", back_populates="type" + ) + + @property + def as_field(self) -> BaseField: + FieldClass = { + FieldTypeEnum.TEXT_LINE: TextField, + FieldTypeEnum.TEXT_BOX: TextField, + FieldTypeEnum.TAGS: TagBoxField, + FieldTypeEnum.DATETIME: DatetimeField, + FieldTypeEnum.BOOLEAN: BooleanField, + } + + return FieldClass[self.type]( + type_key=self.key, + position=self.position, + ) + + +@event.listens_for(ValueType, "before_insert") +def slugify_field_key(mapper, connection, target): + """Slugify the field key before inserting into the database.""" + if not target.key: + from .library import slugify + + target.key = slugify(target.tag) + + +class Preferences(Base): + __tablename__ = "preferences" + + key: Mapped[str] = mapped_column(primary_key=True) + value: Mapped[dict] = mapped_column(JSON, nullable=False) diff --git a/tagstudio/src/core/library/json/__init__.py b/tagstudio/src/core/library/json/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tagstudio/src/core/library/json/fields.py b/tagstudio/src/core/library/json/fields.py new file mode 100644 index 00000000..5e3509e4 --- /dev/null +++ b/tagstudio/src/core/library/json/fields.py @@ -0,0 +1,38 @@ +BOX_FIELDS = ["tag_box", "text_box"] +TEXT_FIELDS = ["text_line", "text_box"] +DATE_FIELDS = ["datetime"] + + +DEFAULT_FIELDS: list[dict] = [ + {"id": 0, "name": "Title", "type": "text_line"}, + {"id": 1, "name": "Author", "type": "text_line"}, + {"id": 2, "name": "Artist", "type": "text_line"}, + {"id": 3, "name": "URL", "type": "text_line"}, + {"id": 4, "name": "Description", "type": "text_box"}, + {"id": 5, "name": "Notes", "type": "text_box"}, + {"id": 6, "name": "Tags", "type": "tag_box"}, + {"id": 7, "name": "Content Tags", "type": "tag_box"}, + {"id": 8, "name": "Meta Tags", "type": "tag_box"}, + {"id": 9, "name": "Collation", "type": "collation"}, + {"id": 10, "name": "Date", "type": "datetime"}, + {"id": 11, "name": "Date Created", "type": "datetime"}, + {"id": 12, "name": "Date Modified", "type": "datetime"}, + {"id": 13, "name": "Date Taken", "type": "datetime"}, + {"id": 14, "name": "Date Published", "type": "datetime"}, + {"id": 15, "name": "Archived", "type": "checkbox"}, + {"id": 16, "name": "Favorite", "type": "checkbox"}, + {"id": 17, "name": "Book", "type": "collation"}, + {"id": 18, "name": "Comic", "type": "collation"}, + {"id": 19, "name": "Series", "type": "collation"}, + {"id": 20, "name": "Manga", "type": "collation"}, + {"id": 21, "name": "Source", "type": "text_line"}, + {"id": 22, "name": "Date Uploaded", "type": "datetime"}, + {"id": 23, "name": "Date Released", "type": "datetime"}, + {"id": 24, "name": "Volume", "type": "collation"}, + {"id": 25, "name": "Anthology", "type": "collation"}, + {"id": 26, "name": "Magazine", "type": "collation"}, + {"id": 27, "name": "Publisher", "type": "text_line"}, + {"id": 28, "name": "Guest Artist", "type": "text_line"}, + {"id": 29, "name": "Composer", "type": "text_line"}, + {"id": 30, "name": "Comments", "type": "text_box"}, +] diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library/json/library.py similarity index 94% rename from tagstudio/src/core/library.py rename to tagstudio/src/core/library/json/library.py index 7eedb8b8..c95a2c79 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library/json/library.py @@ -1,3 +1,5 @@ +# type: ignore +# ruff: noqa # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -5,11 +7,12 @@ """The Library object and related methods for TagStudio.""" import datetime -import logging import os import time import traceback import xml.etree.ElementTree as ET + +import structlog import ujson from enum import Enum @@ -17,15 +20,13 @@ from pathlib import Path from typing import cast, Generator from typing_extensions import Self -from src.core.enums import FieldID -from src.core.json_typing import JsonCollation, JsonEntry, JsonLibary, JsonTag +from .fields import DEFAULT_FIELDS, TEXT_FIELDS +from src.core.enums import OpenStatus from src.core.utils.str import strip_punctuation from src.core.utils.web import strip_web_protocol -from src.core.enums import SearchMode from src.core.constants import ( BACKUP_FOLDER_NAME, COLLAGE_FOLDER_NAME, - TEXT_FIELDS, TS_FOLDER_NAME, VERSION, ) @@ -40,7 +41,7 @@ class ItemType(Enum): TAG_GROUP = 2 -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class Entry: @@ -94,12 +95,12 @@ class Entry: and self.fields == __value.fields ) - def compressed_dict(self) -> JsonEntry: + def compressed_dict(self): """ An alternative to __dict__ that only includes fields containing non-default data. """ - obj: JsonEntry = {"id": self.id} + obj = {"id": self.id} if self.filename: obj["filename"] = str(self.filename) if self.path: @@ -128,7 +129,7 @@ class Entry: if library.get_field_attr(f, "type") == "tag_box": if field_index >= 0 and field_index == i: t: list[int] = library.get_field_attr(f, "content") - logging.info( + logger.info( f't:{tag_id}, i:{i}, idx:{field_index}, c:{library.get_field_attr(f, "content")}' ) t.remove(tag_id) @@ -142,30 +143,30 @@ class Entry: ): # if self.fields: # if field_index != -1: - # logging.info(f'[LIBRARY] ADD TAG to E:{self.id}, F-DI:{field_id}, F-INDEX:{field_index}') + # logger.info(f'[LIBRARY] ADD TAG to E:{self.id}, F-DI:{field_id}, F-INDEX:{field_index}') for i, f in enumerate(self.fields): if library.get_field_attr(f, "id") == field_id: field_index = i - # logging.info(f'[LIBRARY] FOUND F-INDEX:{field_index}') + # logger.info(f'[LIBRARY] FOUND F-INDEX:{field_index}') break if field_index == -1: library.add_field_to_entry(self.id, field_id) - # logging.info(f'[LIBRARY] USING NEWEST F-INDEX:{field_index}') + # logger.info(f'[LIBRARY] USING NEWEST F-INDEX:{field_index}') - # logging.info(list(self.fields[field_index].keys())) + # logger.info(list(self.fields[field_index].keys())) field_id = list(self.fields[field_index].keys())[0] - # logging.info(f'Entry Field ID: {field_id}, Index: {field_index}') + # logger.info(f'Entry Field ID: {field_id}, Index: {field_index}') tags: list[int] = self.fields[field_index][field_id] if tag_id not in tags: - # logging.info(f'Adding Tag: {tag_id}') + # logger.info(f'Adding Tag: {tag_id}') tags.append(tag_id) self.fields[field_index][field_id] = sorted( tags, key=lambda t: library.get_tag(t).display_name(library) ) - # logging.info(f'Tags: {self.fields[field_index][field_id]}') + # logger.info(f'Tags: {self.fields[field_index][field_id]}') class Tag: @@ -219,12 +220,12 @@ class Tag: else: return f"{self.name}" - def compressed_dict(self) -> JsonTag: + def compressed_dict(self): """ An alternative to __dict__ that only includes fields containing non-default data. """ - obj: JsonTag = {"id": self.id} + obj = {"id": self.id} if self.name: obj["name"] = self.name if self.shorthand: @@ -281,12 +282,12 @@ class Collation: __value = cast(Self, __value) return int(self.id) == int(__value.id) and self.fields == __value.fields - def compressed_dict(self) -> JsonCollation: + def compressed_dict(self): """ An alternative to __dict__ that only includes fields containing non-default data. """ - obj: JsonCollation = {"id": self.id} + obj = {"id": self.id} if self.title: obj["title"] = self.title if self.e_ids_and_pages: @@ -368,7 +369,7 @@ class Library: # Map of every Tag ID to the index of the Tag in self.tags. self._tag_id_to_index_map: dict[int, int] = {} - self.default_tags: list[JsonTag] = [ + self.default_tags: list = [ {"id": 0, "name": "Archived", "aliases": ["Archive"], "color": "Red"}, { "id": 1, @@ -383,40 +384,6 @@ class Library: # Tag(id=1, name='Favorite', shorthand='', aliases=['Favorited, Favorites, Likes, Liked, Loved'], subtags_ids=[], color='yellow'), # ] - self.default_fields: list[dict] = [ - {"id": 0, "name": "Title", "type": "text_line"}, - {"id": 1, "name": "Author", "type": "text_line"}, - {"id": 2, "name": "Artist", "type": "text_line"}, - {"id": 3, "name": "URL", "type": "text_line"}, - {"id": 4, "name": "Description", "type": "text_box"}, - {"id": 5, "name": "Notes", "type": "text_box"}, - {"id": 6, "name": "Tags", "type": "tag_box"}, - {"id": 7, "name": "Content Tags", "type": "tag_box"}, - {"id": 8, "name": "Meta Tags", "type": "tag_box"}, - {"id": 9, "name": "Collation", "type": "collation"}, - {"id": 10, "name": "Date", "type": "datetime"}, - {"id": 11, "name": "Date Created", "type": "datetime"}, - {"id": 12, "name": "Date Modified", "type": "datetime"}, - {"id": 13, "name": "Date Taken", "type": "datetime"}, - {"id": 14, "name": "Date Published", "type": "datetime"}, - {"id": 15, "name": "Archived", "type": "checkbox"}, - {"id": 16, "name": "Favorite", "type": "checkbox"}, - {"id": 17, "name": "Book", "type": "collation"}, - {"id": 18, "name": "Comic", "type": "collation"}, - {"id": 19, "name": "Series", "type": "collation"}, - {"id": 20, "name": "Manga", "type": "collation"}, - {"id": 21, "name": "Source", "type": "text_line"}, - {"id": 22, "name": "Date Uploaded", "type": "datetime"}, - {"id": 23, "name": "Date Released", "type": "datetime"}, - {"id": 24, "name": "Volume", "type": "collation"}, - {"id": 25, "name": "Anthology", "type": "collation"}, - {"id": 26, "name": "Magazine", "type": "collation"}, - {"id": 27, "name": "Publisher", "type": "text_line"}, - {"id": 28, "name": "Guest Artist", "type": "text_line"}, - {"id": 29, "name": "Composer", "type": "text_line"}, - {"id": 30, "name": "Comments", "type": "text_box"}, - ] - def create_library(self, path: Path) -> int: """ Creates a TagStudio library in the given directory.\n @@ -433,7 +400,7 @@ class Library: self.verify_ts_folders() self.save_library_to_disk() self.open_library(self.library_dir) - except: + except Exception: traceback.print_exc() return 2 @@ -443,7 +410,7 @@ class Library: """If '.TagStudio' is included in the path, trim the path up to it.""" path = Path(path) paths = [x for x in [path, *path.parents] if x.stem == TS_FOLDER_NAME] - if len(paths) > 0: + if paths: return paths[0].parent return path @@ -463,12 +430,12 @@ class Library: if not os.path.isdir(full_collage_path): os.mkdir(full_collage_path) - def verify_default_tags(self, tag_list: list[JsonTag]) -> list[JsonTag]: + def verify_default_tags(self, tag_list: list) -> list: """ Ensures that the default builtin tags are present in the Library's save file. Takes in and returns the tag dictionary from the JSON file. """ - missing: list[JsonTag] = [] + missing: list = [] for dt in self.default_tags: if dt["id"] not in [t["id"] for t in tag_list]: @@ -479,16 +446,14 @@ class Library: return tag_list - def open_library(self, path: str | Path) -> int: + def open_library(self, path: str | Path) -> OpenStatus: """ - Opens a TagStudio v9+ Library. - Returns 0 if library does not exist, 1 if successfully opened, 2 if corrupted. + Open a TagStudio v9+ Library. """ - - return_code: int = 2 + return_code = OpenStatus.CORRUPTED _path: Path = self._fix_lib_path(path) - + logger.info("opening library", path=_path) if (_path / TS_FOLDER_NAME / "ts_library.json").exists(): try: with open( @@ -496,7 +461,7 @@ class Library: "r", encoding="utf-8", ) as file: - json_dump: JsonLibary = ujson.load(file) + json_dump = ujson.load(file) self.library_dir = Path(_path) self.verify_ts_folders() major, minor, patch = json_dump["ts-version"].split(".") @@ -525,7 +490,7 @@ class Library: self.is_exclude_list = json_dump.get("is_exclude_list", True) end_time = time.time() - logging.info( + logger.info( f"[LIBRARY] Extension list loaded in {(end_time - start_time):.3f} seconds" ) @@ -570,7 +535,7 @@ class Library: self._map_tag_id_to_index(t, -1) self._map_tag_strings_to_tag_id(t) else: - logging.info( + logger.info( f"[LIBRARY]Skipping Tag with duplicate ID: {tag}" ) @@ -579,7 +544,7 @@ class Library: self._map_tag_id_to_cluster(t) end_time = time.time() - logging.info( + logger.info( f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds" ) @@ -680,8 +645,8 @@ class Library: self._map_entry_id_to_index(e, -1) end_time = time.time() - logging.info( - f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds" + logger.info( + f"[LIBRARY] Entries loaded", load_time=end_time - start_time ) # Parse Collations ----------------------------------------- @@ -704,7 +669,7 @@ class Library: c = Collation( id=id, title=title, - e_ids_and_pages=e_ids_and_pages, # type: ignore + e_ids_and_pages=e_ids_and_pages, sort_order=sort_order, cover_id=cover_id, ) @@ -716,16 +681,16 @@ class Library: self.collations.append(c) self._map_collation_id_to_index(c, -1) end_time = time.time() - logging.info( + logger.info( f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds" ) - return_code = 1 + return_code = OpenStatus.SUCCESS except ujson.JSONDecodeError: - logging.info("[LIBRARY][ERROR]: Empty JSON file!") + logger.info("[LIBRARY][ERROR]: Empty JSON file!") # If the Library is loaded, continue other processes. - if return_code == 1: + if return_code == OpenStatus.SUCCESS: (self.library_dir / TS_FOLDER_NAME).mkdir(parents=True, exist_ok=True) self._map_filenames_to_entry_ids() @@ -759,7 +724,7 @@ class Library: Used in saving the library to disk. """ - file_to_save: JsonLibary = { + file_to_save = { "ts-version": VERSION, "ext_list": [i for i in self.ext_list if i], "is_exclude_list": self.is_exclude_list, @@ -790,7 +755,7 @@ class Library: def save_library_to_disk(self): """Saves the Library to disk at the default TagStudio folder location.""" - logging.info(f"[LIBRARY] Saving Library to Disk...") + logger.info(f"[LIBRARY] Saving Library to Disk...") start_time = time.time() filename = "ts_library.json" @@ -808,7 +773,7 @@ class Library: ) # , indent=4 <-- How to prettyprint dump end_time = time.time() - logging.info( + logger.info( f"[LIBRARY] Library saved to disk in {(end_time - start_time):.3f} seconds" ) @@ -817,7 +782,7 @@ class Library: Saves a backup file of the Library to disk at the default TagStudio folder location. Returns the filename used, including the date and time.""" - logging.info(f"[LIBRARY] Saving Library Backup to Disk...") + logger.info(f"[LIBRARY] Saving Library Backup to Disk...") start_time = time.time() filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json' @@ -835,7 +800,7 @@ class Library: escape_forward_slashes=False, ) end_time = time.time() - logging.info( + logger.info( f"[LIBRARY] Library backup saved to disk in {(end_time - start_time):.3f} seconds" ) return filename @@ -908,7 +873,7 @@ class Library: # print(file) self.files_not_in_library.append(file) except PermissionError: - logging.info( + logger.info( f"The File/Folder {f} cannot be accessed, because it requires higher permission!" ) end_time = time.time() @@ -951,7 +916,7 @@ class Library: # Remove this Entry from the Entries list. entry = self.get_entry(entry_id) path = entry.path / entry.filename - # logging.info(f'Removing path: {path}') + # logger.info(f'Removing path: {path}') del self.filename_to_entry_id_map[path] @@ -1000,9 +965,9 @@ class Library: for k, v in registered.items(): if len(v) > 1: self.dupe_entries.append((v[0], v[1:])) - # logging.info(f"DUPLICATE FOUND: {(v[0], v[1:])}") + # logger.info(f"DUPLICATE FOUND: {(v[0], v[1:])}") # for id in v: - # logging.info(f"\t{(Path()/self.get_entry(id).path/self.get_entry(id).filename)}") + # logger.info(f"\t{(Path()/self.get_entry(id).path/self.get_entry(id).filename)}") yield len(self.entries) @@ -1014,7 +979,7 @@ class Library: `dupe_entries = tuple(int, list[int])` """ - logging.info("[LIBRARY] Mirroring Duplicate Entries...") + logger.info("[LIBRARY] Mirroring Duplicate Entries...") id_to_entry_map: dict = {} for dupe in self.dupe_entries: @@ -1026,7 +991,7 @@ class Library: id_to_entry_map[id] = self.get_entry(id) self.mirror_entry_fields([dupe[0]] + dupe[1]) - logging.info( + logger.info( "[LIBRARY] Consolidating Entries... (This may take a while for larger libraries)" ) for i, dupe in enumerate(self.dupe_entries): @@ -1037,7 +1002,7 @@ class Library: # takes but in a batch-friendly way here. # NOTE: Couldn't use get_entry(id) because that relies on the # entry's index in the list, which is currently being messed up. - logging.info(f"[LIBRARY] Removing Unneeded Entry {id}") + logger.info(f"[LIBRARY] Removing Unneeded Entry {id}") self.entries.remove(id_to_entry_map[id]) yield i - 1 # The -1 waits for the next step to finish @@ -1107,12 +1072,12 @@ class Library: # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') try: id = self.get_entry_id_from_filepath(missing) - logging.info(f"Removing Entry ID {id}:\n\t{missing}") + logger.info(f"Removing Entry ID {id}:\n\t{missing}") self.remove_entry(id) # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) deleted.append(missing) except KeyError: - logging.info( + logger.info( f'[LIBRARY][ERROR]: "{id}" was reported as missing, but is not in the file_to_entry_id map.' ) yield (i, id) @@ -1288,7 +1253,10 @@ class Library: path = Path(file) # print(os.path.split(file)) entry = Entry( - id=self._next_entry_id, filename=path.name, path=path.parent, fields=[] + id=self._next_entry_id, + filename=path.name, + path=path.parent, + fields=[], ) self._next_entry_id += 1 self.add_entry_to_library(entry) @@ -1331,7 +1299,7 @@ class Library: entries=True, collations=True, tag_groups=True, - search_mode=SearchMode.AND, + search_mode=0, # AND ) -> list[tuple[ItemType, int]]: """ Uses a search query to generate a filtered results list. @@ -1473,7 +1441,7 @@ class Library: if not added: results.append((ItemType.ENTRY, entry.id)) - if search_mode == SearchMode.AND: # Include all terms + if search_mode == 0: # AND # Include all terms # For each verified, extracted Tag term. failure_to_union_terms = False for term in all_tag_terms: @@ -1507,7 +1475,7 @@ class Library: if all_tag_terms and not failure_to_union_terms: add_entry(entry) - if search_mode == SearchMode.OR: # Include any terms + if search_mode == 1: # OR # Include any terms # For each verified, extracted Tag term. for term in all_tag_terms: # Add the immediate associated Tags to the set (ex. Name, Alias hits) @@ -1771,7 +1739,7 @@ class Library: """Returns a list of Field Template IDs returned from a string query.""" matches: list[int] = [] - for ft in self.default_fields: + for ft in DEFAULT_FIELDS: if ft["name"].lower().startswith(query.lower()): matches.append(ft["id"]) @@ -2104,7 +2072,7 @@ class Library: elif field_type == "datetime": entry.fields.append({int(field_id): ""}) else: - logging.info( + logger.info( f"[LIBRARY][ERROR]: Unknown field id attempted to be added to entry: {field_id}" ) @@ -2181,8 +2149,8 @@ class Library: Returns a field template object associated with a field ID. The objects have "id", "name", and "type" fields. """ - if int(field_id) < len(self.default_fields): - return self.default_fields[int(field_id)] + if int(field_id) < len(DEFAULT_FIELDS): + return DEFAULT_FIELDS[int(field_id)] else: return {"id": -1, "name": "Unknown Field", "type": "unknown"} diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 886e0bd6..74a30fc0 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -1,11 +1,18 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import traceback +from enum import IntEnum +from typing import Any -from enum import Enum +import structlog + +from src.core.library.alchemy.enums import TagColor + +logger = structlog.get_logger(__name__) -class ColorType(int, Enum): +class ColorType(IntEnum): PRIMARY = 0 TEXT = 1 BORDER = 2 @@ -13,71 +20,71 @@ class ColorType(int, Enum): DARK_ACCENT = 4 -_TAG_COLORS = { - "": { +TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { + TagColor.DEFAULT: { ColorType.PRIMARY: "#1e1e1e", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#333333", ColorType.LIGHT_ACCENT: "#FFFFFF", ColorType.DARK_ACCENT: "#222222", }, - "black": { + TagColor.BLACK: { ColorType.PRIMARY: "#111018", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#18171e", ColorType.LIGHT_ACCENT: "#b7b6be", ColorType.DARK_ACCENT: "#03020a", }, - "dark gray": { + TagColor.DARK_GRAY: { ColorType.PRIMARY: "#24232a", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#2a2930", ColorType.LIGHT_ACCENT: "#bdbcc4", ColorType.DARK_ACCENT: "#07060e", }, - "gray": { + TagColor.GRAY: { ColorType.PRIMARY: "#53525a", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#5b5a62", ColorType.LIGHT_ACCENT: "#cbcad2", ColorType.DARK_ACCENT: "#191820", }, - "light gray": { + TagColor.LIGHT_GRAY: { ColorType.PRIMARY: "#aaa9b0", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#b6b4bc", ColorType.LIGHT_ACCENT: "#cbcad2", ColorType.DARK_ACCENT: "#191820", }, - "white": { + TagColor.WHITE: { ColorType.PRIMARY: "#f2f1f8", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#fefeff", ColorType.LIGHT_ACCENT: "#ffffff", ColorType.DARK_ACCENT: "#302f36", }, - "light pink": { + TagColor.LIGHT_PINK: { ColorType.PRIMARY: "#ff99c4", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#ffaad0", ColorType.LIGHT_ACCENT: "#ffcbe7", ColorType.DARK_ACCENT: "#6c2e3b", }, - "pink": { + TagColor.PINK: { ColorType.PRIMARY: "#ff99c4", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#ffaad0", ColorType.LIGHT_ACCENT: "#ffcbe7", ColorType.DARK_ACCENT: "#6c2e3b", }, - "magenta": { + TagColor.MAGENTA: { ColorType.PRIMARY: "#f6466f", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#f7587f", ColorType.LIGHT_ACCENT: "#fba4bf", ColorType.DARK_ACCENT: "#61152f", }, - "red": { + TagColor.RED: { ColorType.PRIMARY: "#e22c3c", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#b21f2d", @@ -85,35 +92,35 @@ _TAG_COLORS = { ColorType.LIGHT_ACCENT: "#f39caa", ColorType.DARK_ACCENT: "#440d12", }, - "red orange": { + TagColor.RED_ORANGE: { ColorType.PRIMARY: "#e83726", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#ea4b3b", ColorType.LIGHT_ACCENT: "#f5a59d", ColorType.DARK_ACCENT: "#61120b", }, - "salmon": { + TagColor.SALMON: { ColorType.PRIMARY: "#f65848", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#f76c5f", ColorType.LIGHT_ACCENT: "#fcadaa", ColorType.DARK_ACCENT: "#6f1b16", }, - "orange": { + TagColor.ORANGE: { ColorType.PRIMARY: "#ed6022", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#ef7038", ColorType.LIGHT_ACCENT: "#f7b79b", ColorType.DARK_ACCENT: "#551e0a", }, - "yellow orange": { + TagColor.YELLOW_ORANGE: { ColorType.PRIMARY: "#fa9a2c", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#fba94b", ColorType.LIGHT_ACCENT: "#fdd7ab", ColorType.DARK_ACCENT: "#66330d", }, - "yellow": { + TagColor.YELLOW: { ColorType.PRIMARY: "#ffd63d", ColorType.TEXT: ColorType.DARK_ACCENT, # ColorType.BORDER: '#ffe071', @@ -121,154 +128,154 @@ _TAG_COLORS = { ColorType.LIGHT_ACCENT: "#fff3c4", ColorType.DARK_ACCENT: "#754312", }, - "mint": { + TagColor.MINT: { ColorType.PRIMARY: "#4aed90", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#79f2b1", ColorType.LIGHT_ACCENT: "#c8fbe9", ColorType.DARK_ACCENT: "#164f3e", }, - "lime": { + TagColor.LIME: { ColorType.PRIMARY: "#92e649", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#b2ed72", ColorType.LIGHT_ACCENT: "#e9f9b7", ColorType.DARK_ACCENT: "#405516", }, - "light green": { + TagColor.LIGHT_GREEN: { ColorType.PRIMARY: "#85ec76", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#a3f198", ColorType.LIGHT_ACCENT: "#e7fbe4", ColorType.DARK_ACCENT: "#2b5524", }, - "green": { + TagColor.GREEN: { ColorType.PRIMARY: "#28bb48", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#43c568", ColorType.LIGHT_ACCENT: "#93e2c8", ColorType.DARK_ACCENT: "#0d3828", }, - "teal": { + TagColor.TEAL: { ColorType.PRIMARY: "#1ad9b2", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#4de3c7", ColorType.LIGHT_ACCENT: "#a0f3e8", ColorType.DARK_ACCENT: "#08424b", }, - "cyan": { + TagColor.CYAN: { ColorType.PRIMARY: "#49e4d5", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#76ebdf", ColorType.LIGHT_ACCENT: "#bff5f0", ColorType.DARK_ACCENT: "#0f4246", }, - "light blue": { + TagColor.LIGHT_BLUE: { ColorType.PRIMARY: "#55bbf6", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#70c6f7", ColorType.LIGHT_ACCENT: "#bbe4fb", ColorType.DARK_ACCENT: "#122541", }, - "blue": { + TagColor.BLUE: { ColorType.PRIMARY: "#3b87f0", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#4e95f2", ColorType.LIGHT_ACCENT: "#aedbfa", ColorType.DARK_ACCENT: "#122948", }, - "blue violet": { + TagColor.BLUE_VIOLET: { ColorType.PRIMARY: "#5948f2", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#6258f3", ColorType.LIGHT_ACCENT: "#9cb8fb", ColorType.DARK_ACCENT: "#1b1649", }, - "violet": { + TagColor.VIOLET: { ColorType.PRIMARY: "#874ff5", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#9360f6", ColorType.LIGHT_ACCENT: "#c9b0fa", ColorType.DARK_ACCENT: "#3a1860", }, - "purple": { + TagColor.PURPLE: { ColorType.PRIMARY: "#bb4ff0", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#c364f2", ColorType.LIGHT_ACCENT: "#dda7f7", ColorType.DARK_ACCENT: "#531862", }, - "peach": { + TagColor.PEACH: { ColorType.PRIMARY: "#f1c69c", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#f4d4b4", ColorType.LIGHT_ACCENT: "#fbeee1", ColorType.DARK_ACCENT: "#613f2f", }, - "brown": { + TagColor.BROWN: { ColorType.PRIMARY: "#823216", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#8a3e22", ColorType.LIGHT_ACCENT: "#cd9d83", ColorType.DARK_ACCENT: "#3a1804", }, - "lavender": { + TagColor.LAVENDER: { ColorType.PRIMARY: "#ad8eef", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#b99ef2", ColorType.LIGHT_ACCENT: "#d5c7fa", ColorType.DARK_ACCENT: "#492b65", }, - "blonde": { + TagColor.BLONDE: { ColorType.PRIMARY: "#efc664", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#f3d387", ColorType.LIGHT_ACCENT: "#faebc6", ColorType.DARK_ACCENT: "#6d461e", }, - "auburn": { + TagColor.AUBURN: { ColorType.PRIMARY: "#a13220", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#aa402f", ColorType.LIGHT_ACCENT: "#d98a7f", ColorType.DARK_ACCENT: "#3d100a", }, - "light brown": { + TagColor.LIGHT_BROWN: { ColorType.PRIMARY: "#be5b2d", ColorType.TEXT: ColorType.DARK_ACCENT, ColorType.BORDER: "#c4693d", ColorType.LIGHT_ACCENT: "#e5b38c", ColorType.DARK_ACCENT: "#4c290e", }, - "dark brown": { + TagColor.DARK_BROWN: { ColorType.PRIMARY: "#4c2315", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#542a1c", ColorType.LIGHT_ACCENT: "#b78171", ColorType.DARK_ACCENT: "#211006", }, - "cool gray": { + TagColor.COOL_GRAY: { ColorType.PRIMARY: "#515768", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#5b6174", ColorType.LIGHT_ACCENT: "#9ea1c3", ColorType.DARK_ACCENT: "#181a37", }, - "warm gray": { + TagColor.WARM_GRAY: { ColorType.PRIMARY: "#625550", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#6c5e57", ColorType.LIGHT_ACCENT: "#c0a392", ColorType.DARK_ACCENT: "#371d18", }, - "olive": { + TagColor.OLIVE: { ColorType.PRIMARY: "#4c652e", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#586f36", ColorType.LIGHT_ACCENT: "#b4c17a", ColorType.DARK_ACCENT: "#23300e", }, - "berry": { + TagColor.BERRY: { ColorType.PRIMARY: "#9f2aa7", ColorType.TEXT: ColorType.LIGHT_ACCENT, ColorType.BORDER: "#aa43b4", @@ -278,12 +285,14 @@ _TAG_COLORS = { } -def get_tag_color(type, color): - color = color.lower() +def get_tag_color(color_type: ColorType, color_id: TagColor) -> str: try: - if type == ColorType.TEXT: - return get_tag_color(_TAG_COLORS[color][type], color) - else: - return _TAG_COLORS[color][type] + if color_type == ColorType.TEXT: + text_account: ColorType = TAG_COLORS[color_id][color_type] + return get_tag_color(text_account, color_id) + + return TAG_COLORS[color_id][color_type] except KeyError: + traceback.print_stack() + logger.error("Color not found", color_id=color_id) return "#FF00FF" diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 63ac30e6..aeed6409 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -5,78 +5,73 @@ """The core classes and methods of TagStudio.""" import json -import os from pathlib import Path -from enum import Enum from src.core.library import Entry, Library -from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS +from src.core.constants import TS_FOLDER_NAME +from src.core.library.alchemy.fields import _FieldID +from src.core.utils.missing_files import logger class TagStudioCore: - """ - Instantiate this to establish a TagStudio session. - Holds all TagStudio session data and provides methods to manage it. - """ - def __init__(self): self.lib: Library = Library() - def get_gdl_sidecar(self, filepath: str | Path, source: str = "") -> dict: + @classmethod + def get_gdl_sidecar(cls, filepath: Path, source: str = "") -> dict: """ - Attempts to open and dump a Gallery-DL Sidecar sidecar file for - the filepath.\n Returns a formatted object with notable values or an - empty object if none is found. + Attempt to open and dump a Gallery-DL Sidecar file for the filepath. + + Return a formatted object with notable values or an empty object if none is found. """ - json_dump = {} info = {} - _filepath: Path = Path(filepath) - _filepath = _filepath.parent / (_filepath.stem + ".json") + _filepath = filepath.parent / (filepath.stem + ".json") # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. # This may only occur with sidecar files that are downloaded separate from posts. - if source == "instagram": - if not _filepath.is_file(): - newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:] - _filepath = _filepath.parent / (newstem + ".json") + if source == "instagram" and not _filepath.is_file(): + newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:] + _filepath = _filepath.parent / (newstem + ".json") + + logger.info( + "get_gdl_sidecar", filepath=filepath, source=source, sidecar=_filepath + ) try: - with open(_filepath, "r", encoding="utf8") as f: + with open(_filepath, encoding="utf8") as f: json_dump = json.load(f) + if not json_dump: + return {} - if json_dump: - if source == "twitter": - info["content"] = json_dump["content"].strip() - info["date_published"] = json_dump["date"] - elif source == "instagram": - info["description"] = json_dump["description"].strip() - info["date_published"] = json_dump["date"] - elif source == "artstation": - info["title"] = json_dump["title"].strip() - info["artist"] = json_dump["user"]["full_name"].strip() - info["description"] = json_dump["description"].strip() - info["tags"] = json_dump["tags"] - # info["tags"] = [x for x in json_dump["mediums"]["name"]] - info["date_published"] = json_dump["date"] - elif source == "newgrounds": - # info["title"] = json_dump["title"] - # info["artist"] = json_dump["artist"] - # info["description"] = json_dump["description"] - info["tags"] = json_dump["tags"] - info["date_published"] = json_dump["date"] - info["artist"] = json_dump["user"].strip() - info["description"] = json_dump["description"].strip() - info["source"] = json_dump["post_url"].strip() + if source == "twitter": + info[_FieldID.DESCRIPTION] = json_dump["content"].strip() + info[_FieldID.DATE_PUBLISHED] = json_dump["date"] + elif source == "instagram": + info[_FieldID.DESCRIPTION] = json_dump["description"].strip() + info[_FieldID.DATE_PUBLISHED] = json_dump["date"] + elif source == "artstation": + info[_FieldID.TITLE] = json_dump["title"].strip() + info[_FieldID.ARTIST] = json_dump["user"]["full_name"].strip() + info[_FieldID.DESCRIPTION] = json_dump["description"].strip() + info[_FieldID.TAGS] = json_dump["tags"] + # info["tags"] = [x for x in json_dump["mediums"]["name"]] + info[_FieldID.DATE_PUBLISHED] = json_dump["date"] + elif source == "newgrounds": + # info["title"] = json_dump["title"] + # info["artist"] = json_dump["artist"] + # info["description"] = json_dump["description"] + info[_FieldID.TAGS] = json_dump["tags"] + info[_FieldID.DATE_PUBLISHED] = json_dump["date"] + info[_FieldID.ARTIST] = json_dump["user"].strip() + info[_FieldID.DESCRIPTION] = json_dump["description"].strip() + info[_FieldID.SOURCE] = json_dump["post_url"].strip() # else: # print( # f'[INFO]: TagStudio does not currently support sidecar files for "{source}"') - # except FileNotFoundError: - except: - # print( - # f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"') - pass + except Exception: + logger.exception("Error handling sidecar file.", path=_filepath) return info @@ -103,102 +98,86 @@ class TagStudioCore: # # # print("Could not resolve URL.") # # pass - def match_conditions(self, entry_id: int) -> None: - """Matches defined conditions against a file to add Entry data.""" + @classmethod + def match_conditions(cls, lib: Library, entry_id: int) -> bool: + """Match defined conditions against a file to add Entry data.""" - cond_file = self.lib.library_dir / TS_FOLDER_NAME / "conditions.json" + # TODO - what even is this file format? # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - entry: Entry = self.lib.get_entry(entry_id) + cond_file = lib.library_dir / TS_FOLDER_NAME / "conditions.json" + if not cond_file.is_file(): + return False + + entry: Entry = lib.get_entry(entry_id) + try: - if cond_file.is_file(): - with open(cond_file, "r", encoding="utf8") as f: - json_dump = json.load(f) - for c in json_dump["conditions"]: - match: bool = False - for path_c in c["path_conditions"]: - if str(Path(path_c).resolve()) in str(entry.path): - match = True - break - if match: - if fields := c.get("fields"): - for field in fields: - field_id = self.lib.get_field_attr(field, "id") - content = field[field_id] + with open(cond_file, encoding="utf8") as f: + json_dump = json.load(f) + for c in json_dump["conditions"]: + match: bool = False + for path_c in c["path_conditions"]: + if Path(path_c).is_relative_to(entry.path): + match = True + break - if ( - self.lib.get_field_obj(int(field_id))["type"] - == "tag_box" - ): - existing_fields: list[int] = ( - self.lib.get_field_index_in_entry( - entry, field_id - ) - ) - if existing_fields: - self.lib.update_entry_field( - entry_id, - existing_fields[0], - content, - "append", - ) - else: - self.lib.add_field_to_entry( - entry_id, field_id - ) - self.lib.update_entry_field( - entry_id, -1, content, "append" - ) + if not match: + return False - if ( - self.lib.get_field_obj(int(field_id))["type"] - in TEXT_FIELDS - ): - if not self.lib.does_field_content_exist( - entry_id, field_id, content - ): - self.lib.add_field_to_entry( - entry_id, field_id - ) - self.lib.update_entry_field( - entry_id, -1, content, "replace" - ) - except: - print("Error in match_conditions...") - # input() - pass + if not c.get("fields"): + return False - def build_url(self, entry_id: int, source: str): - """Tries to rebuild a source URL given a specific filename structure.""" + fields = c["fields"] + entry_field_types = { + field.type_key: field for field in entry.fields + } + + for field in fields: + is_new = field["id"] not in entry_field_types + field_key = field["id"] + if is_new: + lib.add_entry_field_type( + entry.id, field_key, field["value"] + ) + else: + lib.update_entry_field(entry.id, field_key, field["value"]) + + except Exception: + logger.exception("Error matching conditions.", entry=entry) + + return False + + @classmethod + def build_url(cls, entry: Entry, source: str): + """Try to rebuild a source URL given a specific filename structure.""" source = source.lower().replace("-", " ").replace("_", " ") if "twitter" in source: - return self._build_twitter_url(entry_id) + return cls._build_twitter_url(entry) elif "instagram" in source: - return self._build_instagram_url(entry_id) + return cls._build_instagram_url(entry) - def _build_twitter_url(self, entry_id: int): + @classmethod + def _build_twitter_url(cls, entry: Entry): """ - Builds an Twitter URL given a specific filename structure. + Build a Twitter URL given a specific filename structure. Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD' """ try: - entry = self.lib.get_entry(entry_id) - stubs = str(entry.filename).rsplit("_", 3) - # print(stubs) - # source, author = os.path.split(entry.path) + stubs = str(entry.path.name).rsplit("_", 3) url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}" return url - except: + except Exception: + logger.exception("Error building Twitter URL.", entry=entry) return "" - def _build_instagram_url(self, entry_id: int): + @classmethod + def _build_instagram_url(cls, entry: Entry): """ - Builds an Instagram URL given a specific filename structure. + Build an Instagram URL given a specific filename structure. Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD' """ try: - entry = self.lib.get_entry(entry_id) - stubs = str(entry.filename).rsplit("_", 2) + stubs = str(entry.path.name).rsplit("_", 2) # stubs[0] = stubs[0].replace(f"{author}_", '', 1) # print(stubs) # NOTE: Both Instagram usernames AND their ID can have underscores in them, @@ -207,5 +186,6 @@ class TagStudioCore: # seems to more or less be the case... for now... url = f"www.instagram.com/p/{stubs[-3][-11:]}" return url - except: + except Exception: + logger.exception("Error building Instagram URL.", entry=entry) return "" diff --git a/tagstudio/src/core/utils/dupe_files.py b/tagstudio/src/core/utils/dupe_files.py new file mode 100644 index 00000000..71947018 --- /dev/null +++ b/tagstudio/src/core/utils/dupe_files.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass, field +from pathlib import Path +import xml.etree.ElementTree as ET + +import structlog + +from src.core.library import Library, Entry +from src.core.library.alchemy.enums import FilterState + +logger = structlog.get_logger() + + +@dataclass +class DupeRegistry: + """State handler for DupeGuru results.""" + + library: Library + groups: list[list[Entry]] = field(default_factory=list) + + @property + def groups_count(self) -> int: + return len(self.groups) + + def refresh_dupe_files(self, results_filepath: str | Path): + """ + Refresh the list of duplicate files. + A duplicate file is defined as an identical or near-identical file as determined + by a DupeGuru results file. + """ + library_dir = self.library.library_dir + if not isinstance(results_filepath, Path): + results_filepath = Path(results_filepath) + + if not results_filepath.is_file(): + raise ValueError("invalid file path") + + self.groups.clear() + tree = ET.parse(results_filepath) + root = tree.getroot() + for group in root: + # print(f'-------------------- Match Group {i}---------------------') + files: list[Entry] = [] + for element in group: + if element.tag == "file": + file_path = Path(element.attrib.get("path")) + + try: + path_relative = file_path.relative_to(library_dir) + except ValueError: + # The file is not in the library directory + continue + + _, entries = self.library.search_library( + FilterState(path=path_relative), + ) + + if not entries: + # file not in library + continue + + files.append(entries[0]) + + if not len(files) > 1: + # only one file in the group, nothing to do + continue + + self.groups.append(files) + + def merge_dupe_entries(self): + """ + Merge the duplicate Entry items. + A duplicate Entry is defined as an Entry pointing to a file that one or more other Entries are also pointing to + """ + logger.info( + "Consolidating Entries... (This may take a while for larger libraries)", + groups=len(self.groups), + ) + + for i, entries in enumerate(self.groups): + remove_ids = [x.id for x in entries[1:]] + logger.info("Removing entries group", ids=remove_ids) + self.library.remove_entries(remove_ids) + yield i - 1 # The -1 waits for the next step to finish diff --git a/tagstudio/src/core/utils/fs.py b/tagstudio/src/core/utils/fs.py deleted file mode 100644 index 44ba1e88..00000000 --- a/tagstudio/src/core/utils/fs.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - -def clean_folder_name(folder_name: str) -> str: - cleaned_name = folder_name - invalid_chars = '<>:"/\\|?*.' - for char in invalid_chars: - cleaned_name = cleaned_name.replace(char, "_") - return cleaned_name diff --git a/tagstudio/src/core/utils/missing_files.py b/tagstudio/src/core/utils/missing_files.py new file mode 100644 index 00000000..8ecf9a4c --- /dev/null +++ b/tagstudio/src/core/utils/missing_files.py @@ -0,0 +1,71 @@ +from collections.abc import Iterator +from dataclasses import field, dataclass +from pathlib import Path + +import structlog + +from src.core.library import Library, Entry + +IGNORE_ITEMS = [ + "$recycle.bin", +] + +logger = structlog.get_logger() + + +@dataclass +class MissingRegistry: + """State tracker for unlinked and moved files.""" + + library: Library + files_fixed_count: int = 0 + missing_files: list[Entry] = field(default_factory=list) + + @property + def missing_files_count(self) -> int: + return len(self.missing_files) + + def refresh_missing_files(self) -> Iterator[int]: + """Track the number of Entries that point to an invalid file path.""" + logger.info("refresh_missing_files running") + self.missing_files = [] + for i, entry in enumerate(self.library.get_entries()): + full_path = self.library.library_dir / entry.path + if not full_path.exists() or not full_path.is_file(): + self.missing_files.append(entry) + yield i + + def match_missing_file(self, match_item: Entry) -> list[Path]: + """ + Try to find missing entry files within the library directory. + Works if files were just moved to different subfolders and don't have duplicate names. + """ + + matches = [] + for item in self.library.library_dir.glob(f"**/{match_item.path.name}"): + if item.name == match_item.path.name: # TODO - implement IGNORE_ITEMS + new_path = Path(item).relative_to(self.library.library_dir) + matches.append(new_path) + + return matches + + def fix_missing_files(self) -> Iterator[int]: + """Attempt to fix missing files by finding a match in the library directory.""" + self.files_fixed_count = 0 + for i, entry in enumerate(self.missing_files, start=1): + item_matches = self.match_missing_file(entry) + if len(item_matches) == 1: + logger.info("fix_missing_files", entry=entry, item_matches=item_matches) + self.library.update_entry_path(entry.id, item_matches[0]) + self.files_fixed_count += 1 + # remove fixed file + self.missing_files.remove(entry) + yield i + + def execute_deletion(self) -> Iterator[int]: + for i, missing in enumerate(self.missing_files, start=1): + # TODO - optimize this by removing multiple entries at once + self.library.remove_entries([missing.id]) + yield i + + self.missing_files = [] diff --git a/tagstudio/src/core/utils/refresh_dir.py b/tagstudio/src/core/utils/refresh_dir.py new file mode 100644 index 00000000..b265f395 --- /dev/null +++ b/tagstudio/src/core/utils/refresh_dir.py @@ -0,0 +1,73 @@ +import time +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path + +from src.core.constants import TS_FOLDER_NAME +from src.core.library import Library, Entry + + +@dataclass +class RefreshDirTracker: + library: Library + dir_file_count: int = 0 + files_not_in_library: list[Path] = field(default_factory=list) + + @property + def files_count(self) -> int: + return len(self.files_not_in_library) + + def save_new_files(self) -> Iterator[int]: + """Save the list of files that are not in the library.""" + if not self.files_not_in_library: + yield 0 + + for idx, entry_path in enumerate(self.files_not_in_library): + self.library.add_entries( + [ + Entry( + path=entry_path, + folder=self.library.folder, + fields=self.library.default_fields, + ) + ] + ) + yield idx + + self.files_not_in_library = [] + + def refresh_dir(self) -> Iterator[int]: + """Scan a directory for files, and add those relative filenames to internal variables.""" + if self.library.folder is None: + raise ValueError("No folder set.") + + start_time = time.time() + self.files_not_in_library = [] + self.dir_file_count = 0 + + lib_path = self.library.folder.path + + for path in lib_path.glob("**/*"): + str_path = str(path) + if ( + path.is_dir() + or "$RECYCLE.BIN" in str_path + or TS_FOLDER_NAME in str_path + or "tagstudio_thumbs" in str_path + ): + continue + + suffix = path.suffix.lower().lstrip(".") + if suffix in self.library.ignored_extensions: + continue + + self.dir_file_count += 1 + relative_path = path.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) + + end_time = time.time() + # Yield output every 1/30 of a second + if (end_time - start_time) > 0.034: + yield self.dir_file_count diff --git a/tagstudio/src/qt/flowlayout.py b/tagstudio/src/qt/flowlayout.py index 7e366221..05bcc1b0 100644 --- a/tagstudio/src/qt/flowlayout.py +++ b/tagstudio/src/qt/flowlayout.py @@ -72,14 +72,12 @@ class FlowLayout(QLayout): return height def setGeometry(self, rect): - super(FlowLayout, self).setGeometry(rect) + super().setGeometry(rect) self._do_layout(rect, False) - def setGridEfficiency(self, bool): - """ - Enables or Disables efficiencies when all objects are equally sized. - """ - self.grid_efficiency = bool + def setGridEfficiency(self, value: bool): + """Enable or Disable efficiencies when all objects are equally sized.""" + self.grid_efficiency = value def sizeHint(self): return self.minimumSize() @@ -101,27 +99,29 @@ class FlowLayout(QLayout): ) return size - def _do_layout(self, rect, test_only): + def _do_layout(self, rect: QRect, test_only: bool) -> float: x = rect.x() y = rect.y() line_height = 0 spacing = self.spacing() - item = None - style = None layout_spacing_x = None layout_spacing_y = None - if self.grid_efficiency: - if self._item_list: - item = self._item_list[0] - style = item.widget().style() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical - ) - for i, item in enumerate(self._item_list): + if self.grid_efficiency and self._item_list: + item = self._item_list[0] + style = item.widget().style() + layout_spacing_x = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, + ) + + for item in self._item_list: # print(issubclass(type(item.widget()), FlowWidget)) # print(item.widget().ignore_size) skip_count = 0 @@ -139,10 +139,14 @@ class FlowLayout(QLayout): if not self.grid_efficiency: style = item.widget().style() layout_spacing_x = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, ) layout_spacing_y = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, ) space_x = spacing + layout_spacing_x space_y = spacing + layout_spacing_y diff --git a/tagstudio/src/qt/helpers/file_opener.py b/tagstudio/src/qt/helpers/file_opener.py index 76ef3666..d9afcd73 100644 --- a/tagstudio/src/qt/helpers/file_opener.py +++ b/tagstudio/src/qt/helpers/file_opener.py @@ -2,22 +2,18 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging -import os import subprocess import shutil import sys import traceback from pathlib import Path +import structlog from PySide6.QtWidgets import QLabel from PySide6.QtCore import Qt -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) def open_file(path: str | Path, file_manager: bool = False): @@ -28,14 +24,15 @@ def open_file(path: str | Path, file_manager: bool = False): file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS). Defaults to False. """ - _path = str(path) - logging.info(f"Opening file: {_path}") - if not os.path.exists(_path): - logging.error(f"File not found: {_path}") + path = Path(path) + logger.info("Opening file", path=path) + if not path.exists(): + logger.error("File not found", path=path) return + try: if sys.platform == "win32": - normpath = os.path.normpath(_path) + normpath = Path(path).resolve().as_posix() if file_manager: command_name = "explorer" command_args = '/select,"' + normpath + '"' @@ -61,7 +58,7 @@ def open_file(path: str | Path, file_manager: bool = False): else: if sys.platform == "darwin": command_name = "open" - command_args = [_path] + command_args = [str(path)] if file_manager: # will reveal in Finder command_args.append("-R") @@ -75,18 +72,20 @@ def open_file(path: str | Path, file_manager: bool = False): "--type=method_call", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", - f"array:string:file://{_path}", + f"array:string:file://{str(path)}", "string:", ] else: command_name = "xdg-open" - command_args = [_path] + command_args = [str(path)] command = shutil.which(command_name) if command is not None: subprocess.Popen([command] + command_args, close_fds=True) else: - logging.info(f"Could not find {command_name} on system PATH") - except: + logger.info( + "Could not find command on system PATH", command=command_name + ) + except Exception: traceback.print_exc() @@ -144,9 +143,9 @@ class FileOpenerLabel(QLabel): """ super().mousePressEvent(event) - if event.button() == Qt.LeftButton: + if event.button() == Qt.MouseButton.LeftButton: opener = FileOpenerHelper(self.filepath) opener.open_explorer() - elif event.button() == Qt.RightButton: + elif event.button() == Qt.MouseButton.RightButton: # Show context menu pass diff --git a/tagstudio/src/qt/helpers/function_iterator.py b/tagstudio/src/qt/helpers/function_iterator.py index 197f90a6..770e2846 100644 --- a/tagstudio/src/qt/helpers/function_iterator.py +++ b/tagstudio/src/qt/helpers/function_iterator.py @@ -1,14 +1,13 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - +from collections.abc import Callable from PySide6.QtCore import Signal, QObject -from typing import Callable class FunctionIterator(QObject): - """Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™""" + """Iterate over a yielding function and emit progress as the 'value' signal.""" value = Signal(object) diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index a77f8744..ce6b1e33 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -36,7 +36,7 @@ class Ui_MainWindow(QMainWindow): def __init__(self, driver: "QtDriver", parent=None) -> None: super().__init__(parent) - self.driver: "QtDriver" = driver + self.driver = driver self.setupUi(self) # NOTE: These are old attempts to allow for a translucent/acrylic @@ -235,4 +235,4 @@ class Ui_MainWindow(QMainWindow): else: self.landing_widget.setHidden(True) self.landing_widget.set_status_label("") - self.scrollArea.setHidden(False) \ No newline at end of file + self.scrollArea.setHidden(False) diff --git a/tagstudio/src/qt/modals/add_field.py b/tagstudio/src/qt/modals/add_field.py index c0137da2..9e1ef5c7 100644 --- a/tagstudio/src/qt/modals/add_field.py +++ b/tagstudio/src/qt/modals/add_field.py @@ -10,23 +10,24 @@ from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, - QComboBox, + QListWidget, + QListWidgetItem, ) from src.core.library import Library class AddFieldModal(QWidget): - done = Signal(int) + done = Signal(list) - def __init__(self, library: "Library"): + def __init__(self, library: Library): # [Done] # - OR - # [Cancel] [Save] super().__init__() self.is_connected = False self.lib = library - self.setWindowTitle(f"Add Field") + self.setWindowTitle("Add Field") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(400, 300) self.root_layout = QVBoxLayout(self) @@ -43,17 +44,7 @@ class AddFieldModal(QWidget): self.title_widget.setText("Add Field") self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.combo_box = QComboBox() - self.combo_box.setEditable(False) - # self.combo_box.setMaxVisibleItems(5) - self.combo_box.setStyleSheet("combobox-popup:0;") - self.combo_box.view().setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - for df in self.lib.default_fields: - self.combo_box.addItem( - f'{df["name"]} ({df["type"].replace("_", " ").title()})' - ) + self.list_widget = QListWidget() self.button_container = QWidget() self.button_layout = QHBoxLayout(self.button_container) @@ -75,17 +66,25 @@ class AddFieldModal(QWidget): self.save_button.setDefault(True) self.save_button.clicked.connect(self.hide) self.save_button.clicked.connect( - lambda: self.done.emit(self.combo_box.currentIndex()) + lambda: ( + # get userData for each selected item + self.done.emit(self.list_widget.selectedItems()) + ) ) - # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) self.button_layout.addWidget(self.save_button) - # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - - # self.done.connect(lambda x: callback(x)) - self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(self.combo_box) + self.root_layout.addWidget(self.list_widget) # self.root_layout.setStretch(1,2) + self.root_layout.addStretch(1) self.root_layout.addWidget(self.button_container) + + def show(self): + self.list_widget.clear() + for df in self.lib.field_types.values(): + item = QListWidgetItem(f"{df.name} ({df.type.value})") + item.setData(Qt.ItemDataRole.UserRole, df.key) + self.list_widget.addItem(item) + + super().show() diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index c7fa543e..5507fb09 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -3,8 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging - +import structlog from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import ( QWidget, @@ -18,30 +17,26 @@ from PySide6.QtWidgets import ( QComboBox, ) -from src.core.library import Library, Tag +from src.core.library import Tag, Library +from src.core.library.alchemy.enums import TagColor from src.core.palette import ColorType, get_tag_color -from src.core.constants import TAG_COLORS + from src.qt.widgets.panel import PanelWidget, PanelModal from src.qt.widgets.tag import TagWidget from src.qt.modals.tag_search import TagSearchPanel - -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" - -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class BuildTagPanel(PanelWidget): on_edit = Signal(Tag) - def __init__(self, library, tag_id: int = -1): + def __init__(self, library: Library, tag: Tag | None = None): super().__init__() - self.lib: Library = library + self.lib = library # self.callback = callback # self.tag_id = tag_id - self.tag = None + self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) @@ -95,6 +90,7 @@ class BuildTagPanel(PanelWidget): self.subtags_layout.setContentsMargins(0, 0, 0, 0) self.subtags_layout.setSpacing(0) self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.subtags_title = QLabel() self.subtags_title.setText("Parent Tags") self.subtags_layout.addWidget(self.subtags_title) @@ -140,15 +136,18 @@ class BuildTagPanel(PanelWidget): self.color_field.setEditable(False) self.color_field.setMaxVisibleItems(10) self.color_field.setStyleSheet("combobox-popup:0;") - for color in TAG_COLORS: - self.color_field.addItem(color.title()) + for color in TagColor: + self.color_field.addItem(color.name, userData=color.value) # self.color_field.setProperty("appearance", "flat") - self.color_field.currentTextChanged.connect( - lambda c: self.color_field.setStyleSheet(f"""combobox-popup:0; - font-weight:600; - color:{get_tag_color(ColorType.TEXT, c.lower())}; - background-color:{get_tag_color(ColorType.PRIMARY, c.lower())}; - """) + self.color_field.currentIndexChanged.connect( + lambda c: ( + self.color_field.setStyleSheet( + "combobox-popup:0;" + "font-weight:600;" + f"color:{get_tag_color(ColorType.TEXT, self.color_field.currentData())};" + f"background-color:{get_tag_color(ColorType.PRIMARY, self.color_field.currentData())};" + ) + ) ) self.color_layout.addWidget(self.color_field) @@ -160,86 +159,59 @@ class BuildTagPanel(PanelWidget): self.root_layout.addWidget(self.color_widget) # self.parent().done.connect(self.update_tag) - if tag_id >= 0: - self.tag = self.lib.get_tag(tag_id) - else: - self.tag = Tag(-1, "New Tag", "", [], [], "") - self.set_tag(self.tag) + # TODO - fill subtags + self.subtags: set[int] = set() + self.set_tag(tag or Tag(name="New Tag")) def add_subtag_callback(self, tag_id: int): - logging.info(f"adding {tag_id}") - # tag = self.lib.get_tag(self.tag_id) - # TODO: Create a single way to update tags and refresh library data - # new = self.build_tag() - self.tag.add_subtag(tag_id) - # self.tag = new - # self.lib.update_tag(new) + logger.info("add_subtag_callback", tag_id=tag_id) + self.subtags.add(tag_id) self.set_subtags() - # self.on_edit.emit(self.build_tag()) def remove_subtag_callback(self, tag_id: int): - logging.info(f"removing {tag_id}") - # tag = self.lib.get_tag(self.tag_id) - # TODO: Create a single way to update tags and refresh library data - # new = self.build_tag() - self.tag.remove_subtag(tag_id) - # self.tag = new - # self.lib.update_tag(new) + logger.info("removing subtag", tag_id=tag_id) + self.subtags.remove(tag_id) self.set_subtags() - # self.on_edit.emit(self.build_tag()) def set_subtags(self): while self.scroll_layout.itemAt(0): self.scroll_layout.takeAt(0).widget().deleteLater() - logging.info(f"Setting {self.tag.subtag_ids}") + c = QWidget() - l = QVBoxLayout(c) - l.setContentsMargins(0, 0, 0, 0) - l.setSpacing(3) - for tag_id in self.tag.subtag_ids: - tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True) - tw.on_remove.connect( - lambda checked=False, t=tag_id: self.remove_subtag_callback(t) - ) - l.addWidget(tw) + layout = QVBoxLayout(c) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + for tag_id in self.subtags: + tag = self.lib.get_tag(tag_id) + tw = TagWidget(tag, False, True) + tw.on_remove.connect(lambda t=tag_id: self.remove_subtag_callback(t)) + layout.addWidget(tw) self.scroll_layout.addWidget(c) def set_tag(self, tag: Tag): - # tag = self.lib.get_tag(tag_id) + logger.info("setting tag", tag=tag) + self.name_field.setText(tag.name) - self.shorthand_field.setText(tag.shorthand) - self.aliases_field.setText("\n".join(tag.aliases)) + self.shorthand_field.setText(tag.shorthand or "") + # TODO: Implement aliases + # self.aliases_field.setText("\n".join(tag.aliases)) self.set_subtags() - self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower())) - # self.tag_id = tag.id + # select item in self.color_field where the userData value matched tag.color + for i in range(self.color_field.count()): + if self.color_field.itemData(i) == tag.color: + self.color_field.setCurrentIndex(i) + break + + self.tag = tag def build_tag(self) -> Tag: - # tag: Tag = self.tag - # if self.tag_id >= 0: - # tag = self.lib.get_tag(self.tag_id) - # else: - # tag = Tag(-1, '', '', [], [], '') - new_tag: Tag = Tag( - id=self.tag.id, - name=self.name_field.text(), - shorthand=self.shorthand_field.text(), - aliases=self.aliases_field.toPlainText().split("\n"), - subtags_ids=self.tag.subtag_ids, - color=self.color_field.currentText().lower(), - ) - logging.info(f"built {new_tag}") - return new_tag + color = self.color_field.currentData() or TagColor.DEFAULT - # NOTE: The callback and signal do the same thing, I'm currently - # transitioning from using callbacks to the Qt method of using signals. - # self.tag_updated.emit(new_tag) - # self.callback(new_tag) + tag = self.tag - # def on_return(self, callback, text:str): - # if text and self.first_tag_id >= 0: - # callback(self.first_tag_id) - # self.search_field.setText('') - # self.update_tags('') - # else: - # self.search_field.setFocus() - # self.parentWidget().hide() + tag.name = self.name_field.text() + tag.shorthand = self.shorthand_field.text() + tag.color = color + + logger.info("built tag", tag=tag) + return tag diff --git a/tagstudio/src/qt/modals/delete_unlinked.py b/tagstudio/src/qt/modals/delete_unlinked.py index 453148f9..19a3af08 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -15,7 +15,7 @@ from PySide6.QtWidgets import ( QListView, ) -from src.core.library import ItemType, Library +from src.core.utils.missing_files import MissingRegistry from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator from src.qt.widgets.progress import ProgressWidget @@ -28,10 +28,10 @@ if typing.TYPE_CHECKING: class DeleteUnlinkedEntriesModal(QWidget): done = Signal() - def __init__(self, library: "Library", driver: "QtDriver"): + def __init__(self, driver: "QtDriver", tracker: MissingRegistry): super().__init__() - self.lib = library self.driver = driver + self.tracker = tracker self.setWindowTitle("Delete Unlinked Entries") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(500, 400) @@ -42,7 +42,7 @@ class DeleteUnlinkedEntriesModal(QWidget): self.desc_widget.setObjectName("descriptionLabel") self.desc_widget.setWordWrap(True) self.desc_widget.setText(f""" - Are you sure you want to delete the following {len(self.lib.missing_files)} entries? + Are you sure you want to delete the following {self.tracker.missing_files_count} entries? """) self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -73,35 +73,38 @@ class DeleteUnlinkedEntriesModal(QWidget): def refresh_list(self): self.desc_widget.setText(f""" - Are you sure you want to delete the following {len(self.lib.missing_files)} entries? + Are you sure you want to delete the following {self.tracker.missing_files_count} entries? """) self.model.clear() - for i in self.lib.missing_files: - self.model.appendRow(QStandardItem(str(i))) + for i in self.tracker.missing_files: + self.model.appendRow(QStandardItem(str(i.path))) def delete_entries(self): - iterator = FunctionIterator(self.lib.remove_missing_files) - pw = ProgressWidget( window_title="Deleting Entries", label_text="", cancel_button_text=None, minimum=0, - maximum=len(self.lib.missing_files), + maximum=self.tracker.missing_files_count, ) pw.show() - iterator.value.connect(lambda x: pw.update_progress(x[0] + 1)) + iterator = FunctionIterator(self.tracker.execute_deletion) + files_count = self.tracker.missing_files_count iterator.value.connect( - lambda x: pw.update_label( - f"Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries" + lambda idx: ( + pw.update_progress(idx), + pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"), ) ) - iterator.value.connect( - lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]) - ) - r = CustomRunnable(lambda: iterator.run()) + r = CustomRunnable(iterator.run) QThreadPool.globalInstance().start(r) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) + r.done.connect( + lambda: ( + pw.hide(), + pw.deleteLater(), + self.done.emit(), + ) + ) diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index c95b520b..c631cd50 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -19,13 +19,17 @@ from PySide6.QtWidgets import ( from src.core.library import Library from src.qt.widgets.panel import PanelWidget +from src.core.constants import LibraryPrefs class FileExtensionItemDelegate(QStyledItemDelegate): def setModelData(self, editor, model, index): - if isinstance(editor, QLineEdit): - if editor.text() and not editor.text().startswith("."): - editor.setText(f".{editor.text()}") + if ( + isinstance(editor, QLineEdit) + and editor.text() + and not editor.text().startswith(".") + ): + editor.setText(f".{editor.text()}") super().setModelData(editor, model, index) @@ -43,7 +47,7 @@ class FileExtensionModal(PanelWidget): self.root_layout.setContentsMargins(6, 6, 6, 6) # Create Table Widget -------------------------------------------------- - self.table = QTableWidget(len(self.lib.ext_list), 1) + self.table = QTableWidget(len(self.lib.prefs(LibraryPrefs.EXTENSION_LIST)), 1) self.table.horizontalHeader().setVisible(False) self.table.verticalHeader().setVisible(False) self.table.horizontalHeader().setStretchLastSection(True) @@ -65,9 +69,12 @@ class FileExtensionModal(PanelWidget): self.mode_label.setText("List Mode:") self.mode_combobox = QComboBox() self.mode_combobox.setEditable(False) - self.mode_combobox.addItem("Exclude") self.mode_combobox.addItem("Include") - self.mode_combobox.setCurrentIndex(0 if self.lib.is_exclude_list else 1) + self.mode_combobox.addItem("Exclude") + + is_exclude_list = int(bool(self.lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST))) + + self.mode_combobox.setCurrentIndex(is_exclude_list) self.mode_combobox.currentIndexChanged.connect( lambda i: self.update_list_mode(i) ) @@ -91,23 +98,23 @@ class FileExtensionModal(PanelWidget): Args: mode (int): The list mode, given by the index of the mode inside - the mode combobox. 0 for "Exclude", 1 for "Include". + the mode combobox. 1 for "Exclude", 0 for "Include". """ - if mode == 0: - self.lib.is_exclude_list = True - elif mode == 1: - self.lib.is_exclude_list = False + self.lib.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, bool(mode)) def refresh_list(self): - for i, ext in enumerate(self.lib.ext_list): + for i, ext in enumerate(self.lib.prefs(LibraryPrefs.EXTENSION_LIST)): self.table.setItem(i, 0, QTableWidgetItem(ext)) def add_item(self): self.table.insertRow(self.table.rowCount()) def save(self): - self.lib.ext_list.clear() + extensions = [] for i in range(self.table.rowCount()): ext = self.table.item(i, 0) - if ext and ext.text(): - self.lib.ext_list.append(ext.text().lower()) + if ext and ext.text().strip(): + extensions.append(ext.text().strip().lower()) + + # save preference + self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions) diff --git a/tagstudio/src/qt/modals/fix_dupes.py b/tagstudio/src/qt/modals/fix_dupes.py index b471f076..e323c20e 100644 --- a/tagstudio/src/qt/modals/fix_dupes.py +++ b/tagstudio/src/qt/modals/fix_dupes.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import os import typing from PySide6.QtCore import Qt @@ -17,6 +16,7 @@ from PySide6.QtWidgets import ( ) from src.core.library import Library +from src.core.utils.dupe_files import DupeRegistry from src.qt.modals.mirror_entities import MirrorEntriesModal # Only import for type checking/autocompletion, will not be imported at runtime. @@ -32,12 +32,14 @@ class FixDupeFilesModal(QWidget): self.driver = driver self.count = -1 self.filename = "" - self.setWindowTitle(f"Fix Duplicate Files") + self.setWindowTitle("Fix Duplicate Files") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(400, 300) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) + self.tracker = DupeRegistry(library=self.lib) + self.desc_widget = QLabel() self.desc_widget.setObjectName("descriptionLabel") self.desc_widget.setWordWrap(True) @@ -80,13 +82,14 @@ class FixDupeFilesModal(QWidget): self.open_button = QPushButton() self.open_button.setText("&Load DupeGuru File") - self.open_button.clicked.connect(lambda: self.select_file()) + self.open_button.clicked.connect(self.select_file) + + self.mirror_modal = MirrorEntriesModal(self.driver, self.tracker) + self.mirror_modal.done.connect(self.refresh_dupes) self.mirror_button = QPushButton() - self.mirror_modal = MirrorEntriesModal(self.lib, self.driver) - self.mirror_modal.done.connect(lambda: self.refresh_dupes()) self.mirror_button.setText("&Mirror Entries") - self.mirror_button.clicked.connect(lambda: self.mirror_modal.show()) + self.mirror_button.clicked.connect(self.mirror_modal.show) self.mirror_desc = QLabel() self.mirror_desc.setWordWrap(True) self.mirror_desc.setText( @@ -134,7 +137,7 @@ class FixDupeFilesModal(QWidget): self.root_layout.addStretch(1) self.root_layout.addWidget(self.button_container) - self.set_dupe_count(self.count) + self.set_dupe_count(-1) def select_file(self): qfd = QFileDialog(self, "Open DupeGuru Results File", str(self.lib.library_dir)) @@ -155,15 +158,14 @@ class FixDupeFilesModal(QWidget): self.mirror_modal.refresh_list() def refresh_dupes(self): - self.lib.refresh_dupe_files(self.filename) - self.set_dupe_count(len(self.lib.dupe_files)) + self.tracker.refresh_dupe_files(self.filename) + self.set_dupe_count(self.tracker.groups_count) def set_dupe_count(self, count: int): - self.count = count - if self.count < 0: + if count < 0: self.mirror_button.setDisabled(True) - self.dupe_count.setText(f"Duplicate File Matches: N/A") - elif self.count == 0: + self.dupe_count.setText("Duplicate File Matches: N/A") + elif count == 0: self.mirror_button.setDisabled(True) self.dupe_count.setText(f"Duplicate File Matches: {count}") else: diff --git a/tagstudio/src/qt/modals/fix_unlinked.py b/tagstudio/src/qt/modals/fix_unlinked.py index 72d115cd..b933aa6d 100644 --- a/tagstudio/src/qt/modals/fix_unlinked.py +++ b/tagstudio/src/qt/modals/fix_unlinked.py @@ -3,13 +3,13 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import typing from PySide6.QtCore import Qt, QThreadPool from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from src.core.library import Library +from src.core.utils.missing_files import MissingRegistry from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal @@ -22,18 +22,14 @@ if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" - -logging.basicConfig(format="%(message)s", level=logging.INFO) - - class FixUnlinkedEntriesModal(QWidget): def __init__(self, library: "Library", driver: "QtDriver"): super().__init__() self.lib = library self.driver = driver + + self.tracker = MissingRegistry(library=self.lib) + self.missing_count = -1 self.dupe_count = -1 self.setWindowTitle("Fix Unlinked Entries") @@ -50,14 +46,6 @@ class FixUnlinkedEntriesModal(QWidget): """Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""" ) - self.dupe_desc_widget = QLabel() - self.dupe_desc_widget.setObjectName("dupeDescriptionLabel") - self.dupe_desc_widget.setWordWrap(True) - self.dupe_desc_widget.setStyleSheet("text-align:left;") - self.dupe_desc_widget.setText( - """Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio.""" - ) - self.missing_count_label = QLabel() self.missing_count_label.setObjectName("missingCountLabel") self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;") @@ -70,42 +58,37 @@ class FixUnlinkedEntriesModal(QWidget): self.refresh_unlinked_button = QPushButton() self.refresh_unlinked_button.setText("&Refresh All") - self.refresh_unlinked_button.clicked.connect( - lambda: self.refresh_missing_files() - ) + self.refresh_unlinked_button.clicked.connect(self.refresh_missing_files) self.merge_class = MergeDuplicateEntries(self.lib, self.driver) - self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver) + self.relink_class = RelinkUnlinkedEntries(self.tracker) self.search_button = QPushButton() self.search_button.setText("&Search && Relink") self.relink_class.done.connect( - lambda: self.refresh_and_repair_dupe_entries(self.merge_class) + # refresh the grid + lambda: ( + self.driver.filter_items(), + self.refresh_missing_files(), + ) ) - self.search_button.clicked.connect(lambda: self.relink_class.repair_entries()) - - self.refresh_dupe_button = QPushButton() - self.refresh_dupe_button.setText("Refresh Duplicate Entries") - self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries()) - - self.merge_dupe_button = QPushButton() - self.merge_dupe_button.setText("&Merge Duplicate Entries") - self.merge_class.done.connect(lambda: self.set_dupe_count(-1)) - self.merge_class.done.connect(lambda: self.set_missing_count(-1)) - self.merge_class.done.connect(lambda: self.driver.filter_items()) - self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries()) + self.search_button.clicked.connect(self.relink_class.repair_entries) self.manual_button = QPushButton() self.manual_button.setText("&Manual Relink") + self.manual_button.setHidden(True) self.delete_button = QPushButton() - self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver) + self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker) self.delete_modal.done.connect( - lambda: self.set_missing_count(len(self.lib.missing_files)) + lambda: ( + self.set_missing_count(self.tracker.missing_files_count), + # refresh the grid + self.driver.filter_items(), + ) ) - self.delete_modal.done.connect(lambda: self.driver.update_thumbs()) self.delete_button.setText("De&lete Unlinked Entries") - self.delete_button.clicked.connect(lambda: self.delete_modal.show()) + self.delete_button.clicked.connect(self.delete_modal.show) self.button_container = QWidget() self.button_layout = QHBoxLayout(self.button_container) @@ -122,83 +105,35 @@ class FixUnlinkedEntriesModal(QWidget): self.root_layout.addWidget(self.unlinked_desc_widget) self.root_layout.addWidget(self.refresh_unlinked_button) self.root_layout.addWidget(self.search_button) - self.manual_button.setHidden(True) self.root_layout.addWidget(self.manual_button) self.root_layout.addWidget(self.delete_button) self.root_layout.addStretch(1) - self.root_layout.addWidget(self.dupe_count_label) - self.root_layout.addWidget(self.dupe_desc_widget) - self.root_layout.addWidget(self.refresh_dupe_button) - self.root_layout.addWidget(self.merge_dupe_button) self.root_layout.addStretch(2) self.root_layout.addWidget(self.button_container) self.set_missing_count(self.missing_count) - self.set_dupe_count(self.dupe_count) def refresh_missing_files(self): - iterator = FunctionIterator(self.lib.refresh_missing_files) pw = ProgressWidget( window_title="Scanning Library", label_text="Scanning Library for Unlinked Entries...", cancel_button_text=None, minimum=0, - maximum=len(self.lib.entries), + maximum=self.lib.entries_count, ) + pw.show() + + iterator = FunctionIterator(self.tracker.refresh_missing_files) iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(lambda: iterator.run()) + r = CustomRunnable(iterator.run) QThreadPool.globalInstance().start(r) r.done.connect( lambda: ( pw.hide(), pw.deleteLater(), - self.set_missing_count(len(self.lib.missing_files)), + self.set_missing_count(self.tracker.missing_files_count), self.delete_modal.refresh_list(), - self.refresh_dupe_entries(), - ) - ) - - def refresh_dupe_entries(self): - iterator = FunctionIterator(self.lib.refresh_dupe_entries) - pw = ProgressWidget( - window_title="Scanning Library", - label_text="Scanning Library for Duplicate Entries...", - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.entries), - ) - pw.show() - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(lambda: iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.set_dupe_count(len(self.lib.dupe_entries)), - ) - ) - - def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries): - iterator = FunctionIterator(self.lib.refresh_dupe_entries) - pw = ProgressWidget( - window_title="Scanning Library", - label_text="Scanning Library for Duplicate Entries...", - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.entries), - ) - pw.show() - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(lambda: iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), # type: ignore - pw.deleteLater(), # type: ignore - self.set_dupe_count(len(self.lib.dupe_entries)), - merge_class.merge_entries(), ) ) @@ -208,23 +143,8 @@ class FixUnlinkedEntriesModal(QWidget): self.search_button.setDisabled(True) self.delete_button.setDisabled(True) self.missing_count_label.setText("Unlinked Entries: N/A") - elif self.missing_count == 0: - self.search_button.setDisabled(True) - self.delete_button.setDisabled(True) - self.missing_count_label.setText(f"Unlinked Entries: {count}") else: - self.search_button.setDisabled(False) - self.delete_button.setDisabled(False) + # disable buttons if there are no files to fix + self.search_button.setDisabled(self.missing_count == 0) + self.delete_button.setDisabled(self.missing_count == 0) self.missing_count_label.setText(f"Unlinked Entries: {count}") - - def set_dupe_count(self, count: int): - self.dupe_count = count - if self.dupe_count < 0: - self.dupe_count_label.setText("Duplicate Entries: N/A") - self.merge_dupe_button.setDisabled(True) - elif self.dupe_count == 0: - self.dupe_count_label.setText(f"Duplicate Entries: {count}") - self.merge_dupe_button.setDisabled(True) - else: - self.dupe_count_label.setText(f"Duplicate Entries: {count}") - self.merge_dupe_button.setDisabled(False) diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index 2486a76e..ae6dacb3 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -3,10 +3,11 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import math import typing +from dataclasses import dataclass, field +import structlog from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QWidget, @@ -18,142 +19,143 @@ from PySide6.QtWidgets import ( QFrame, ) -from src.core.enums import FieldID -from src.core.library import Library, Tag +from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED +from src.core.library import Tag, Library +from src.core.library.alchemy.fields import _FieldID from src.core.palette import ColorType, get_tag_color from src.qt.flowlayout import FlowLayout -# Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" -logging.basicConfig(format="%(message)s", level=logging.INFO) +@dataclass +class BranchData: + dirs: dict[str, "BranchData"] = field(default_factory=dict) + files: list[str] = field(default_factory=list) + tag: Tag | None = None + + +def add_folders_to_tree( + library: Library, tree: BranchData, items: tuple[str, ...] +) -> BranchData: + branch = tree + for folder in items: + if folder not in branch.dirs: + # TODO - subtags + new_tag = Tag(name=folder) + library.add_tag(new_tag) + branch.dirs[folder] = BranchData(tag=new_tag) + branch.tag = new_tag + branch = branch.dirs[folder] + return branch def folders_to_tags(library: Library): - logging.info("Converting folders to Tags") - tree: dict = dict(dirs={}) + logger.info("Converting folders to Tags") + tree = BranchData() def add_tag_to_tree(items: list[Tag]): branch = tree for tag in items: - if tag.name not in branch["dirs"]: - branch["dirs"][tag.name] = dict(dirs={}, tag=tag) - branch = branch["dirs"][tag.name] - - def add_folders_to_tree(items: list[str]) -> Tag: - branch: dict = tree - for folder in items: - if folder not in branch["dirs"]: - new_tag = Tag( - -1, - folder, - "", - [], - ([branch["tag"].id] if "tag" in branch else []), - "", - ) - library.add_tag_to_library(new_tag) - branch["dirs"][folder] = dict(dirs={}, tag=new_tag) - branch = branch["dirs"][folder] - return branch.get("tag") + if tag.name not in branch.dirs: + branch.dirs[tag.name] = BranchData() + branch = branch.dirs[tag.name] for tag in library.tags: reversed_tag = reverse_tag(library, tag, None) add_tag_to_tree(reversed_tag) - for entry in library.entries: - folders = list(entry.path.parts) - if len(folders) == 1 and folders[0] == "": + for entry in library.get_entries(): + folders = entry.path.parts[1:-1] + if not folders: continue - tag = add_folders_to_tree(folders) - if tag: - if not entry.has_tag(library, tag.id): - entry.add_tag(library, tag.id, FieldID.TAGS) - logging.info("Done") + tag = add_folders_to_tree(library, tree, folders).tag + if tag and not entry.has_tag(tag): + library.add_field_tag(entry, tag, _FieldID.TAGS.name, create_field=True) + + logger.info("Done") -def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]: - if list is not None: - list.append(tag) - else: - list = [tag] +def reverse_tag(library: Library, tag: Tag, items: list[Tag] | None) -> list[Tag]: + items = items or [] + items.append(tag) - if len(tag.subtag_ids) == 0: - list.reverse() - return list - else: - for subtag_id in tag.subtag_ids: - subtag = library.get_tag(subtag_id) - return reverse_tag(library, subtag, list) + if not tag.subtag_ids: + items.reverse() + return items + + for subtag_id in tag.subtag_ids: + subtag = library.get_tag(subtag_id) + return reverse_tag(library, subtag, items) # =========== UI =========== -def generate_preview_data(library: Library): - tree: dict = dict(dirs={}, files=[]) +def generate_preview_data(library: Library) -> BranchData: + tree = BranchData() def add_tag_to_tree(items: list[Tag]): - branch: dict = tree + branch = tree for tag in items: - if tag.name not in branch["dirs"]: - branch["dirs"][tag.name] = dict(dirs={}, tag=tag, files=[]) - branch = branch["dirs"][tag.name] + if tag.name not in branch.dirs: + branch.dirs[tag.name] = BranchData(tag=tag) + branch = branch.dirs[tag.name] - def add_folders_to_tree(items: list[str]) -> dict: - branch: dict = tree + def _add_folders_to_tree(items: typing.Sequence[str]) -> BranchData: + branch = tree for folder in items: - if folder not in branch["dirs"]: - new_tag = Tag(-1, folder, "", [], [], "green") - branch["dirs"][folder] = dict(dirs={}, tag=new_tag, files=[]) - branch = branch["dirs"][folder] + if folder not in branch.dirs: + new_tag = Tag(name=folder) + branch.dirs[folder] = BranchData(tag=new_tag) + branch = branch.dirs[folder] return branch for tag in library.tags: + if tag.id in (TAG_FAVORITE, TAG_ARCHIVED): + continue reversed_tag = reverse_tag(library, tag, None) add_tag_to_tree(reversed_tag) - for entry in library.entries: - folders = list(entry.path.parts) - if len(folders) == 1 and folders[0] == "": + for entry in library.get_entries(): + folders = entry.path.parts[1:-1] + if not folders: continue - branch = add_folders_to_tree(folders) + + branch = _add_folders_to_tree(folders) if branch: - field_indexes = library.get_field_index_in_entry(entry, 6) has_tag = False - for index in field_indexes: - content = library.get_field_attr(entry.fields[index], "content") - for tag_id in content: - tag = library.get_tag(tag_id) - if tag.name == branch["tag"].name: + for tag_field in entry.tag_box_fields: + for tag in tag_field.tags: + if tag.name == branch.tag.name: has_tag = True break if not has_tag: - branch["files"].append(entry.filename) + branch.files.append(entry.path.name) - def cut_branches_adding_nothing(branch: dict): - folders = set(branch["dirs"].keys()) + def cut_branches_adding_nothing(branch: BranchData) -> bool: + folders = list(branch.dirs.keys()) for folder in folders: - cut = cut_branches_adding_nothing(branch["dirs"][folder]) + cut = cut_branches_adding_nothing(branch.dirs[folder]) if cut: - branch["dirs"].pop(folder) + branch.dirs.pop(folder) - if "tag" not in branch: - return - if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first + if not branch.tag: return False - if len(branch["dirs"].keys()) == 0: - return True + + if not branch.tag.id: + return False + + if branch.files: + return False + + return not bool(branch.dirs) cut_branches_adding_nothing(tree) - return tree @@ -166,7 +168,7 @@ class FoldersToTagsModal(QWidget): self.count = -1 self.filename = "" - self.setWindowTitle(f"Create Tags From Folders") + self.setWindowTitle("Create Tags From Folders") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(640, 640) self.root_layout = QVBoxLayout(self) @@ -242,19 +244,19 @@ class FoldersToTagsModal(QWidget): data = generate_preview_data(self.library) - for folder in data["dirs"].values(): - test = TreeItem(folder, None) + for folder in data.dirs.values(): + test = TreeItem(folder) self.scroll_layout.addWidget(test) def set_all_branches(self, hidden: bool): for i in reversed(range(self.scroll_layout.count())): child = self.scroll_layout.itemAt(i).widget() - if type(child) == TreeItem: + if isinstance(child, TreeItem): child.set_all_branches(hidden) class TreeItem(QWidget): - def __init__(self, data: dict, parentTag: Tag): + def __init__(self, data: BranchData, parent_tag: Tag | None = None): super().__init__() self.setStyleSheet("QLabel{font-size: 13px}") @@ -270,7 +272,7 @@ class TreeItem(QWidget): self.label = QLabel() self.tag_layout.addWidget(self.label) - self.tag_widget = ModifiedTagWidget(data["tag"], parentTag) + self.tag_widget = ModifiedTagWidget(data.tag, parent_tag) self.tag_widget.bg_button.clicked.connect(lambda: self.hide_show()) self.tag_layout.addWidget(self.tag_widget) @@ -284,24 +286,24 @@ class TreeItem(QWidget): self.children_widget.setHidden(not self.children_widget.isHidden()) self.label.setText(">" if self.children_widget.isHidden() else "v") - def populate(self, data: dict): - for folder in data["dirs"].values(): - item = TreeItem(folder, data["tag"]) + def populate(self, data: BranchData): + for folder in data.dirs.values(): + item = TreeItem(folder, data.tag) self.children_layout.addWidget(item) - for file in data["files"]: + for file in data.files: label = QLabel() label.setText(" -> " + str(file)) self.children_layout.addWidget(label) - if len(data["files"]) == 0 and len(data["dirs"].values()) == 0: - self.hide_show() - else: + if data.files or data.dirs: self.label.setText("v") + else: + self.hide_show() def set_all_branches(self, hidden: bool): for i in reversed(range(self.children_layout.count())): child = self.children_layout.itemAt(i).widget() - if type(child) == TreeItem: + if isinstance(child, TreeItem): child.set_all_branches(hidden) self.children_widget.setHidden(hidden) @@ -343,7 +345,7 @@ class ModifiedTagWidget( f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};" f"border-radius: 6px;" f"border-style:inset;" - f"border-width: {math.ceil(1*self.devicePixelRatio())}px;" + f"border-width: {math.ceil(self.devicePixelRatio())}px;" f"padding-right: 4px;" f"padding-bottom: 1px;" f"padding-left: 4px;" diff --git a/tagstudio/src/qt/modals/merge_dupe_entries.py b/tagstudio/src/qt/modals/merge_dupe_entries.py index 249e6d11..47051233 100644 --- a/tagstudio/src/qt/modals/merge_dupe_entries.py +++ b/tagstudio/src/qt/modals/merge_dupe_entries.py @@ -7,6 +7,7 @@ import typing from PySide6.QtCore import QObject, Signal, QThreadPool from src.core.library import Library +from src.core.utils.dupe_files import DupeRegistry from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.widgets.progress import ProgressWidget @@ -23,16 +24,17 @@ class MergeDuplicateEntries(QObject): super().__init__() self.lib = library self.driver = driver + self.tracker = DupeRegistry(library=self.lib) def merge_entries(self): - iterator = FunctionIterator(self.lib.merge_dupe_entries) + iterator = FunctionIterator(self.tracker.merge_dupe_entries) pw = ProgressWidget( window_title="Merging Duplicate Entries", label_text="", cancel_button_text=None, minimum=0, - maximum=len(self.lib.dupe_entries), + maximum=self.tracker.groups_count, ) pw.show() @@ -41,6 +43,6 @@ class MergeDuplicateEntries(QObject): lambda: (pw.update_label("Merging Duplicate Entries...")) ) - r = CustomRunnable(lambda: iterator.run()) + r = CustomRunnable(iterator.run) r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) QThreadPool.globalInstance().start(r) diff --git a/tagstudio/src/qt/modals/mirror_entities.py b/tagstudio/src/qt/modals/mirror_entities.py index 09e4bab0..97a11357 100644 --- a/tagstudio/src/qt/modals/mirror_entities.py +++ b/tagstudio/src/qt/modals/mirror_entities.py @@ -17,7 +17,7 @@ from PySide6.QtWidgets import ( QListView, ) -from src.core.library import Library +from src.core.utils.dupe_files import DupeRegistry from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.widgets.progress import ProgressWidget @@ -30,21 +30,22 @@ if typing.TYPE_CHECKING: class MirrorEntriesModal(QWidget): done = Signal() - def __init__(self, library: "Library", driver: "QtDriver"): + def __init__(self, driver: "QtDriver", tracker: DupeRegistry): super().__init__() - self.lib = library self.driver = driver - self.setWindowTitle(f"Mirror Entries") + self.setWindowTitle("Mirror Entries") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(500, 400) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) + self.tracker = tracker self.desc_widget = QLabel() self.desc_widget.setObjectName("descriptionLabel") self.desc_widget.setWordWrap(True) + self.desc_widget.setText(f""" - Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries? + Are you sure you want to mirror the following {self.tracker.groups_count} Entries? """) self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -66,7 +67,7 @@ class MirrorEntriesModal(QWidget): self.mirror_button = QPushButton() self.mirror_button.setText("&Mirror") self.mirror_button.clicked.connect(self.hide) - self.mirror_button.clicked.connect(lambda: self.mirror_entries()) + self.mirror_button.clicked.connect(self.mirror_entries) self.button_layout.addWidget(self.mirror_button) self.root_layout.addWidget(self.desc_widget) @@ -75,45 +76,30 @@ class MirrorEntriesModal(QWidget): def refresh_list(self): self.desc_widget.setText(f""" - Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries? + Are you sure you want to mirror the following {self.tracker.groups_count} Entries? """) self.model.clear() - for i in self.lib.dupe_files: + for i in self.tracker.groups: self.model.appendRow(QStandardItem(str(i))) def mirror_entries(self): - # pb = QProgressDialog('', None, 0, len(self.lib.dupe_files)) - # # pb.setMaximum(len(self.lib.missing_files)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Mirroring Entries') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() - - # r = CustomRunnable(lambda: self.mirror_entries_runnable(pb)) - # r.done.connect(lambda: self.done.emit()) - # r.done.connect(lambda: self.driver.preview_panel.refresh()) - # # r.done.connect(lambda: self.model.clear()) - # # QThreadPool.globalInstance().start(r) - # r.run() - iterator = FunctionIterator(self.mirror_entries_runnable) pw = ProgressWidget( window_title="Mirroring Entries", - label_text=f"Mirroring 1/{len(self.lib.dupe_files)} Entries...", + label_text=f"Mirroring 1/{self.tracker.groups_count} Entries...", cancel_button_text=None, minimum=0, - maximum=len(self.lib.dupe_files), + maximum=self.tracker.groups_count, ) pw.show() iterator.value.connect(lambda x: pw.update_progress(x + 1)) iterator.value.connect( lambda x: pw.update_label( - f"Mirroring {x+1}/{len(self.lib.dupe_files)} Entries..." + f"Mirroring {x + 1}/{self.tracker.groups_count} Entries..." ) ) - r = CustomRunnable(lambda: iterator.run()) + r = CustomRunnable(iterator.run) QThreadPool.globalInstance().start(r) r.done.connect( lambda: ( @@ -126,15 +112,11 @@ class MirrorEntriesModal(QWidget): def mirror_entries_runnable(self): mirrored: list = [] - for i, dupe in enumerate(self.lib.dupe_files): - # pb.setValue(i) - # pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries') - entry_id_1 = self.lib.get_entry_id_from_filepath(dupe[0]) - entry_id_2 = self.lib.get_entry_id_from_filepath(dupe[1]) - self.lib.mirror_entry_fields([entry_id_1, entry_id_2]) + lib = self.driver.lib + for i, entries in enumerate(self.tracker.groups): + lib.mirror_entry_fields(*entries) sleep(0.005) yield i + for d in mirrored: - self.lib.dupe_files.remove(d) - # self.driver.filter_items('') - # self.done.emit() + self.tracker.groups.remove(d) diff --git a/tagstudio/src/qt/modals/relink_unlinked.py b/tagstudio/src/qt/modals/relink_unlinked.py index 15af5cd3..36e8da82 100644 --- a/tagstudio/src/qt/modals/relink_unlinked.py +++ b/tagstudio/src/qt/modals/relink_unlinked.py @@ -2,60 +2,51 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import typing from PySide6.QtCore import QObject, Signal, QThreadPool -from src.core.library import Library +from src.core.utils.missing_files import MissingRegistry from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.widgets.progress import ProgressWidget -# Only import for type checking/autocompletion, will not be imported at runtime. -if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver - class RelinkUnlinkedEntries(QObject): done = Signal() - def __init__(self, library: "Library", driver: "QtDriver"): + def __init__(self, tracker: MissingRegistry): super().__init__() - self.lib = library - self.driver = driver - self.fixed = 0 + self.tracker = tracker def repair_entries(self): - iterator = FunctionIterator(self.lib.fix_missing_files) + iterator = FunctionIterator(self.tracker.fix_missing_files) pw = ProgressWidget( window_title="Relinking Entries", label_text="", cancel_button_text=None, minimum=0, - maximum=len(self.lib.missing_files), + maximum=self.tracker.missing_files_count, ) pw.show() - iterator.value.connect(lambda x: pw.update_progress(x[0] + 1)) iterator.value.connect( - lambda x: ( - self.increment_fixed() if x[1] else (), + lambda idx: ( + pw.update_progress(idx), pw.update_label( - f"Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked" + f"Attempting to Relink {idx}/{self.tracker.missing_files_count} Entries. " + f"{self.tracker.files_fixed_count} Successfully Relinked." ), ) ) - r = CustomRunnable(lambda: iterator.run()) + r = CustomRunnable(iterator.run) r.done.connect( - lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed()) + lambda: ( + pw.hide(), + pw.deleteLater(), + self.done.emit(), + ) ) QThreadPool.globalInstance().start(r) - - def increment_fixed(self): - self.fixed += 1 - - def reset_fixed(self): - self.fixed = 0 diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index 6101b737..9b9f11e4 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -12,7 +12,8 @@ from PySide6.QtWidgets import ( QFrame, ) -from src.core.library import Library +from src.core.library import Library, Tag +from src.core.library.alchemy.enums import FilterState from src.qt.widgets.panel import PanelWidget, PanelModal from src.qt.widgets.tag import TagWidget from src.qt.modals.build_tag import BuildTagPanel @@ -21,7 +22,7 @@ from src.qt.modals.build_tag import BuildTagPanel class TagDatabasePanel(PanelWidget): tag_chosen = Signal(int) - def __init__(self, library): + def __init__(self, library: Library): super().__init__() self.lib: Library = library # self.callback = callback @@ -38,7 +39,7 @@ class TagDatabasePanel(PanelWidget): self.search_field.setMinimumSize(QSize(0, 32)) self.search_field.setPlaceholderText("Search Tags") self.search_field.textEdited.connect( - lambda x=self.search_field.text(): self.update_tags(x) + lambda: self.update_tags(self.search_field.text()) ) self.search_field.returnPressed.connect( lambda checked=False: self.on_return(self.search_field.text()) @@ -73,7 +74,7 @@ class TagDatabasePanel(PanelWidget): self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) - self.update_tags("") + self.update_tags() # def reset(self): # self.search_field.setText('') @@ -84,60 +85,47 @@ class TagDatabasePanel(PanelWidget): if text and self.first_tag_id >= 0: # callback(self.first_tag_id) self.search_field.setText("") - self.update_tags("") + self.update_tags() else: self.search_field.setFocus() self.parentWidget().hide() - def update_tags(self, query: str): + def update_tags(self, query: str | None = None): # TODO: Look at recycling rather than deleting and reinitializing while self.scroll_layout.itemAt(0): self.scroll_layout.takeAt(0).widget().deleteLater() - # If there is a query, get a list of tag_ids that match, otherwise return all - if query: - tags = self.lib.search_tags(query, include_cluster=True)[ - : self.tag_limit - 1 - ] - else: - # Get tag ids to keep this behaviorally identical - tags = [t.id for t in self.lib.tags] + tags = self.lib.search_tags(FilterState(path=query, page_size=self.tag_limit)) - first_id_set = False - for tag_id in tags: - if not first_id_set: - self.first_tag_id = tag_id - first_id_set = True + for tag in tags: container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(3) - tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False) - tw.on_edit.connect( - lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id)) - ) - row.addWidget(tw) + tag_widget = TagWidget(tag, True, False) + tag_widget.on_edit.connect(lambda checked=False, t=tag: self.edit_tag(t)) + row.addWidget(tag_widget) self.scroll_layout.addWidget(container) self.search_field.setFocus() - def edit_tag(self, tag_id: int): - btp = BuildTagPanel(self.lib, tag_id) - # btp.on_edit.connect(lambda x: self.edit_tag_callback(x)) + def edit_tag(self, tag: Tag): + build_tag_panel = BuildTagPanel(self.lib, tag=tag) + self.edit_modal = PanelModal( - btp, - self.lib.get_tag(tag_id).display_name(self.lib), + build_tag_panel, + tag.name, "Edit Tag", done_callback=(self.update_tags(self.search_field.text())), has_save=True, ) # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) # TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead - self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp)) + self.edit_modal.saved.connect(lambda: self.edit_tag_callback(build_tag_panel)) self.edit_modal.show() def edit_tag_callback(self, btp: BuildTagPanel): - self.lib.update_tag(btp.build_tag()) + self.lib.add_tag(btp.build_tag()) self.update_tags(self.search_field.text()) # def enterEvent(self, event: QEnterEvent) -> None: diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index 896f634f..8459f11a 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -3,9 +3,9 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import math +import structlog from PySide6.QtCore import Signal, Qt, QSize from PySide6.QtWidgets import ( QWidget, @@ -18,24 +18,20 @@ from PySide6.QtWidgets import ( ) from src.core.library import Library +from src.core.library.alchemy.enums import FilterState from src.core.palette import ColorType, get_tag_color from src.qt.widgets.panel import PanelWidget from src.qt.widgets.tag import TagWidget - -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" - -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class TagSearchPanel(PanelWidget): tag_chosen = Signal(int) - def __init__(self, library): + def __init__(self, library: Library): super().__init__() - self.lib: Library = library + self.lib = library # self.callback = callback self.first_tag_id = None self.tag_limit = 100 @@ -49,7 +45,7 @@ class TagSearchPanel(PanelWidget): self.search_field.setMinimumSize(QSize(0, 32)) self.search_field.setPlaceholderText("Search Tags") self.search_field.textEdited.connect( - lambda x=self.search_field.text(): self.update_tags(x) + lambda: self.update_tags(self.search_field.text()) ) self.search_field.returnPressed.connect( lambda checked=False: self.on_return(self.search_field.text()) @@ -84,7 +80,7 @@ class TagSearchPanel(PanelWidget): self.root_layout.addWidget(self.search_field) self.root_layout.addWidget(self.scroll_area) - self.update_tags("") + self.update_tags() # def reset(self): # self.search_field.setText('') @@ -101,55 +97,51 @@ class TagSearchPanel(PanelWidget): self.search_field.setFocus() self.parentWidget().hide() - def update_tags(self, query: str = ""): - # for c in self.scroll_layout.children(): - # c.widget().deleteLater() + def update_tags(self, name: str | None = None): while self.scroll_layout.count(): - # logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}") self.scroll_layout.takeAt(0).widget().deleteLater() - found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit] - self.first_tag_id = found_tags[0] if found_tags else None + found_tags = self.lib.search_tags( + FilterState( + path=name, + page_size=self.tag_limit, + ) + ) - for tag_id in found_tags: + for tag in found_tags: c = QWidget() - l = QHBoxLayout(c) - l.setContentsMargins(0, 0, 0, 0) - l.setSpacing(3) - tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False) + layout = QHBoxLayout(c) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + tw = TagWidget(tag, False, False) ab = QPushButton() ab.setMinimumSize(23, 23) ab.setMaximumSize(23, 23) ab.setText("+") ab.setStyleSheet( f"QPushButton{{" - f"background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};" - # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' - # f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};" - f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};" + f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};" + f"color: {get_tag_color(ColorType.TEXT, tag.color)};" f"font-weight: 600;" - f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};" + f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};" f"border-radius: 6px;" f"border-style:solid;" - f"border-width: {math.ceil(1*self.devicePixelRatio())}px;" - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' + f"border-width: {math.ceil(self.devicePixelRatio())}px;" f"padding-bottom: 5px;" - # f'padding-left: 4px;' f"font-size: 20px;" f"}}" f"QPushButton::hover" f"{{" - f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};" - f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};" - f"background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};" + f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};" + f"color: {get_tag_color(ColorType.DARK_ACCENT, tag.color)};" + f"background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};" f"}}" ) - ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x)) + ab.clicked.connect(lambda checked=False, x=tag.id: self.tag_chosen.emit(x)) - l.addWidget(tw) - l.addWidget(ab) + layout.addWidget(tw) + layout.addWidget(ab) self.scroll_layout.addWidget(c) self.search_field.setFocus() diff --git a/tagstudio/src/qt/pagination.py b/tagstudio/src/qt/pagination.py index 904dd19f..174bc845 100644 --- a/tagstudio/src/qt/pagination.py +++ b/tagstudio/src/qt/pagination.py @@ -10,7 +10,6 @@ from PySide6.QtGui import QIntValidator from PySide6.QtWidgets import ( QWidget, QHBoxLayout, - QPushButton, QLabel, QLineEdit, QSizePolicy, @@ -274,7 +273,7 @@ class Pagination(QWidget, QObject): if self.end_buffer_layout.itemAt(i): self.end_buffer_layout.itemAt(i).widget().setHidden(True) sbc += 1 - self.current_page_field.setText((str(i + 1))) + self.current_page_field.setText(str(i + 1)) # elif index == page_count-1: # self.start_button.setText(str(page_count)) @@ -419,7 +418,6 @@ class Pagination(QWidget, QObject): self.validator.setTop(page_count) # if self.current_page_index != index: if emit: - print(f"[PAGINATION] Emitting {index}") self.index.emit(index) self.current_page_index = index self.page_count = page_count @@ -435,7 +433,7 @@ class Pagination(QWidget, QObject): button.is_connected = True def _populate_buffer_buttons(self): - for i in range(max(self.buffer_page_count * 2, 5)): + for _ in range(max(self.buffer_page_count * 2, 5)): button = QPushButtonWrapper() button.setMinimumSize(self.button_size) button.setMaximumSize(self.button_size) @@ -443,13 +441,12 @@ class Pagination(QWidget, QObject): # button.setMaximumHeight(self.button_size.height()) self.start_buffer_layout.addWidget(button) - for i in range(max(self.buffer_page_count * 2, 5)): - button = QPushButtonWrapper() - button.setMinimumSize(self.button_size) - button.setMaximumSize(self.button_size) - button.setHidden(True) + end_button = QPushButtonWrapper() + end_button.setMinimumSize(self.button_size) + end_button.setMaximumSize(self.button_size) + end_button.setHidden(True) # button.setMaximumHeight(self.button_size.height()) - self.end_buffer_layout.addWidget(button) + self.end_buffer_layout.addWidget(end_button) class Validator(QIntValidator): diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index 0db8bb19..137363bb 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -2,13 +2,13 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging from pathlib import Path from typing import Any +import structlog import ujson -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class ResourceManager: @@ -21,12 +21,10 @@ class ResourceManager: def __init__(self) -> None: # Load JSON resource map if not ResourceManager._initialized: - with open( - Path(__file__).parent / "resources.json", mode="r", encoding="utf-8" - ) as f: + with open(Path(__file__).parent / "resources.json", encoding="utf-8") as f: ResourceManager._map = ujson.load(f) - logging.info( - f"[ResourceManager] {len(ResourceManager._map.items())} resources registered" + logger.info( + "resources registered", count=len(ResourceManager._map.items()) ) ResourceManager._initialized = True diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index be740368..eb041dd4 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -8,18 +8,18 @@ """A Qt driver for TagStudio.""" import ctypes -import logging +import dataclasses import math import os import sys import time -import typing import webbrowser -from datetime import datetime as dt +from collections.abc import Sequence +from itertools import zip_longest from pathlib import Path from queue import Queue -from typing import Optional -from PIL import Image + +import structlog from PySide6 import QtCore from PySide6.QtCore import ( QObject, @@ -43,7 +43,6 @@ from PySide6.QtUiTools import QUiLoader from PySide6.QtWidgets import ( QApplication, QWidget, - QHBoxLayout, QPushButton, QLineEdit, QScrollArea, @@ -55,30 +54,36 @@ from PySide6.QtWidgets import ( ) from humanfriendly import format_timespan -from src.core.enums import SettingItems, SearchMode -from src.core.library import ItemType -from src.core.ts_core import TagStudioCore +from src.core.enums import SettingItems, MacroID + from src.core.constants import ( - COLLAGE_FOLDER_NAME, - BACKUP_FOLDER_NAME, TS_FOLDER_NAME, VERSION_BRANCH, VERSION, - TAG_FAVORITE, + LibraryPrefs, TAG_ARCHIVED, + TAG_FAVORITE, ) +from src.core.library.alchemy.enums import ( + SearchMode, + FilterState, + ItemType, + FieldTypeEnum, +) +from src.core.library.alchemy.fields import _FieldID +from src.core.ts_core import TagStudioCore +from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.resource_manager import ResourceManager -from src.qt.widgets.collage_icon import CollageIconRenderer from src.qt.widgets.panel import PanelModal from src.qt.widgets.thumb_renderer import ThumbRenderer from src.qt.widgets.progress import ProgressWidget from src.qt.widgets.preview_panel import PreviewPanel -from src.qt.widgets.item_thumb import ItemThumb +from src.qt.widgets.item_thumb import ItemThumb, BadgeType from src.qt.modals.build_tag import BuildTagPanel from src.qt.modals.tag_database import TagDatabasePanel from src.qt.modals.file_extension import FileExtensionModal @@ -87,7 +92,7 @@ from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.folders_to_tags import FoldersToTagsModal # this import has side-effect of import PySide resources -import src.qt.resources_rc # pylint: disable=unused-import +import src.qt.resources_rc # noqa: F401 # SIGQUIT is not defined on Windows if sys.platform == "win32": @@ -97,33 +102,7 @@ if sys.platform == "win32": else: from signal import signal, SIGINT, SIGTERM, SIGQUIT -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" - -logging.basicConfig(format="%(message)s", level=logging.INFO) - - -class NavigationState: - """Represents a state of the Library grid view.""" - - def __init__( - self, - contents, - scrollbar_pos: int, - page_index: int, - page_count: int, - search_text: str | None = None, - thumb_size=None, - spacing=None, - ) -> None: - self.contents = contents - self.scrollbar_pos = scrollbar_pos - self.page_index = page_index - self.page_count = page_count - self.search_text = search_text - self.thumb_size = thumb_size - self.spacing = spacing +logger = structlog.get_logger(__name__) class Consumer(QThread): @@ -143,18 +122,6 @@ class Consumer(QThread): except RuntimeError: pass - def set_page_count(self, count: int): - self.page_count = count - - def jump_to_page(self, index: int): - pass - - def nav_back(self): - pass - - def nav_forward(self): - pass - class QtDriver(QObject): """A Qt GUI frontend driver for TagStudio.""" @@ -163,20 +130,20 @@ class QtDriver(QObject): preview_panel: PreviewPanel - def __init__(self, core: TagStudioCore, args): + def __init__(self, backend, args): super().__init__() - self.core: TagStudioCore = core - self.lib = self.core.lib + # prevent recursive badges update when multiple items selected + self.badge_update_lock = False + self.lib = backend.Library() self.rm: ResourceManager = ResourceManager() self.args = args - self.frame_dict: dict = {} - self.nav_frames: list[NavigationState] = [] - self.cur_frame_idx: int = -1 + self.frame_content = [] + self.filter = FilterState() + self.pages_count = 0 - self.search_mode = SearchMode.AND - - # self.main_window = None - # self.main_window = Ui_MainWindow() + self.scrollbar_pos = 0 + self.thumb_size = 128 + self.spacing = None self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}" @@ -185,18 +152,17 @@ class QtDriver(QObject): self.thumb_job_queue: Queue = Queue() self.thumb_threads: list[Consumer] = [] self.thumb_cutoff: float = time.time() - # self.selected: list[tuple[int,int]] = [] # (Thumb Index, Page Index) - self.selected: list[tuple[ItemType, int]] = [] # (Item Type, Item ID) + + # grid indexes of selected items + self.selected: list[int] = [] self.SIGTERM.connect(self.handleSIGTERM) if self.args.config_file: path = Path(self.args.config_file) if not path.exists(): - logging.warning( - f"[QT DRIVER] Config File does not exist creating {str(path)}" - ) - logging.info(f"[QT DRIVER] Using Config File {str(path)}") + logger.warning("Config File does not exist creating", path=path) + logger.info("Using Config File", path=path) self.settings = QSettings(str(path), QSettings.Format.IniFormat) else: self.settings = QSettings( @@ -205,14 +171,12 @@ class QtDriver(QObject): "TagStudio", "TagStudio", ) - logging.info( - f"[QT DRIVER] Config File not specified, defaulting to {self.settings.fileName()}" + logger.info( + "Config File not specified, using default one", + filename=self.settings.fileName(), ) max_threads = os.cpu_count() - if args.ci: - # spawn only single worker in CI environment - max_threads = 1 for i in range(max_threads): # thread = threading.Thread(target=self.consumer, name=f'ThumbRenderer_{i}',args=(), daemon=True) # thread.start() @@ -223,7 +187,10 @@ class QtDriver(QObject): def open_library_from_dialog(self): dir = QFileDialog.getExistingDirectory( - None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly + None, + "Open/Create Library", + "/", + QFileDialog.Option.ShowDirsOnly, ) if dir not in (None, ""): self.open_library(Path(dir)) @@ -238,11 +205,12 @@ class QtDriver(QObject): signal(SIGQUIT, self.signal_handler) def start(self) -> None: - """Launches the main Qt window.""" + """Launch the main Qt window.""" - loader = QUiLoader() + _ = QUiLoader() if os.name == "nt": sys.argv += ["-platform", "windows:darkmode=2"] + app = QApplication(sys.argv) app.setStyle("Fusion") # pal: QPalette = app.palette() @@ -251,7 +219,7 @@ class QtDriver(QObject): # pal.setColor(QPalette.ColorGroup.Normal, # QPalette.ColorRole.Window, QColor('#110F1B')) # app.setPalette(pal) - home_path = Path(__file__).parent / "ui/home.ui" + # home_path = Path(__file__).parent / "ui/home.ui" icon_path = Path(__file__).parents[2] / "resources/icon.png" # Handle OS signals @@ -280,7 +248,7 @@ class QtDriver(QObject): splash_pixmap = QPixmap(":/images/splash.png") splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) - self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore + self.splash = QSplashScreen(splash_pixmap, Qt.WindowType.WindowStaysOnTopHint) # self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.splash.show() @@ -319,19 +287,6 @@ class QtDriver(QObject): open_library_action.setToolTip("Ctrl+O") file_menu.addAction(open_library_action) - save_library_action = QAction("&Save Library", menu_bar) - save_library_action.triggered.connect( - lambda: self.callback_library_needed_check(self.save_library) - ) - save_library_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_S, - ) - ) - save_library_action.setStatusTip("Ctrl+S") - file_menu.addAction(save_library_action) - save_library_backup_action = QAction("&Save Library Backup", menu_bar) save_library_backup_action.triggered.connect( lambda: self.callback_library_needed_check(self.backup_library) @@ -365,7 +320,6 @@ class QtDriver(QObject): add_new_files_action.setStatusTip("Ctrl+R") # file_menu.addAction(refresh_lib_action) file_menu.addAction(add_new_files_action) - file_menu.addSeparator() close_library_action = QAction("&Close Library", menu_bar) @@ -406,9 +360,7 @@ class QtDriver(QObject): edit_menu.addSeparator() manage_file_extensions_action = QAction("Manage File Extensions", menu_bar) - manage_file_extensions_action.triggered.connect( - lambda: self.show_file_extension_modal() - ) + manage_file_extensions_action.triggered.connect(self.show_file_extension_modal) edit_menu.addAction(manage_file_extensions_action) tag_database_action = QAction("Manage Tags", menu_bar) @@ -418,7 +370,7 @@ class QtDriver(QObject): check_action = QAction("Open library on start", self) check_action.setCheckable(True) check_action.setChecked( - self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool) # type: ignore + bool(self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool)) ) check_action.triggered.connect( lambda checked: self.settings.setValue( @@ -428,67 +380,58 @@ class QtDriver(QObject): window_menu.addAction(check_action) # Tools Menu =========================================================== + def create_fix_unlinked_entries_modal(): + if not hasattr(self, "unlinked_modal"): + self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self) + self.unlinked_modal.show() + fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar) - fue_modal = FixUnlinkedEntriesModal(self.lib, self) - fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show()) + fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal) tools_menu.addAction(fix_unlinked_entries_action) + def create_dupe_files_modal(): + if not hasattr(self, "dupe_modal"): + self.dupe_modal = FixDupeFilesModal(self.lib, self) + self.dupe_modal.show() + fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar) - fdf_modal = FixDupeFilesModal(self.lib, self) - fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show()) + fix_dupe_files_action.triggered.connect(create_dupe_files_modal) tools_menu.addAction(fix_dupe_files_action) - create_collage_action = QAction("Create Collage", menu_bar) - create_collage_action.triggered.connect(lambda: self.create_collage()) - tools_menu.addAction(create_collage_action) + # create_collage_action = QAction("Create Collage", menu_bar) + # create_collage_action.triggered.connect(lambda: self.create_collage()) + # tools_menu.addAction(create_collage_action) # Macros Menu ========================================================== self.autofill_action = QAction("Autofill", menu_bar) self.autofill_action.triggered.connect( lambda: ( - self.run_macros( - "autofill", [x[1] for x in self.selected if x[0] == ItemType.ENTRY] - ), + self.run_macros(MacroID.AUTOFILL, self.selected), self.preview_panel.update_widgets(), ) ) macros_menu.addAction(self.autofill_action) - self.sort_fields_action = QAction("&Sort Fields", menu_bar) - self.sort_fields_action.triggered.connect( - lambda: ( - self.run_macros( - "sort-fields", - [x[1] for x in self.selected if x[0] == ItemType.ENTRY], - ), - self.preview_panel.update_widgets(), - ) - ) - self.sort_fields_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.AltModifier), - QtCore.Qt.Key.Key_S, - ) - ) - self.sort_fields_action.setToolTip("Alt+S") - macros_menu.addAction(self.sort_fields_action) - show_libs_list_action = QAction("Show Recent Libraries", menu_bar) show_libs_list_action.setCheckable(True) show_libs_list_action.setChecked( - self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool) # type: ignore + bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool)) ) show_libs_list_action.triggered.connect( lambda checked: ( - self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), # type: ignore + self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), self.toggle_libs_list(checked), ) ) window_menu.addAction(show_libs_list_action) + def create_folders_tags_modal(): + if not hasattr(self, "folders_modal"): + self.folders_modal = FoldersToTagsModal(self.lib, self) + self.folders_modal.show() + folders_to_tags_action = QAction("Folders to Tags", menu_bar) - ftt_modal = FoldersToTagsModal(self.lib, self) - folders_to_tags_action.triggered.connect(lambda: ftt_modal.show()) + folders_to_tags_action.triggered.connect(create_folders_tags_modal) macros_menu.addAction(folders_to_tags_action) # Help Menu ========================================================== @@ -507,30 +450,29 @@ class QtDriver(QObject): menu_bar.addMenu(help_menu) self.preview_panel = PreviewPanel(self.lib, self) - l: QHBoxLayout = self.main_window.splitter - l.addWidget(self.preview_panel) + splitter = self.main_window.splitter + splitter.addWidget(self.preview_panel) QFontDatabase.addApplicationFont( str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf") ) self.thumb_size = 128 - self.max_results = 500 self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] - self.collation_thumb_size = math.ceil(self.thumb_size * 2) + self.filter = FilterState() self.init_library_window() - lib = None + lib: str | None = None if self.args.open: lib = self.args.open elif self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool): - lib = self.settings.value(SettingItems.LAST_LIBRARY) + lib = str(self.settings.value(SettingItems.LAST_LIBRARY)) # TODO: Remove this check if the library is no longer saved with files if lib and not (Path(lib) / TS_FOLDER_NAME).exists(): - logging.error( + logger.error( f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist." ) self.settings.setValue(SettingItems.LAST_LIBRARY, "") @@ -542,11 +484,7 @@ class QtDriver(QObject): int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), QColor("#9782ff"), ) - self.open_library(Path(lib)) - - if self.args.ci: - # gracefully terminate the app in CI environment - self.thumb_job_queue.put((self.SIGTERM.emit, [])) + self.open_library(lib) app.exec() @@ -560,15 +498,19 @@ class QtDriver(QObject): # so the resource isn't being used, then store the specific size variations # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) - # self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size) search_button: QPushButton = self.main_window.searchButton search_button.clicked.connect( - lambda: self.filter_items(self.main_window.searchField.text()) + lambda: self.filter_items( + FilterState(query=self.main_window.searchField.text()) + ) ) search_field: QLineEdit = self.main_window.searchField search_field.returnPressed.connect( - lambda: self.filter_items(self.main_window.searchField.text()) + # TODO - parse search field for filters + lambda: self.filter_items( + FilterState(query=self.main_window.searchField.text()) + ) ) search_type_selector: QComboBox = self.main_window.comboBox_2 search_type_selector.currentIndexChanged.connect( @@ -578,9 +520,9 @@ class QtDriver(QObject): ) back_button: QPushButton = self.main_window.backButton - back_button.clicked.connect(self.nav_back) + back_button.clicked.connect(lambda: self.page_move(-1)) forward_button: QPushButton = self.main_window.forwardButton - forward_button.clicked.connect(self.nav_forward) + forward_button.clicked.connect(lambda: self.page_move(1)) # NOTE: Putting this early will result in a white non-responsive # window until everything is loaded. Consider adding a splash screen @@ -589,28 +531,8 @@ class QtDriver(QObject): self.main_window.activateWindow() self.main_window.toggle_landing_page(True) - self.frame_dict = {} - self.main_window.pagination.index.connect( - lambda i: ( - self.nav_forward( - *self.get_frame_contents( - i, self.nav_frames[self.cur_frame_idx].search_text - ) - ), - logging.info(f"emitted {i}"), - ) - ) + self.main_window.pagination.index.connect(lambda i: self.page_move(page_id=i)) - self.nav_frames = [] - self.cur_frame_idx = -1 - self.cur_query = "" - self.filter_items() - # self.update_thumbs() - - # self.render_times: list = [] - # self.main_window.setWindowFlag(Qt.FramelessWindowHint) - - # self.main_window.raise_() self.splash.finish(self.main_window) self.preview_panel.update_widgets() @@ -631,11 +553,10 @@ class QtDriver(QObject): def shutdown(self): """Save Library on Application Exit""" - if self.lib.library_dir: - self.save_library() + if self.lib and self.lib.library_dir: self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) self.settings.sync() - logging.info("[SHUTDOWN] Ending Thumbnail Threads...") + logger.info("[SHUTDOWN] Ending Thumbnail Threads...") for _ in self.thumb_threads: self.thumb_job_queue.put(Consumer.MARKER_QUIT) @@ -646,89 +567,66 @@ class QtDriver(QObject): QApplication.quit() - def save_library(self, show_status=True): - logging.info(f"Saving Library...") - if show_status: - self.main_window.statusbar.showMessage(f"Saving Library...") - start_time = time.time() - # This might still be able to error, if the selected directory deletes in a race condition - # or something silly like that. Hence the loop, but if this is considered overkill, thats fair. - while True: - try: - self.lib.save_library_to_disk() - break - # If the parent directory got moved, or deleted, prompt user for where to save. - except FileNotFoundError: - logging.info( - "Library parent directory not found, prompting user to select the directory" - ) - dir = QFileDialog.getExistingDirectory( - None, - "Library Location not found, please select location to save Library", - "/", - QFileDialog.ShowDirsOnly, - ) - if dir not in (None, ""): - self.lib.library_dir = dir - if show_status: - end_time = time.time() - self.main_window.statusbar.showMessage( - f"Library Saved! ({format_timespan(end_time - start_time)})" - ) - def close_library(self): - if self.lib.library_dir: - logging.info(f"Closing Library...") - self.main_window.statusbar.showMessage(f"Closing & Saving Library...") - start_time = time.time() - self.save_library(show_status=False) - self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) - self.settings.sync() + if not self.lib.library_dir: + logger.info("No Library to Close") + return - self.lib.clear_internal_vars() - title_text = f"{self.base_title}" - self.main_window.setWindowTitle(title_text) - - self.nav_frames = [] - self.cur_frame_idx = -1 - self.cur_query = "" - self.selected.clear() - self.preview_panel.update_widgets() - self.filter_items() - self.main_window.toggle_landing_page(True) - - end_time = time.time() - self.main_window.statusbar.showMessage( - f"Library Saved and Closed! ({format_timespan(end_time - start_time)})" - ) - - def backup_library(self): - logging.info(f"Backing Up Library...") - self.main_window.statusbar.showMessage(f"Saving Library...") + logger.info("Closing Library...") + self.main_window.statusbar.showMessage("Closing Library...") start_time = time.time() - fn = self.lib.save_library_backup_to_disk() + self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) + self.settings.sync() + + title_text = f"{self.base_title}" + self.main_window.setWindowTitle(title_text) + + self.selected = [] + self.frame_content = [] + self.item_thumbs = [] + + self.preview_panel.update_widgets() + self.main_window.toggle_landing_page(True) + + self.main_window.pagination.setHidden(True) + end_time = time.time() self.main_window.statusbar.showMessage( - f'Library Backup Saved at: "{ self.lib.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / fn}" ({format_timespan(end_time - start_time)})' + f"Library Closed ({format_timespan(end_time - start_time)})" + ) + + def backup_library(self): + logger.info("Backing Up Library...") + self.main_window.statusbar.showMessage("Saving Library...") + start_time = time.time() + target_path = self.lib.save_library_backup_to_disk() + end_time = time.time() + self.main_window.statusbar.showMessage( + f'Library Backup Saved at: "{target_path}" ({format_timespan(end_time - start_time)})' ) def add_tag_action_callback(self): self.modal = PanelModal( - BuildTagPanel(self.lib), "New Tag", "Add Tag", has_save=True + BuildTagPanel(self.lib), + "New Tag", + "Add Tag", + has_save=True, ) - # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) + panel: BuildTagPanel = self.modal.widget self.modal.saved.connect( - lambda: (self.lib.add_tag_to_library(panel.build_tag()), self.modal.hide()) + lambda: ( + self.lib.add_tag(panel.build_tag(), panel.subtags), + self.modal.hide(), + ) ) - # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) self.modal.show() def select_all_action_callback(self): - for item in self.item_thumbs: - if item.mode and (item.mode, item.item_id) not in self.selected: - self.selected.append((item.mode, item.item_id)) - item.thumb_button.set_selected(True) + self.selected = list(range(0, len(self.frame_content))) + + for grid_idx in self.selected: + self.item_thumbs[grid_idx].thumb_button.set_selected(True) self.set_macro_menu_viability() self.preview_panel.update_widgets() @@ -755,54 +653,15 @@ class QtDriver(QObject): "File Extensions", has_save=True, ) - self.modal.saved.connect(lambda: (panel.save(), self.filter_items(""))) + + self.modal.saved.connect(lambda: (panel.save(), self.filter_items())) self.modal.show() def add_new_files_callback(self): - """Runs when user initiates adding new files to the Library.""" - # # if self.lib.files_not_in_library: - # # mb = QMessageBox() - # # mb.setText(f'Would you like to refresh the directory before adding {len(self.lib.files_not_in_library)} new files to the library?\nThis will add any additional files that have been moved to the directory since the last refresh.') - # # mb.setWindowTitle('Refresh Library') - # # mb.setIcon(QMessageBox.Icon.Information) - # # mb.setStandardButtons(QMessageBox.StandardButton.No) - # # refresh_button = mb.addButton('Refresh', QMessageBox.ButtonRole.AcceptRole) - # # mb.setDefaultButton(refresh_button) - # # result = mb.exec_() - # # # logging.info(result) - # # if result == 0: - # # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) - # # self.lib.refresh_dir() - # # else: - # pb = QProgressDialog('Scanning Directories for New Files...\nPreparing...', None, 0,0) + """Run when user initiates adding new files to the Library.""" - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # # pb.setLabelText('Scanning Directories...') - # pb.setWindowTitle('Scanning Directories') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # # pb.setMinimum(0) - # # pb.setMaximum(0) - # # pb.setValue(0) - # pb.show() - # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) - # # self.lib.refresh_dir() - # r = CustomRunnable(lambda: self.runnable(pb)) - # logging.info(f'Main: {QThread.currentThread()}') - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.add_new_files_runnable())) - # QThreadPool.globalInstance().start(r) - # # r.run() + tracker = RefreshDirTracker(self.lib) - # # new_ids: list[int] = self.lib.add_new_files_as_entries() - # # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') - # # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) - # # # for id in new_ids: - # # # self.run_macro('autofill', id) - - # # self.main_window.statusbar.showMessage('', 3) - # # self.filter_entries('') - - iterator = FunctionIterator(self.lib.refresh_dir) pw = ProgressWidget( window_title="Refreshing Directories", label_text="Scanning Directories for New Files...\nPreparing...", @@ -811,31 +670,31 @@ class QtDriver(QObject): maximum=0, ) pw.show() - iterator.value.connect(lambda x: pw.update_progress(x + 1)) + + iterator = FunctionIterator(tracker.refresh_dir) iterator.value.connect( - lambda x: pw.update_label( - f'Scanning Directories for New Files...\n{x + 1} File{"s" if x + 1 != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found' + lambda x: ( + pw.update_progress(x + 1), + pw.update_label( + f'Scanning Directories for New Files...\n{x + 1} File{"s" if x + 1 != 1 else ""} Searched, {tracker.files_count} New Files Found' + ), ) ) - r = CustomRunnable(lambda: iterator.run()) - # r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(''))) - # vvv This one runs the macros when adding new files to the library. + r = CustomRunnable(iterator.run) r.done.connect( - lambda: (pw.hide(), pw.deleteLater(), self.add_new_files_runnable()) + lambda: ( + pw.hide(), + pw.deleteLater(), + self.add_new_files_runnable(tracker), + ) ) QThreadPool.globalInstance().start(r) - # def runnable(self, pb:QProgressDialog): - # for i in self.lib.refresh_dir(): - # pb.setLabelText(f'Scanning Directories for New Files...\n{i} File{"s" if i != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found') - - def add_new_files_runnable(self): + def add_new_files_runnable(self, tracker: RefreshDirTracker): """ Threaded method that adds any known new files to the library and initiates running default macros on them. """ - # logging.info(f'Start ANF: {QThread.currentThread()}') - new_ids: list[int] = self.lib.add_new_files_as_entries() # pb = QProgressDialog(f'Running Configured Macros on 1/{len(new_ids)} New Entries', None, 0,len(new_ids)) # pb.setFixedSize(432, 112) # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) @@ -848,34 +707,44 @@ class QtDriver(QObject): # r.run() # # QThreadPool.globalInstance().start(r) - # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) # # pb.hide() - iterator = FunctionIterator(lambda: self.new_file_macros_runnable(new_ids)) + files_count = tracker.files_count + + # iterator = FunctionIterator(lambda: self.new_file_macros_runnable(tracker.files_not_in_library)) + iterator = FunctionIterator(tracker.save_new_files) pw = ProgressWidget( window_title="Running Macros on New Entries", - label_text=f"Running Configured Macros on 1/{len(new_ids)} New Entries", + label_text=f"Running Configured Macros on 1/{files_count} New Entries", cancel_button_text=None, minimum=0, - maximum=0, + maximum=files_count, ) pw.show() - iterator.value.connect(lambda x: pw.update_progress(x + 1)) iterator.value.connect( - lambda x: pw.update_label( - f"Running Configured Macros on {x + 1}/{len(new_ids)} New Entries" + lambda x: ( + pw.update_progress(x + 1), + pw.update_label( + f"Running Configured Macros on {x + 1}/{files_count} New Entries" + ), + ) + ) + r = CustomRunnable(iterator.run) + r.done.connect( + lambda: ( + pw.hide(), + pw.deleteLater(), + # refresh the library only when new items are added + files_count and self.filter_items(), ) ) - r = CustomRunnable(lambda: iterator.run()) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(""))) QThreadPool.globalInstance().start(r) def new_file_macros_runnable(self, new_ids): """Threaded method that runs macros on a set of Entry IDs.""" # sleep(1) - # logging.info(f'ANFR: {QThread.currentThread()}') # for i, id in enumerate(new_ids): # # pb.setValue(i) # # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries') @@ -890,75 +759,66 @@ class QtDriver(QObject): # sleep(5) # pb.deleteLater() - def run_macros(self, name: str, entry_ids: list[int]): - """Runs a specific Macro on a group of given entry_ids.""" - for id in entry_ids: - self.run_macro(name, id) + def run_macros(self, name: MacroID, grid_idx: list[int]): + """Run a specific Macro on a group of given entry_ids.""" + for gid in grid_idx: + self.run_macro(name, gid) - def run_macro(self, name: str, entry_id: int): - """Runs a specific Macro on an Entry given a Macro name.""" - entry = self.lib.get_entry(entry_id) - path = self.lib.library_dir / entry.path / entry.filename + def run_macro(self, name: MacroID, grid_idx: int): + """Run a specific Macro on an Entry given a Macro name.""" + entry = self.frame_content[grid_idx] + ful_path = self.lib.library_dir / entry.path source = entry.path.parts[0] - if name == "sidecar": - self.lib.add_generic_data_to_entry( - self.core.get_gdl_sidecar(path, source), entry_id - ) - elif name == "autofill": - self.run_macro("sidecar", entry_id) - self.run_macro("build-url", entry_id) - self.run_macro("match", entry_id) - self.run_macro("clean-url", entry_id) - self.run_macro("sort-fields", entry_id) - elif name == "build-url": - data = {"source": self.core.build_url(entry_id, source)} - self.lib.add_generic_data_to_entry(data, entry_id) - elif name == "sort-fields": - order: list[int] = ( - [0] - + [1, 2] - + [9, 17, 18, 19, 20] - + [8, 7, 6] - + [4] - + [3, 21] - + [10, 14, 11, 12, 13, 22] - + [5] - ) - self.lib.sort_fields(entry_id, order) - elif name == "match": - self.core.match_conditions(entry_id) - # elif name == 'scrape': - # self.core.scrape(entry_id) - elif name == "clean-url": - # entry = self.lib.get_entry_from_index(entry_id) - if entry.fields: - for i, field in enumerate(entry.fields, start=0): - if self.lib.get_field_attr(field, "type") == "text_line": - self.lib.update_entry_field( - entry_id=entry_id, - field_index=i, - content=strip_web_protocol( - self.lib.get_field_attr(field, "content") - ), - mode="replace", - ) + + logger.info( + "running macro", + source=source, + macro=name, + entry_id=entry.id, + grid_idx=grid_idx, + ) + + if name == MacroID.AUTOFILL: + for macro_id in MacroID: + if macro_id == MacroID.AUTOFILL: + continue + self.run_macro(macro_id, entry.id) + + elif name == MacroID.SIDECAR: + parsed_items = TagStudioCore.get_gdl_sidecar(ful_path, source) + for field_id, value in parsed_items.items(): + self.lib.add_entry_field_type( + entry.id, + field_id=field_id, + value=value, + ) + + elif name == MacroID.BUILD_URL: + url = TagStudioCore.build_url(entry.id, source) + self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url) + elif name == MacroID.MATCH: + TagStudioCore.match_conditions(self.lib, entry.id) + elif name == MacroID.CLEAN_URL: + for field in entry.text_fields: + if field.type.type == FieldTypeEnum.TEXT_LINE and field.value: + self.lib.update_entry_field( + entry_ids=entry.id, + content=strip_web_protocol(field.value), + ) def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: - self.nav_forward() + self.page_move(1) elif event.button() == Qt.MouseButton.BackButton: - self.nav_back() + self.page_move(-1) - def nav_forward( - self, - frame_content: Optional[list[tuple[ItemType, int]]] = None, - page_index: int = 0, - page_count: int = 0, - ): - """Navigates a step further into the navigation stack.""" - logging.info( - f"Calling NavForward with Content:{False if not frame_content else frame_content[0]}, Index:{page_index}, PageCount:{page_count}" + def page_move(self, delta: int = None, page_id: int = None) -> None: + """Navigate a step further into the navigation stack.""" + logger.info( + "page_move", + delta=delta, + page_id=page_id, ) # Ex. User visits | A ->[B] | @@ -967,135 +827,21 @@ class QtDriver(QObject): # |[A]<- B C | Previous routes still exist # | A ->[D] | Stack is cut from [:A] on new route - # Moving forward (w/ or wo/ new content) in the middle of the stack - original_pos = self.cur_frame_idx - sb: QScrollArea = self.main_window.scrollArea - sb_pos = sb.verticalScrollBar().value() - search_text = self.main_window.searchField.text() + # sb: QScrollArea = self.main_window.scrollArea + # sb_pos = sb.verticalScrollBar().value() - trimmed = False - if len(self.nav_frames) > self.cur_frame_idx + 1: - if frame_content is not None: - # Trim the nav stack if user is taking a new route. - self.nav_frames = self.nav_frames[: self.cur_frame_idx + 1] - if self.nav_frames and not self.nav_frames[self.cur_frame_idx].contents: - self.nav_frames.pop() - trimmed = True - self.nav_frames.append( - NavigationState( - frame_content, 0, page_index, page_count, search_text - ) - ) - # logging.info(f'Saving Text: {search_text}') - # Update the last frame's scroll_pos - self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos - self.cur_frame_idx += 1 if not trimmed else 0 - # Moving forward at the end of the stack with new content - elif frame_content is not None: - # If the current page is empty, don't include it in the new stack. - if self.nav_frames and not self.nav_frames[self.cur_frame_idx].contents: - self.nav_frames.pop() - trimmed = True - self.nav_frames.append( - NavigationState(frame_content, 0, page_index, page_count, search_text) - ) - # logging.info(f'Saving Text: {search_text}') - self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos - self.cur_frame_idx += 1 if not trimmed else 0 + page_index = page_id if page_id is not None else self.filter.page_index + delta + page_index = max(0, min(page_index, self.pages_count - 1)) - # if self.nav_stack[self.cur_page_idx].contents: - if (self.cur_frame_idx != original_pos) or (frame_content is not None): - self.update_thumbs() - sb.verticalScrollBar().setValue( - self.nav_frames[self.cur_frame_idx].scrollbar_pos - ) - self.main_window.searchField.setText( - self.nav_frames[self.cur_frame_idx].search_text - ) - self.main_window.pagination.update_buttons( - self.nav_frames[self.cur_frame_idx].page_count, - self.nav_frames[self.cur_frame_idx].page_index, - emit=False, - ) - # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') - # else: - # self.nav_stack.pop() - # self.cur_page_idx -= 1 - # self.update_thumbs() - # sb.verticalScrollBar().setValue(self.nav_stack[self.cur_page_idx].scrollbar_pos) + self.filter.page_index = page_index + self.filter_items() - # logging.info(f'Forward: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') - - def nav_back(self): - """Navigates a step backwards in the navigation stack.""" - - original_pos = self.cur_frame_idx - sb: QScrollArea = self.main_window.scrollArea - sb_pos = sb.verticalScrollBar().value() - - if self.cur_frame_idx > 0: - self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos - self.cur_frame_idx -= 1 - if self.cur_frame_idx != original_pos: - self.update_thumbs() - sb.verticalScrollBar().setValue( - self.nav_frames[self.cur_frame_idx].scrollbar_pos - ) - self.main_window.searchField.setText( - self.nav_frames[self.cur_frame_idx].search_text - ) - self.main_window.pagination.update_buttons( - self.nav_frames[self.cur_frame_idx].page_count, - self.nav_frames[self.cur_frame_idx].page_index, - emit=False, - ) - # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') - # logging.info(f'Back: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') - - def refresh_frame( - self, - frame_content: list[tuple[ItemType, int]], - page_index: int = 0, - page_count: int = 0, - ): - """ - Refreshes the current navigation contents without altering the - navigation stack order. - """ - if self.nav_frames: - self.nav_frames[self.cur_frame_idx] = NavigationState( - frame_content, - 0, - self.nav_frames[self.cur_frame_idx].page_index, - self.nav_frames[self.cur_frame_idx].page_count, - self.main_window.searchField.text(), - ) - else: - self.nav_forward(frame_content, page_index, page_count) - self.update_thumbs() - # logging.info(f'Refresh: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}') - - @typing.no_type_check - def purge_item_from_navigation(self, type: ItemType, id: int): - # logging.info(self.nav_frames) - # TODO - types here are ambiguous - for i, frame in enumerate(self.nav_frames, start=0): - while (type, id) in frame.contents: - logging.info(f"Removing {id} from nav stack frame {i}") - frame.contents.remove((type, id)) - - for i, key in enumerate(self.frame_dict.keys(), start=0): - for frame in self.frame_dict[key]: - while (type, id) in frame: - logging.info(f"Removing {id} from frame dict item {i}") - frame.remove((type, id)) - - while (type, id) in self.selected: - logging.info(f"Removing {id} from frame selected") - self.selected.remove((type, id)) + def remove_grid_item(self, grid_idx: int): + self.frame_content[grid_idx] = None + self.item_thumbs[grid_idx].hide() def _init_thumb_grid(self): - # logging.info('Initializing Thumbnail Grid...') + # logger.info('Initializing Thumbnail Grid...') layout = FlowLayout() layout.setGridEfficiency(True) # layout.setContentsMargins(0,0,0,0) @@ -1105,10 +851,10 @@ class QtDriver(QObject): # layout = QListView() # layout.setViewMode(QListView.ViewMode.IconMode) - col_size = 28 - for i in range(0, self.max_results): + # TODO - init after library is loaded, it can have different page_size + for grid_idx in range(self.filter.page_size): item_thumb = ItemThumb( - None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size) + None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx ) layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -1122,62 +868,37 @@ class QtDriver(QObject): sa.setWidgetResizable(True) sa.setWidget(self.flow_container) - def select_item(self, type: ItemType, id: int, append: bool, bridge: bool): - """Selects one or more items in the Thumbnail Grid.""" + def select_item(self, grid_index: int, append: bool, bridge: bool): + """Select one or more items in the Thumbnail Grid.""" + logger.info( + "selecting item", grid_index=grid_index, append=append, bridge=bridge + ) if append: - # self.selected.append((thumb_index, page_index)) - if ((type, id)) not in self.selected: - self.selected.append((type, id)) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(True) + if grid_index not in self.selected: + self.selected.append(grid_index) + self.item_thumbs[grid_index].thumb_button.set_selected(True) else: - self.selected.remove((type, id)) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(False) - # self.item_thumbs[thumb_index].thumb_button.set_selected(True) + self.selected.remove(grid_index) + self.item_thumbs[grid_index].thumb_button.set_selected(False) elif bridge and self.selected: - logging.info(f"Last Selected: {self.selected[-1]}") - contents = self.nav_frames[self.cur_frame_idx].contents - last_index = self.nav_frames[self.cur_frame_idx].contents.index( - self.selected[-1] - ) - current_index = self.nav_frames[self.cur_frame_idx].contents.index( - (type, id) - ) - index_range: list = contents[ - min(last_index, current_index) : max(last_index, current_index) + 1 - ] - # Preserve bridge direction for correct appending order. - if last_index < current_index: - index_range.reverse() + select_from = min(self.selected) + select_to = max(self.selected) - # logging.info(f'Current Frame Contents: {len(self.nav_frames[self.cur_frame_idx].contents)}') - # logging.info(f'Last Selected Index: {last_index}') - # logging.info(f'Current Selected Index: {current_index}') - # logging.info(f'Index Range: {index_range}') + if select_to < grid_index: + index_range = range(select_from, grid_index + 1) + else: + index_range = range(grid_index, select_to + 1) - for c_type, c_id in index_range: - for it in self.item_thumbs: - if it.mode == c_type and it.item_id == c_id: - it.thumb_button.set_selected(True) - if ((c_type, c_id)) not in self.selected: - self.selected.append((c_type, c_id)) + self.selected = list(index_range) + + for selected_idx in self.selected: + self.item_thumbs[selected_idx].thumb_button.set_selected(True) else: - # for i in self.selected: - # if i[1] == self.cur_frame_idx: - # self.item_thumbs[i[0]].thumb_button.set_selected(False) - self.selected.clear() - # self.selected.append((thumb_index, page_index)) - self.selected.append((type, id)) - # self.item_thumbs[thumb_index].thumb_button.set_selected(True) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(True) - else: - it.thumb_button.set_selected(False) + self.selected = [grid_index] + for thumb_idx, item_thumb in enumerate(self.item_thumbs): + item_matched = thumb_idx == grid_index + item_thumb.thumb_button.set_selected(item_matched) # NOTE: By using the preview panel's "set_tags_updated_slot" method, # only the last of multiple identical item selections are connected. @@ -1185,24 +906,19 @@ class QtDriver(QObject): # just bypass the method and manually disconnect and connect the slots. if len(self.selected) == 1: for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - self.preview_panel.set_tags_updated_slot(it.update_badges) + if it.item_id == id: + self.preview_panel.set_tags_updated_slot(it.refresh_badge) self.set_macro_menu_viability() self.preview_panel.update_widgets() def set_macro_menu_viability(self): - if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0: - self.autofill_action.setDisabled(True) - self.sort_fields_action.setDisabled(True) - else: - self.autofill_action.setDisabled(False) - self.sort_fields_action.setDisabled(False) + self.autofill_action.setDisabled(not self.selected) def update_thumbs(self): - """Updates search thumbnails.""" + """Update search thumbnails.""" # start_time = time.time() - # logging.info(f'Current Page: {self.cur_page_idx}, Stack Length:{len(self.nav_stack)}') + # logger.info(f'Current Page: {self.cur_page_idx}, Stack Length:{len(self.nav_stack)}') with self.thumb_job_queue.mutex: # Cancels all thumb jobs waiting to be started self.thumb_job_queue.queue.clear() @@ -1214,195 +930,118 @@ class QtDriver(QObject): ratio: float = self.main_window.devicePixelRatio() base_size: tuple[int, int] = (self.thumb_size, self.thumb_size) - for i, item_thumb in enumerate(self.item_thumbs, start=0): - if i < len(self.nav_frames[self.cur_frame_idx].contents): - # Set new item type modes - # logging.info(f'[UPDATE] Setting Mode To: {self.nav_stack[self.cur_page_idx].contents[i][0]}') - item_thumb.set_mode(self.nav_frames[self.cur_frame_idx].contents[i][0]) - item_thumb.ignore_size = False - # logging.info(f'[UPDATE] Set Mode To: {item.mode}') - # Set thumbnails to loading (will always finish if rendering) - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (sys.float_info.max, "", base_size, ratio, True, True), - ) - ) - # # Restore Selected Borders - # if (item_thumb.mode, item_thumb.item_id) in self.selected: - # item_thumb.thumb_button.set_selected(True) - # else: - # item_thumb.thumb_button.set_selected(False) - else: - item_thumb.ignore_size = True - item_thumb.set_mode(None) - item_thumb.set_item_id(-1) - item_thumb.thumb_button.set_selected(False) - # scrollbar: QScrollArea = self.main_window.scrollArea # scrollbar.verticalScrollBar().setValue(scrollbar_pos) self.flow_container.layout().update() self.main_window.update() - for i, item_thumb in enumerate(self.item_thumbs, start=0): - if i < len(self.nav_frames[self.cur_frame_idx].contents): - filepath = "" - if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: - entry = self.lib.get_entry( - self.nav_frames[self.cur_frame_idx].contents[i][1] - ) - filepath = self.lib.library_dir / entry.path / entry.filename + for idx, (entry, item_thumb) in enumerate( + zip_longest(self.frame_content, self.item_thumbs) + ): + if not entry: + item_thumb.hide() + continue - item_thumb.set_item_id(entry.id) - item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED)) - item_thumb.assign_favorite(entry.has_tag(self.lib, TAG_FAVORITE)) - # ctrl_down = True if QGuiApplication.keyboardModifiers() else False - # TODO: Change how this works. The click function - # for collations a few lines down should NOT be allowed during modifier keys. - item_thumb.update_clickable( - clickable=( - lambda checked=False, entry=entry: self.select_item( - ItemType.ENTRY, - entry.id, - append=True - if QGuiApplication.keyboardModifiers() - == Qt.KeyboardModifier.ControlModifier - else False, - bridge=True - if QGuiApplication.keyboardModifiers() - == Qt.KeyboardModifier.ShiftModifier - else False, - ) - ) - ) - # item_thumb.update_clickable(clickable=( - # lambda checked=False, filepath=filepath, entry=entry, - # item_t=item_thumb, i=i, page=self.cur_frame_idx: ( - # self.preview_panel.update_widgets(entry), - # self.select_item(ItemType.ENTRY, entry.id, - # append=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier else False, - # bridge=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier else False)))) - # item.dumpObjectTree() - elif ( - self.nav_frames[self.cur_frame_idx].contents[i][0] - == ItemType.COLLATION - ): - collation = self.lib.get_collation( - self.nav_frames[self.cur_frame_idx].contents[i][1] - ) - cover_id = ( - collation.cover_id - if collation.cover_id >= 0 - else collation.e_ids_and_pages[0][0] - ) - cover_e = self.lib.get_entry(cover_id) - filepath = self.lib.library_dir / cover_e.path / cover_e.filename - item_thumb.set_count(str(len(collation.e_ids_and_pages))) - item_thumb.update_clickable( - clickable=( - lambda checked=False, - filepath=filepath, - entry=cover_e, - collation=collation: ( - self.expand_collation(collation.e_ids_and_pages) - ) - ) - ) - # item.setHidden(False) + 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) - # Restore Selected Borders - if (item_thumb.mode, item_thumb.item_id) in self.selected: - item_thumb.thumb_button.set_selected(True) - else: - item_thumb.thumb_button.set_selected(False) + # TODO - show after item is rendered + item_thumb.show() - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (time.time(), filepath, base_size, ratio, False, True), + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (sys.float_info.max, "", base_size, ratio, True, True), + ) + ) + + entry_tag_ids = {tag.id for tag in entry.tags} + item_thumb.assign_badge(BadgeType.ARCHIVED, TAG_ARCHIVED in entry_tag_ids) + item_thumb.assign_badge(BadgeType.FAVORITE, TAG_FAVORITE in entry_tag_ids) + item_thumb.update_clickable( + clickable=( + lambda checked=False, index=idx: self.select_item( + index, + append=( + QGuiApplication.keyboardModifiers() + == Qt.KeyboardModifier.ControlModifier + ), + bridge=( + QGuiApplication.keyboardModifiers() + == Qt.KeyboardModifier.ShiftModifier + ), ) ) - else: - # item.setHidden(True) - pass - # update_widget_clickable(widget=item.bg_button, clickable=()) - # self.thumb_job_queue.put( - # (item.renderer.render, ('', base_size, ratio, False))) + ) - # end_time = time.time() - # logging.info( - # f'[MAIN] Elements thumbs updated in {(end_time - start_time):.3f} seconds') + # Restore Selected Borders + is_selected = (item_thumb.mode, item_thumb.item_id) in self.selected + item_thumb.thumb_button.set_selected(is_selected) - def update_badges(self): - for i, item_thumb in enumerate(self.item_thumbs, start=0): - item_thumb.update_badges() + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (time.time(), filepath, base_size, ratio, False, True), + ) + ) - def expand_collation(self, collation_entries: list[tuple[int, int]]): - self.nav_forward([(ItemType.ENTRY, x[0]) for x in collation_entries]) - # self.update_thumbs() + def update_badges(self, grid_item_ids: Sequence[int] = None): + if not grid_item_ids: + # no items passed, update all items in grid + grid_item_ids = range(min(len(self.item_thumbs), len(self.frame_content))) - def get_frame_contents(self, index=0, query: str = ""): - return ( - [] if not self.frame_dict[query] else self.frame_dict[query][index], - index, - len(self.frame_dict[query]), + logger.info("updating badges for items", grid_item_ids=grid_item_ids) + + for grid_idx in grid_item_ids: + # get the entry from grid to avoid loading from db again + entry = self.frame_content[grid_idx] + self.item_thumbs[grid_idx].refresh_badge(entry) + + def filter_items(self, filter: FilterState | None = None) -> None: + assert self.lib.engine + + if filter: + self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter)) + + self.main_window.statusbar.showMessage( + f'Searching Library: "{self.filter.summary}"' + ) + self.main_window.statusbar.repaint() + start_time = time.time() + + query_count, page_items = self.lib.search_library(self.filter) + + logger.info("items to render", count=len(page_items)) + + end_time = time.time() + if self.filter.summary: + self.main_window.statusbar.showMessage( + f'{query_count} Results Found for "{self.filter.summary}" ({format_timespan(end_time - start_time)})' + ) + else: + self.main_window.statusbar.showMessage( + f"{query_count} Results ({format_timespan(end_time - start_time)})" + ) + + # update page content + self.frame_content = list(page_items) + self.update_thumbs() + + # update pagination + self.pages_count = math.ceil(query_count / self.filter.page_size) + self.main_window.pagination.update_buttons( + self.pages_count, self.filter.page_index, emit=False ) - def filter_items(self, query: str = ""): - if self.lib: - # logging.info('Filtering...') - self.main_window.statusbar.showMessage( - f'Searching Library for "{query}"...' + def set_search_type(self, mode: SearchMode = SearchMode.AND): + self.filter_items( + FilterState( + search_mode=mode, + path=self.main_window.searchField.text(), ) - self.main_window.statusbar.repaint() - start_time = time.time() - - # self.filtered_items = self.lib.search_library(query) - # 73601 Entries at 500 size should be 246 - all_items = self.lib.search_library(query, search_mode=self.search_mode) - frames: list[list[tuple[ItemType, int]]] = [] - frame_count = math.ceil(len(all_items) / self.max_results) - for i in range(0, frame_count): - frames.append( - all_items[ - min(len(all_items) - 1, (i) * self.max_results) : min( - len(all_items), (i + 1) * self.max_results - ) - ] - ) - for i, f in enumerate(frames): - logging.info(f"Query:{query}, Frame: {i}, Length: {len(f)}") - self.frame_dict[query] = frames - # self.frame_dict[query] = [all_items] - - if self.cur_query == query: - # self.refresh_frame(self.lib.search_library(query)) - # NOTE: Trying to refresh instead of navigating forward here - # now creates a bug when the page counts differ on refresh. - # If refreshing is absolutely desired, see how to update - # page counts where they need to be updated. - self.nav_forward(*self.get_frame_contents(0, query)) - else: - # self.nav_forward(self.lib.search_library(query)) - self.nav_forward(*self.get_frame_contents(0, query)) - self.cur_query = query - - end_time = time.time() - if query: - self.main_window.statusbar.showMessage( - f'{len(all_items)} Results Found for "{query}" ({format_timespan(end_time - start_time)})' - ) - else: - self.main_window.statusbar.showMessage( - f"{len(all_items)} Results ({format_timespan(end_time - start_time)})" - ) - # logging.info(f'Done Filtering! ({(end_time - start_time):.3f}) seconds') - - # self.update_thumbs() - - def set_search_type(self, mode=SearchMode.AND): - self.search_mode = mode - self.filter_items(self.main_window.searchField.text()) + ) def remove_recent_library(self, item_key: str): self.settings.beginGroup(SettingItems.LIBS_LIST) @@ -1410,8 +1049,7 @@ class QtDriver(QObject): self.settings.endGroup() self.settings.sync() - @typing.no_type_check - def update_libs_list(self, path: Path): + def update_libs_list(self, path: Path | str): """add library to list in SettingItems.LIBS_LIST""" ITEMS_LIMIT = 5 path = Path(path) @@ -1421,193 +1059,44 @@ class QtDriver(QObject): all_libs = {str(time.time()): str(path)} for item_key in self.settings.allKeys(): - item_path = self.settings.value(item_key) + item_path = str(self.settings.value(item_key, type=str)) if Path(item_path) != path: all_libs[item_key] = item_path # sort items, most recent first - all_libs = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) + all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) # remove previously saved items self.settings.clear() - for item_key, item_value in all_libs[:ITEMS_LIMIT]: + for item_key, item_value in all_libs_list[:ITEMS_LIMIT]: self.settings.setValue(item_key, item_value) self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path): + def open_library(self, path: Path | str): """Opens 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() - if self.lib.library_dir: - self.save_library() - self.lib.clear_internal_vars() + self.lib.open_library(path) - return_code = self.lib.open_library(path) - if return_code == 1: - pass - else: - logging.info( - f"{ERROR} No existing TagStudio library found at '{path}'. Creating one." - ) - print(f"Library Creation Return Code: {self.lib.create_library(path)}") - self.add_new_files_callback() + self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) + + # TODO - make this call optional + self.add_new_files_callback() self.update_libs_list(path) title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" self.main_window.setWindowTitle(title_text) - self.nav_frames = [] - self.cur_frame_idx = -1 - self.cur_query = "" self.selected.clear() self.preview_panel.update_widgets() + + # page (re)rendering, extract eventually self.filter_items() + self.main_window.toggle_landing_page(False) - - def create_collage(self) -> None: - """Generates and saves an image collage based on Library Entries.""" - - run: bool = True - keep_aspect: bool = False - data_only_mode: bool = False - data_tint_mode: bool = False - - self.main_window.statusbar.showMessage(f"Creating Library Collage...") - self.collage_start_time = time.time() - - # mode:int = self.scr_choose_option(subtitle='Choose Collage Mode(s)', - # choices=[ - # ('Normal','Creates a standard square image collage made up of Library media files.'), - # ('Data Tint','Tints the collage with a color representing data about the Library Entries/files.'), - # ('Data Only','Ignores media files entirely and only outputs a collage of Library Entry/file data.'), - # ('Normal & Data Only','Creates both Normal and Data Only collages.'), - # ], prompt='', required=True) - mode = 0 - - if mode == 1: - data_tint_mode = True - - if mode == 2: - data_only_mode = True - - if mode in [0, 1, 3]: - # keep_aspect = self.scr_choose_option( - # subtitle='Choose Aspect Ratio Option', - # choices=[ - # ('Stretch to Fill','Stretches the media file to fill the entire collage square.'), - # ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.') - # ], prompt='', required=True) - keep_aspect = False - - if mode in [1, 2, 3]: - # TODO: Choose data visualization options here. - pass - - full_thumb_size: int = 1 - - if mode in [0, 1, 3]: - # full_thumb_size = self.scr_choose_option( - # subtitle='Choose Thumbnail Size', - # choices=[ - # ('Tiny (32px)',''), - # ('Small (64px)',''), - # ('Medium (128px)',''), - # ('Large (256px)',''), - # ('Extra Large (512px)','') - # ], prompt='', required=True) - full_thumb_size = 0 - - thumb_size: int = ( - 32 - if (full_thumb_size == 0) - else 64 - if (full_thumb_size == 1) - else 128 - if (full_thumb_size == 2) - else 256 - if (full_thumb_size == 3) - else 512 - if (full_thumb_size == 4) - else 32 - ) - thumb_size = 16 - - # if len(com) > 1 and com[1] == 'keep-aspect': - # keep_aspect = True - # elif len(com) > 1 and com[1] == 'data-only': - # data_only_mode = True - # elif len(com) > 1 and com[1] == 'data-tint': - # data_tint_mode = True - grid_size = math.ceil(math.sqrt(len(self.lib.entries))) ** 2 - grid_len = math.floor(math.sqrt(grid_size)) - thumb_size = thumb_size if not data_only_mode else 1 - img_size = thumb_size * grid_len - - logging.info( - f"Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})" - ) - if keep_aspect: - logging.info("Keeping original aspect ratios.") - if data_only_mode: - logging.info("Visualizing Entry Data") - - if not data_only_mode: - time.sleep(5) - - self.collage = Image.new("RGB", (img_size, img_size)) - i = 0 - self.completed = 0 - for x in range(0, grid_len): - for y in range(0, grid_len): - if i < len(self.lib.entries) and run: - # if i < 5 and run: - - entry_id = self.lib.entries[i].id - renderer = CollageIconRenderer(self.lib) - renderer.rendered.connect( - lambda image, x=x, y=y: self.collage.paste( - image, (y * thumb_size, x * thumb_size) - ) - ) - renderer.done.connect(lambda: self.try_save_collage(True)) - self.thumb_job_queue.put( - ( - renderer.render, - ( - entry_id, - (thumb_size, thumb_size), - data_tint_mode, - data_only_mode, - keep_aspect, - ), - ) - ) - i = i + 1 - - def try_save_collage(self, increment_progress: bool): - if increment_progress: - self.completed += 1 - # logging.info(f'threshold:{len(self.lib.entries}, completed:{self.completed}') - if self.completed == len(self.lib.entries): - filename = ( - self.lib.library_dir - / TS_FOLDER_NAME - / COLLAGE_FOLDER_NAME - / f'collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png' - ) - self.collage.save(filename) - self.collage = None - - end_time = time.time() - self.main_window.statusbar.showMessage( - f'Collage Saved at "{filename}" ({format_timespan(end_time - self.collage_start_time)})' - ) - logging.info( - f'Collage Saved at "{filename}" ({format_timespan(end_time - self.collage_start_time)})' - ) diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index b9234d7d..b8382a49 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -2,37 +2,22 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging -import os -import traceback from pathlib import Path import cv2 +import structlog from PIL import Image, ImageChops, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import ( QObject, - QThread, Signal, - QRunnable, - Qt, - QThreadPool, - QSize, - QEvent, - QTimer, - QSettings, ) -from src.core.library import Library from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.library import Library +from src.core.library.alchemy.fields import _FieldID - -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" - - -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) class CollageIconRenderer(QObject): @@ -52,26 +37,22 @@ class CollageIconRenderer(QObject): keep_aspect, ): entry = self.lib.get_entry(entry_id) - filepath = self.lib.library_dir / entry.path / entry.filename - file_type = os.path.splitext(filepath)[1].lower()[1:] + filepath = self.lib.library_dir / entry.path color: str = "" try: if data_tint_mode or data_only_mode: - color = "#000000" # Black (Default) - if entry.fields: has_any_tags: bool = False has_content_tags: bool = False has_meta_tags: bool = False - for field in entry.fields: - if self.lib.get_field_attr(field, "type") == "tag_box": - if self.lib.get_field_attr(field, "content"): - has_any_tags = True - if self.lib.get_field_attr(field, "id") == 7: - has_content_tags = True - elif self.lib.get_field_attr(field, "id") == 8: - has_meta_tags = True + for field in entry.tag_box_fields: + if field.tags: + has_any_tags = True + if field.type_key == _FieldID.TAGS_CONTENT.name: + has_content_tags = True + elif field.type_key == _FieldID.TAGS_META.name: + has_meta_tags = True if has_content_tags and has_meta_tags: color = "#28bb48" # Green elif has_any_tags: @@ -88,16 +69,15 @@ class CollageIconRenderer(QObject): # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) if not data_only_mode: - logging.info( - f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m" + logger.info( + "Combining icons", + entry=entry, + color=self.get_file_color(filepath.suffix.lower()), ) - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() + if filepath.suffix.lower() in IMAGE_TYPES: try: - with Image.open( - str(self.lib.library_dir / entry.path / entry.filename) - ) as pic: + with Image.open(str(self.lib.library_dir / entry.path)) as pic: if keep_aspect: pic.thumbnail(size) else: @@ -109,8 +89,10 @@ class CollageIconRenderer(QObject): ) # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) - except DecompressionBombError as e: - logging.info(f"[ERROR] One of the images was too big ({e})") + except DecompressionBombError: + logger.exception( + "One of the images was too big", entry=entry.path + ) elif filepath.suffix.lower() in VIDEO_TYPES: video = cv2.VideoCapture(str(filepath)) video.set( @@ -137,9 +119,7 @@ class CollageIconRenderer(QObject): # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): - logging.info( - f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" - ) + logger.error("Couldn't read entry", entry=entry.path) with Image.open( str( Path(__file__).parents[2] @@ -153,19 +133,11 @@ class CollageIconRenderer(QObject): # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - # clear() - logging.info("\n") - logging.info(f"{INFO} Collage operation cancelled.") - clear_scr = False - except: - logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}") - traceback.print_exc() - logging.info("Continuing...") + logger.info("Collage operation cancelled.") + except Exception: + logger.exception("render failed", entry=entry.path) self.done.emit() - # logging.info('Done!') def get_file_color(self, ext: str): if ext.lower().replace(".", "", 1) == "gif": diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 355a0fa9..fc7a1fd8 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -4,15 +4,14 @@ import math -import os -from types import FunctionType, MethodType +from types import MethodType from pathlib import Path -from typing import Optional, cast, Callable, Any +from typing import Optional, Callable from PIL import Image, ImageQt from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QPixmap, QEnterEvent -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper @@ -41,8 +40,8 @@ class FieldContainer(QWidget): self.title: str = title self.inline: bool = inline # self.editable:bool = editable - self.copy_callback: FunctionType = None - self.edit_callback: FunctionType = None + self.copy_callback: Callable = None + self.edit_callback: Callable = None self.remove_callback: Callable = None button_size = 24 # self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;') @@ -133,7 +132,7 @@ class FieldContainer(QWidget): if callback is not None: self.copy_button.is_connected = True - def set_edit_callback(self, callback: Optional[MethodType]): + def set_edit_callback(self, callback: Callable): if self.edit_button.is_connected: self.edit_button.clicked.disconnect() @@ -142,7 +141,7 @@ class FieldContainer(QWidget): if callback is not None: self.edit_button.is_connected = True - def set_remove_callback(self, callback: Optional[Callable]): + def set_remove_callback(self, callback: Callable): if self.remove_button.is_connected: self.remove_button.clicked.disconnect() @@ -160,9 +159,9 @@ class FieldContainer(QWidget): self.field_layout.itemAt(0).widget().deleteLater() self.field_layout.addWidget(widget) - def get_inner_widget(self) -> Optional["FieldWidget"]: + def get_inner_widget(self): if self.field_layout.itemAt(0): - return cast(FieldWidget, self.field_layout.itemAt(0).widget()) + return self.field_layout.itemAt(0).widget() return None def set_title(self, title: str): @@ -198,8 +197,6 @@ class FieldContainer(QWidget): class FieldWidget(QWidget): - field = dict - def __init__(self, title) -> None: super().__init__() # self.item = item diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 5be2e3fb..05a02d06 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -1,14 +1,14 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import contextlib -import logging -import os import time import typing +from enum import Enum +from functools import wraps from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING +import structlog from PIL import Image, ImageQt from PySide6.QtCore import Qt, QSize, QEvent from PySide6.QtGui import QPixmap, QEnterEvent, QAction @@ -21,8 +21,6 @@ from PySide6.QtWidgets import ( QCheckBox, ) -from src.core.enums import FieldID -from src.core.library import ItemType, Library, Entry from src.core.constants import ( AUDIO_TYPES, VIDEO_TYPES, @@ -30,20 +28,49 @@ from src.core.constants import ( TAG_FAVORITE, TAG_ARCHIVED, ) +from src.core.library import ItemType, Entry, Library +from src.core.library.alchemy.enums import FilterState +from src.core.library.alchemy.fields import _FieldID + from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper from src.qt.widgets.thumb_renderer import ThumbRenderer from src.qt.widgets.thumb_button import ThumbButton -if typing.TYPE_CHECKING: - from src.qt.widgets.preview_panel import PreviewPanel +if TYPE_CHECKING: + from src.qt.ts_qt import QtDriver -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" +logger = structlog.get_logger(__name__) -logging.basicConfig(format="%(message)s", level=logging.INFO) +class BadgeType(Enum): + FAVORITE = "Favorite" + ARCHIVED = "Archived" + + +BADGE_TAGS = { + BadgeType.FAVORITE: TAG_FAVORITE, + BadgeType.ARCHIVED: TAG_ARCHIVED, +} + + +def badge_update_lock(func): + """Prevent recursively triggering badge updates.""" + + @wraps(func) + def wrapper(self, *args, **kwargs): + if self.driver.badge_update_lock: + return + + self.driver.badge_update_lock = True + try: + func(self, *args, **kwargs) + except Exception: + raise + finally: + self.driver.badge_update_lock = False + + return wrapper class ItemThumb(FlowWidget): @@ -89,19 +116,18 @@ class ItemThumb(FlowWidget): def __init__( self, - mode: Optional[ItemType], + mode: ItemType, library: Library, - panel: "PreviewPanel", + driver: "QtDriver", thumb_size: tuple[int, int], + grid_idx: int, ): - """Modes: entry, collation, tag_group""" super().__init__() + self.grid_idx = grid_idx self.lib = library - self.panel = panel - self.mode = mode - self.item_id: int = -1 - self.isFavorite: bool = False - self.isArchived: bool = False + self.mode: ItemType = mode + self.driver = driver + self.item_id: int | None = None self.thumb_size: tuple[int, int] = thumb_size self.setMinimumSize(*thumb_size) self.setMaximumSize(*thumb_size) @@ -179,7 +205,7 @@ class ItemThumb(FlowWidget): lambda ts, i, s, ext: ( self.update_thumb(ts, image=i), self.update_size(ts, size=s), - self.set_extension(ext), # type: ignore + self.set_extension(ext), ) ) self.thumb_button.setFlat(True) @@ -263,54 +289,52 @@ class ItemThumb(FlowWidget): # self.root_layout.addWidget(self.check_badges, 0, 2) self.top_layout.addWidget(self.cb_container) - # Favorite Badge ------------------------------------------------------- - self.favorite_badge = QCheckBox() - self.favorite_badge.setObjectName("favBadge") - self.favorite_badge.setToolTip("Favorite") - self.favorite_badge.setStyleSheet( - f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" - f"QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}" - f"QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}" - # f'QCheckBox{{background-color:yellow;}}' - ) - self.favorite_badge.setMinimumSize(check_size, check_size) - self.favorite_badge.setMaximumSize(check_size, check_size) - self.favorite_badge.stateChanged.connect( - lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x)) - ) + self.badge_active: dict[BadgeType, bool] = { + BadgeType.FAVORITE: False, + BadgeType.ARCHIVED: False, + } - # self.fav_badge.setContentsMargins(0,0,0,0) - # tr_layout.addWidget(self.fav_badge) - # root_layout.addWidget(self.fav_badge, 0, 2) - self.cb_layout.addWidget(self.favorite_badge) - self.favorite_badge.setHidden(True) + self.badges: dict[BadgeType, QCheckBox] = {} + badge_icons = { + BadgeType.FAVORITE: ( + ":/images/star_icon_empty_128.png", + ":/images/star_icon_filled_128.png", + ), + BadgeType.ARCHIVED: ( + ":/images/box_icon_empty_128.png", + ":/images/box_icon_filled_128.png", + ), + } + for badge_type in BadgeType: + icon_empty, icon_checked = badge_icons[badge_type] + badge = QCheckBox() + badge.setObjectName(badge_type.name) + badge.setToolTip(badge_type.value) + badge.setStyleSheet( + f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" + f"QCheckBox::indicator::unchecked{{image: url({icon_empty})}}" + f"QCheckBox::indicator::checked{{image: url({icon_checked})}}" + ) + badge.setMinimumSize(check_size, check_size) + badge.setMaximumSize(check_size, check_size) + badge.setHidden(True) - # Archive Badge -------------------------------------------------------- - self.archived_badge = QCheckBox() - self.archived_badge.setObjectName("archiveBadge") - self.archived_badge.setToolTip("Archive") - self.archived_badge.setStyleSheet( - f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" - f"QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}" - f"QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}" - # f'QCheckBox{{background-color:red;}}' - ) - self.archived_badge.setMinimumSize(check_size, check_size) - self.archived_badge.setMaximumSize(check_size, check_size) - # self.archived_badge.clicked.connect(lambda x: self.assign_archived(x)) - self.archived_badge.stateChanged.connect( - lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x)) - ) + badge.stateChanged.connect(lambda x, bt=badge_type: self.on_badge_check(bt)) - # tr_layout.addWidget(self.archive_badge) - self.cb_layout.addWidget(self.archived_badge) - self.archived_badge.setHidden(True) - # root_layout.addWidget(self.archive_badge, 0, 2) - # self.dumpObjectTree() + self.badges[badge_type] = badge + self.cb_layout.addWidget(badge) self.set_mode(mode) - def set_mode(self, mode: Optional[ItemType]) -> None: + @property + def is_favorite(self) -> bool: + return self.badge_active[BadgeType.FAVORITE] + + @property + def is_archived(self): + return self.badge_active[BadgeType.ARCHIVED] + + def set_mode(self, mode: ItemType | None) -> None: if mode is None: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.unsetCursor() @@ -318,7 +342,6 @@ class ItemThumb(FlowWidget): # self.check_badges.setHidden(True) # self.ext_badge.setHidden(True) # self.item_type_badge.setHidden(True) - pass elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY: self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) self.setCursor(Qt.CursorShape.PointingHandCursor) @@ -349,11 +372,6 @@ class ItemThumb(FlowWidget): self.mode = mode # logging.info(f'Set Mode To: {self.mode}') - # def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None: - # """Updates the ItemThumb's visuals.""" - # if thumb: - # pass - def set_extension(self, ext: str) -> None: if ext and ext.startswith(".") is False: ext = "." + ext @@ -376,8 +394,8 @@ class ItemThumb(FlowWidget): self.ext_badge.setHidden(True) self.count_badge.setHidden(True) - def update_thumb(self, timestamp: float, image: QPixmap = None): - """Updates attributes of a thumbnail element.""" + def update_thumb(self, timestamp: float, image: QPixmap | None = None): + """Update attributes of a thumbnail element.""" # logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}') if timestamp > ItemThumb.update_cutoff: self.thumb_button.setIcon(image if image else QPixmap()) @@ -386,11 +404,10 @@ class ItemThumb(FlowWidget): def update_size(self, timestamp: float, size: QSize): """Updates attributes of a thumbnail element.""" # logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}') - if timestamp > ItemThumb.update_cutoff: - if self.thumb_button.iconSize != size: - self.thumb_button.setIconSize(size) - self.thumb_button.setMinimumSize(size) - self.thumb_button.setMaximumSize(size) + if timestamp > ItemThumb.update_cutoff and self.thumb_button.iconSize != size: + self.thumb_button.setIconSize(size) + self.thumb_button.setMinimumSize(size) + self.thumb_button.setMaximumSize(size) def update_clickable(self, clickable: typing.Callable): """Updates attributes of a thumbnail element.""" @@ -401,58 +418,42 @@ class ItemThumb(FlowWidget): self.thumb_button.clicked.connect(clickable) self.thumb_button.is_connected = True - def update_badges(self): - if self.mode == ItemType.ENTRY: - # logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}') - # logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}') - self.assign_archived( - self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_ARCHIVED) - ) - self.assign_favorite( - self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_FAVORITE) - ) + def refresh_badge(self, entry: Entry | None = None): + if not entry: + if not self.item_id: + logger.error("missing both entry and item_id") + return None - def set_item_id(self, id: int): - """ - also sets the filepath for the file opener - """ - self.item_id = id - if id == -1: - return - entry = self.lib.get_entry(self.item_id) - filepath = self.lib.library_dir / entry.path / entry.filename + entry = self.lib.get_entry(self.item_id) + if not entry: + logger.error("Entry not found", item_id=self.item_id) + return + + self.assign_badge(BadgeType.ARCHIVED, entry.is_archived) + self.assign_badge(BadgeType.FAVORITE, entry.is_favorited) + + def set_item_id(self, entry: Entry): + filepath = self.lib.library_dir / entry.path self.opener.set_filepath(filepath) + self.item_id = entry.id - def assign_favorite(self, value: bool): - # Switching mode to None to bypass mode-specific operations when the - # checkbox's state changes. + def assign_badge(self, badge_type: BadgeType, value: bool) -> None: mode = self.mode + # blank mode to avoid recursive badge updates self.mode = None - self.isFavorite = value - self.favorite_badge.setChecked(value) - if not self.thumb_button.underMouse(): - self.favorite_badge.setHidden(not self.isFavorite) - self.mode = mode + badge = self.badges[badge_type] + self.badge_active[badge_type] = value + if badge.isChecked() != value: + badge.setChecked(value) + badge.setHidden(not value) - def assign_archived(self, value: bool): - # Switching mode to None to bypass mode-specific operations when the - # checkbox's state changes. - mode = self.mode - self.mode = None - self.isArchived = value - self.archived_badge.setChecked(value) - if not self.thumb_button.underMouse(): - self.archived_badge.setHidden(not self.isArchived) self.mode = mode def show_check_badges(self, show: bool): if self.mode != ItemType.TAG_GROUP: - self.favorite_badge.setHidden( - True if (not show and not self.isFavorite) else False - ) - self.archived_badge.setHidden( - True if (not show and not self.isArchived) else False - ) + for badge_type, badge in self.badges.items(): + is_hidden = not (show or self.badge_active[badge_type]) + badge.setHidden(is_hidden) def enterEvent(self, event: QEnterEvent) -> None: self.show_check_badges(True) @@ -462,40 +463,55 @@ class ItemThumb(FlowWidget): self.show_check_badges(False) return super().leaveEvent(event) - def on_archived_check(self, toggle_value: bool): - if self.mode == ItemType.ENTRY: - self.isArchived = toggle_value - self.toggle_item_tag(toggle_value, TAG_ARCHIVED) + @badge_update_lock + def on_badge_check(self, badge_type: BadgeType): + if self.mode is None: + return - def on_favorite_check(self, toggle_value: bool): - if self.mode == ItemType.ENTRY: - self.isFavorite = toggle_value - self.toggle_item_tag(toggle_value, TAG_FAVORITE) + toggle_value = self.badges[badge_type].isChecked() - def toggle_item_tag(self, toggle_value: bool, tag_id: int): - def toggle_tag(entry: Entry): - if toggle_value: - self.favorite_badge.setHidden(False) - entry.add_tag( - self.panel.driver.lib, - tag_id, - field_id=FieldID.META_TAGS, - field_index=-1, - ) - else: - entry.remove_tag(self.panel.driver.lib, tag_id) + self.badge_active[badge_type] = toggle_value + tag_id = BADGE_TAGS[badge_type] - # Is the badge a part of the selection? - if (ItemType.ENTRY, self.item_id) in self.panel.driver.selected: - # Yes, add chosen tag to all selected. - for _, item_id in self.panel.driver.selected: - entry = self.lib.get_entry(item_id) - toggle_tag(entry) + # check if current item is selected. if so, update all selected items + if self.grid_idx in self.driver.selected: + update_items = self.driver.selected else: - # No, add tag to the entry this badge is on. - entry = self.lib.get_entry(self.item_id) - toggle_tag(entry) + update_items = [self.grid_idx] - if self.panel.isOpen: - self.panel.update_widgets() - self.panel.driver.update_badges() + for idx in update_items: + entry = self.driver.frame_content[idx] + self.toggle_item_tag( + entry, toggle_value, tag_id, _FieldID.TAGS_META.name, True + ) + # update the entry + self.driver.frame_content[idx] = self.lib.search_library( + FilterState(id=entry.id) + )[1][0] + + self.driver.update_badges(update_items) + + def toggle_item_tag( + self, + entry: Entry, + toggle_value: bool, + tag_id: int, + field_key: str, + create_field: bool = False, + ): + logger.info( + "toggle_item_tag", + entry_id=entry.id, + toggle_value=toggle_value, + tag_id=tag_id, + field_key=field_key, + ) + + tag = self.lib.get_tag(tag_id) + if toggle_value: + self.lib.add_field_tag(entry, tag, field_key, create_field) + else: + self.lib.remove_field_tag(entry, tag.id, field_key) + + if self.driver.preview_panel.is_open: + self.driver.preview_panel.update_widgets() diff --git a/tagstudio/src/qt/widgets/landing.py b/tagstudio/src/qt/widgets/landing.py index 9df3fa4d..e5df9752 100644 --- a/tagstudio/src/qt/widgets/landing.py +++ b/tagstudio/src/qt/widgets/landing.py @@ -24,7 +24,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO) class LandingWidget(QWidget): def __init__(self, driver: "QtDriver", pixel_ratio: float): super().__init__() - self.driver: "QtDriver" = driver + self.driver = driver self.logo_label: ClickableLabel = ClickableLabel() self._pixel_ratio: float = pixel_ratio self._logo_width: int = int(480 * pixel_ratio) @@ -56,7 +56,6 @@ class LandingWidget(QWidget): self.logo_special_anim.setDuration(500) # Create "Open/Create Library" button ---------------------------------- - open_shortcut_text: str = "" if sys.platform == "darwin": open_shortcut_text = "(⌘+O)" else: diff --git a/tagstudio/src/qt/widgets/panel.py b/tagstudio/src/qt/widgets/panel.py index 2d2538b2..1448d73e 100644 --- a/tagstudio/src/qt/widgets/panel.py +++ b/tagstudio/src/qt/widgets/panel.py @@ -2,7 +2,6 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import logging -from types import FunctionType from typing import Callable from PySide6.QtCore import Signal, Qt @@ -16,12 +15,11 @@ class PanelModal(QWidget): # figure out what you want from this. def __init__( self, - widget: "PanelWidget", + widget, title: str, window_title: str, - done_callback: Callable = None, - # cancel_callback:FunctionType=None, - save_callback: Callable = None, + done_callback: Callable | None = None, + save_callback: Callable | None = None, has_save: bool = False, ): # [Done] @@ -76,10 +74,12 @@ class PanelModal(QWidget): if done_callback: self.save_button.clicked.connect(done_callback) + if save_callback: self.save_button.clicked.connect( lambda: save_callback(widget.get_content()) ) + self.button_layout.addWidget(self.save_button) # trigger save button actions when pressing enter in the widget diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 27320aab..386fa9dd 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -1,8 +1,8 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import logging +import sys +from collections.abc import Callable from pathlib import Path import time import typing @@ -10,6 +10,7 @@ from datetime import datetime as dt import cv2 import rawpy +import structlog from PIL import Image, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import Signal, Qt, QSize @@ -29,8 +30,16 @@ from PySide6.QtWidgets import ( from humanfriendly import format_size from src.core.enums import SettingItems, Theme -from src.core.library import Entry, ItemType, Library from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME +from src.core.library.alchemy.enums import FilterState +from src.core.library.alchemy.fields import ( + TagBoxField, + DatetimeField, + FieldTypeEnum, + _FieldID, + TextField, + BaseField, +) from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file from src.qt.modals.add_field import AddFieldModal from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -42,17 +51,24 @@ from src.qt.widgets.text_box_edit import EditTextBox from src.qt.widgets.text_line_edit import EditTextLine from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.widgets.video_player import VideoPlayer +from src.core.library.alchemy.library import Library - -# Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" +logger = structlog.get_logger(__name__) -logging.basicConfig(format="%(message)s", level=logging.INFO) + +def update_selected_entry(driver: "QtDriver"): + for grid_idx in driver.selected: + entry = driver.frame_content[grid_idx] + # reload entry + _, entries = driver.lib.search_library(FilterState(id=entry.id)) + logger.info( + "found item", entries=entries, grid_idx=grid_idx, lookup_id=entry.id + ) + assert entries, f"Entry not found: {entry.id}" + driver.frame_content[grid_idx] = entries[0] class PreviewPanel(QWidget): @@ -66,14 +82,14 @@ class PreviewPanel(QWidget): self.lib = library self.driver: QtDriver = driver self.initialized = False - self.isOpen: bool = False + self.is_open: bool = False # self.filepath = None # self.item = None # DEPRECATED, USE self.selected self.common_fields: list = [] self.mixed_fields: list = [] - self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items + self.selected: list[int] = [] # New way of tracking items self.tag_callback = None - self.containers: list[QWidget] = [] + self.containers: list[FieldContainer] = [] self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 @@ -226,7 +242,7 @@ class PreviewPanel(QWidget): self.add_field_button.setMaximumSize(96, 28) self.add_field_button.setText("Add Field") self.afb_layout.addWidget(self.add_field_button) - self.afm = AddFieldModal(self.lib) + self.add_field_modal = AddFieldModal(self.lib) self.place_add_field_button() self.update_image_size( (self.image_container.size().width(), self.image_container.size().height()) @@ -236,12 +252,15 @@ class PreviewPanel(QWidget): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(splitter) + def remove_field_prompt(self, name: str) -> str: + return f'Are you sure you want to remove field "{name}"?' + def fill_libs_widget(self, layout: QVBoxLayout): settings = self.driver.settings settings.beginGroup(SettingItems.LIBS_LIST) lib_items: dict[str, tuple[str, str]] = {} for item_tstamp in settings.allKeys(): - val: str = settings.value(item_tstamp) # type: ignore + val = str(settings.value(item_tstamp, type=str)) cut_val = val if len(val) > 45: cut_val = f"{val[0:10]} ... {val[-10:]}" @@ -275,7 +294,7 @@ class PreviewPanel(QWidget): clear_layout(layout) label = QLabel("Recent Libraries") - label.setAlignment(Qt.AlignCenter) # type: ignore + label.setAlignment(Qt.AlignmentFlag.AlignCenter) row_layout = QHBoxLayout() row_layout.addWidget(label) @@ -296,14 +315,12 @@ class PreviewPanel(QWidget): full_style_rows = base_style + (extras or []) btn.setStyleSheet( - ( - "QPushButton{" - f"{''.join(full_style_rows)}" - "}" - f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}" - f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}" - f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}" - ) + "QPushButton{" + f"{''.join(full_style_rows)}" + "}" + f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}" + f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}" + f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}" ) btn.setCursor(Qt.CursorShape.PointingHandCursor) @@ -395,59 +412,51 @@ class PreviewPanel(QWidget): self.preview_vid.setMinimumSize(adj_size) # self.preview_img.setMinimumSize(adj_size) - # if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10: - # if type(self.item) == Entry: - # filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}') - # self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True) - - # logging.info(f' Img Aspect Ratio: {self.image_ratio}') - # logging.info(f' Max Button Size: {size}') - # logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}') - # logging.info(f'Final Button Size: {(adj_width, adj_height)}') - # logging.info(f'') - # logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}') - # logging.info(f'Button Size: {self.preview_img.size().toTuple()}') - def place_add_field_button(self): self.scroll_layout.addWidget(self.afb_container) self.scroll_layout.setAlignment( self.afb_container, Qt.AlignmentFlag.AlignHCenter ) - if self.afm.is_connected: - self.afm.done.disconnect() + if self.add_field_modal.is_connected: + self.add_field_modal.done.disconnect() if self.add_field_button.is_connected: self.add_field_button.clicked.disconnect() # self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets())) - self.afm.done.connect( - lambda f: (self.add_field_to_selected(f), self.update_widgets()) + self.add_field_modal.done.connect( + lambda items: ( + self.add_field_to_selected(items), + update_selected_entry(self.driver), + self.update_widgets(), + ) ) - self.afm.is_connected = True - self.add_field_button.clicked.connect(self.afm.show) + self.add_field_modal.is_connected = True + self.add_field_button.clicked.connect(self.add_field_modal.show) - def add_field_to_selected(self, field_id: int): - """Adds an entry field to one or more selected items.""" - added = set() - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added: - self.lib.add_field_to_entry(item_pair[1], field_id) - added.add(item_pair[1]) + def add_field_to_selected(self, field_list: list): + """Add list of entry fields to one or more selected items.""" + logger.info("add_field_to_selected", selected=self.selected, fields=field_list) + for grid_idx in self.selected: + entry = self.driver.frame_content[grid_idx] + for field_item in field_list: + self.lib.add_entry_field_type( + entry.id, + field_id=field_item.data(Qt.ItemDataRole.UserRole), + ) - # def update_widgets(self, item: Union[Entry, Collation, Tag]): - def update_widgets(self): + def update_widgets(self) -> bool: """ - Renders the panel's widgets with the newest data from the Library. + Render the panel widgets with the newest data from the Library. """ - logging.info(f"[ENTRY PANEL] UPDATE WIDGETS ({self.driver.selected})") - self.isOpen = True + logger.info("update_widgets", selected=self.driver.selected) + self.is_open = True # self.tag_callback = tag_callback if tag_callback else None window_title = "" # update list of libraries self.fill_libs_widget(self.libs_layout) - # 0 Selected Items if not self.driver.selected: if self.selected or not self.initialized: self.file_label.setText("No Items Selected") @@ -460,7 +469,7 @@ class PreviewPanel(QWidget): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - ratio: float = self.devicePixelRatio() + ratio = self.devicePixelRatio() self.thumb_renderer.render( time.time(), "", @@ -471,7 +480,7 @@ class PreviewPanel(QWidget): ) if self.preview_img.is_connected: self.preview_img.clicked.disconnect() - for i, c in enumerate(self.containers): + for c in self.containers: c.setHidden(True) self.preview_img.show() self.preview_vid.stop() @@ -479,141 +488,149 @@ class PreviewPanel(QWidget): self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) - # 1 Selected Item - elif len(self.driver.selected) == 1: + # common code + self.initialized = True + self.setWindowTitle(window_title) + self.show() + return True + + # reload entry and fill it into the grid again + # TODO - do this more granular + # TODO - Entry reload is maybe not necessary + for grid_idx in self.driver.selected: + entry = self.driver.frame_content[grid_idx] + _, entries = self.lib.search_library(FilterState(id=entry.id)) + logger.info( + "found item", entries=entries, grid_idx=grid_idx, lookup_id=entry.id + ) + self.driver.frame_content[grid_idx] = entries[0] + + if len(self.driver.selected) == 1: # 1 Selected Entry - if self.driver.selected[0][0] == ItemType.ENTRY: - self.preview_img.show() - self.preview_vid.stop() - self.preview_vid.hide() - item: Entry = self.lib.get_entry(self.driver.selected[0][1]) - # If a new selection is made, update the thumbnail and filepath. - if not self.selected or self.selected != self.driver.selected: - filepath = self.lib.library_dir / item.path / item.filename - self.file_label.setFilePath(filepath) - window_title = str(filepath) - ratio: float = self.devicePixelRatio() - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - ratio, - update_on_ratio_change=True, - ) - self.file_label.setText("\u200b".join(str(filepath))) - self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) + selected_idx = self.driver.selected[0] + item = self.driver.frame_content[selected_idx] - self.preview_img.setContextMenuPolicy( - Qt.ContextMenuPolicy.ActionsContextMenu - ) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + self.preview_img.show() + self.preview_vid.stop() + self.preview_vid.hide() - self.opener = FileOpenerHelper(filepath) - self.open_file_action.triggered.connect(self.opener.open_file) - self.open_explorer_action.triggered.connect( - self.opener.open_explorer - ) + # If a new selection is made, update the thumbnail and filepath. + if not self.selected or self.selected != self.driver.selected: + filepath = self.lib.library_dir / item.path + self.file_label.setFilePath(filepath) + ratio = self.devicePixelRatio() + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + ratio, + update_on_ratio_change=True, + ) + self.file_label.setText("\u200b".join(str(filepath))) + self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - # TODO: Do this somewhere else, this is just here temporarily. - try: - image = None - if filepath.suffix.lower() in IMAGE_TYPES: - image = Image.open(str(filepath)) - elif filepath.suffix.lower() in RAW_IMAGE_TYPES: - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess() - image = Image.new( - "L", (rgb.shape[1], rgb.shape[0]), color="black" - ) - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ): - pass - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(filepath)) - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - if success: - self.preview_img.hide() - self.preview_vid.play( - filepath, QSize(image.width, image.height) + self.preview_img.setContextMenuPolicy( + Qt.ContextMenuPolicy.ActionsContextMenu + ) + self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + + self.opener = FileOpenerHelper(filepath) + self.open_file_action.triggered.connect(self.opener.open_file) + self.open_explorer_action.triggered.connect(self.opener.open_explorer) + + # TODO: Do this somewhere else, this is just here temporarily. + try: + image = None + if filepath.suffix.lower() in IMAGE_TYPES: + image = Image.open(str(filepath)) + elif filepath.suffix.lower() in RAW_IMAGE_TYPES: + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + image = Image.new( + "L", (rgb.shape[1], rgb.shape[0]), color="black" ) - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - self.preview_vid.show() - - # Stats for specific file types are displayed here. - if image and filepath.suffix.lower() in ( - IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES + except ( + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, ): - self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px" + pass + elif filepath.suffix.lower() in VIDEO_TYPES: + video = cv2.VideoCapture(str(filepath)) + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + if success: + self.preview_img.hide() + self.preview_vid.play( + filepath, QSize(image.width, image.height) ) - else: - self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) ) + self.preview_vid.show() - if not filepath.is_file(): - raise FileNotFoundError - - except FileNotFoundError as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}") - logging.info( - f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" + # Stats for specific file types are displayed here. + if image and filepath.suffix.lower() in ( + IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES + ): + self.dimensions_label.setText( + f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px" ) - - except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()}") - logging.info( - f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" - ) - except ( - UnidentifiedImageError, - DecompressionBombError, - ) as e: + else: self.dimensions_label.setText( f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) - logging.info( - f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" - ) - if self.preview_img.is_connected: - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect( - lambda checked=False, filepath=filepath: open_file(filepath) + if not filepath.is_file(): + raise FileNotFoundError + + except (FileNotFoundError, cv2.error) as e: + self.dimensions_label.setText(f"{filepath.suffix.upper()}") + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e ) - self.preview_img.is_connected = True - self.selected = list(self.driver.selected) - for i, f in enumerate(item.fields): - self.write_container(i, f) - # Hide leftover containers - if len(self.containers) > len(item.fields): - for i, c in enumerate(self.containers): - if i > (len(item.fields) - 1): - c.setHidden(True) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + self.dimensions_label.setText( + f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + ) + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e + ) - self.add_field_button.setHidden(False) + if self.preview_img.is_connected: + self.preview_img.clicked.disconnect() + self.preview_img.clicked.connect( + lambda checked=False, pth=filepath: open_file(pth) + ) + self.preview_img.is_connected = True - # 1 Selected Collation - elif self.driver.selected[0][0] == ItemType.COLLATION: - pass + self.selected = self.driver.selected + logger.info( + "rendering item fields", + item=item.id, + fields=[x.type_key for x in item.fields], + ) + for idx, field in enumerate(item.fields): + self.write_container(idx, field) - # 1 Selected Tag - elif self.driver.selected[0][0] == ItemType.TAG_GROUP: - pass + # Hide leftover containers + if len(self.containers) > len(item.fields): + for i, c in enumerate(self.containers): + if i > (len(item.fields) - 1): + c.setHidden(True) + + self.add_field_button.setHidden(False) # Multiple Selected Items elif len(self.driver.selected) > 1: @@ -631,7 +648,7 @@ class PreviewPanel(QWidget): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - ratio: float = self.devicePixelRatio() + ratio = self.devicePixelRatio() self.thumb_renderer.render( time.time(), "", @@ -644,63 +661,38 @@ class PreviewPanel(QWidget): self.preview_img.clicked.disconnect() self.preview_img.is_connected = False - self.common_fields = [] - self.mixed_fields = [] - for i, item_pair in enumerate(self.driver.selected): - if item_pair[0] == ItemType.ENTRY: - item = self.lib.get_entry(item_pair[1]) - if i == 0: - for f in item.fields: - self.common_fields.append(f) - else: - common_to_remove = [] - for f in self.common_fields: - # Common field found (Same ID, identical content) - if f not in item.fields: - common_to_remove.append(f) + # fill shared fields from first item + first_item = self.driver.frame_content[self.driver.selected[0]] + common_fields = [f for f in first_item.fields] + mixed_fields = [] - # Mixed field found (Same ID, different content) - if self.lib.get_field_index_in_entry( - item, self.lib.get_field_attr(f, "id") - ): - # if self.lib.get_field_attr(f, 'type') == ('tag_box'): - # pass - # logging.info(f) - # logging.info(type(f)) - f_stripped = { - self.lib.get_field_attr(f, "id"): None - } - if f_stripped not in self.mixed_fields and ( - f not in self.common_fields - or f in common_to_remove - ): - # and (f not in self.common_fields or f in common_to_remove) - self.mixed_fields.append(f_stripped) - self.common_fields = [ - f for f in self.common_fields if f not in common_to_remove - ] - order: list[int] = ( - [0] - + [1, 2] - + [9, 17, 18, 19, 20] - + [8, 7, 6] - + [4] - + [3, 21] - + [10, 14, 11, 12, 13, 22] - + [5] - ) - self.mixed_fields = sorted( - self.mixed_fields, - key=lambda x: order.index(self.lib.get_field_attr(x, "id")), - ) + # iterate through other items + for grid_idx in self.driver.selected[1:]: + item = self.driver.frame_content[grid_idx] + item_field_types = {f.type_key for f in item.fields} + for f in common_fields[:]: + if f.type_key not in item_field_types: + common_fields.remove(f) + mixed_fields.append(f) + + self.common_fields = common_fields + self.mixed_fields = sorted(mixed_fields, key=lambda x: x.type.position) self.selected = list(self.driver.selected) + logger.info( + "update_widgets common_fields", + common_fields=self.common_fields, + ) for i, f in enumerate(self.common_fields): - logging.info(f"ci:{i}, f:{f}") self.write_container(i, f) + + logger.info( + "update_widgets mixed_fields", + mixed_fields=self.mixed_fields, + start=len(self.common_fields), + ) for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)): - logging.info(f"mi:{i}, f:{f}") - self.write_container(i, f, mixed=True) + self.write_container(i, f, is_mixed=True) # Hide leftover containers if len(self.containers) > len(self.common_fields) + len(self.mixed_fields): @@ -712,60 +704,9 @@ class PreviewPanel(QWidget): self.initialized = True - # # Uninitialized or New Item: - # if not self.item or self.item.id != item.id: - # # logging.info(f'Uninitialized or New Item ({item.id})') - # if type(item) == Entry: - # # New Entry: Render preview and update filename label - # filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}') - # window_title = filepath - # ratio: float = self.devicePixelRatio() - # self.thumb_renderer.render(time.time(), filepath, (512, 512), ratio,update_on_ratio_change=True) - # self.file_label.setText("\u200b".join(filepath)) - - # # TODO: Deal with this later. - # # https://stackoverflow.com/questions/64252654/pyqt5-drag-and-drop-into-system-file-explorer-with-delayed-encoding - # # https://doc.qt.io/qtforpython-5/PySide2/QtCore/QMimeData.html#more - # # drag = QDrag(self.preview_img) - # # mime = QMimeData() - # # mime.setUrls([filepath]) - # # drag.setMimeData(mime) - # # drag.exec_(Qt.DropAction.CopyAction) - - # try: - # self.preview_img.clicked.disconnect() - # except RuntimeError: - # pass - # self.preview_img.clicked.connect( - # lambda checked=False, filepath=filepath: open_file(filepath)) - - # for i, f in enumerate(item.fields): - # self.write_container(item, i, f) - - # self.item = item - - # # try: - # # self.tags_updated.disconnect() - # # except RuntimeError: - # # pass - # # if self.tag_callback: - # # # logging.info(f'[UPDATE CONTAINER] Updating Callback for {item.id}: {self.tag_callback}') - # # self.tags_updated.connect(self.tag_callback) - - # # Initialized, Updating: - # elif self.item and self.item.id == item.id: - # # logging.info(f'Initialized Item, Updating! ({item.id})') - # for i, f in enumerate(item.fields): - # self.write_container(item, i, f) - - # # Hide leftover containers - # if len(self.containers) > len(self.item.fields): - # for i, c in enumerate(self.containers): - # if i > (len(self.item.fields) - 1): - # c.setHidden(True) - self.setWindowTitle(window_title) self.show() + return True def set_tags_updated_slot(self, slot: object): """ @@ -774,143 +715,144 @@ class PreviewPanel(QWidget): if self.is_connected: self.tags_updated.disconnect() - logging.info("[UPDATE CONTAINER] Setting tags updated slot") + logger.info("[UPDATE CONTAINER] Setting tags updated slot") self.tags_updated.connect(slot) self.is_connected = True - # def write_container(self, item:Union[Entry, Collation, Tag], index, field): - def write_container(self, index, field, mixed=False): - """Updates/Creates data for a FieldContainer.""" - # logging.info(f'[ENTRY PANEL] WRITE CONTAINER') + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): + """Update/Create data for a FieldContainer. + + :param is_mixed: Relevant when multiple items are selected. If True, field is not present in all selected items + """ # Remove 'Add Field' button from scroll_layout, to be re-added later. self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() - container: FieldContainer = None if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) self.scroll_layout.addWidget(container) else: container = self.containers[index] - # container.inner_layout.removeItem(container.inner_layout.itemAt(1)) - # container.setHidden(False) - if self.lib.get_field_attr(field, "type") == "tag_box": - # logging.info(f'WRITING TAGBOX FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, "name")) - # container.set_editable(False) + + 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) - title = f"{self.lib.get_field_attr(field, 'name')} (Tag Box)" - if not mixed: - item = self.lib.get_entry( - self.selected[0][1] - ) # TODO TODO TODO: TEMPORARY - if type(container.get_inner_widget()) == TagBoxWidget: - inner_container: TagBoxWidget = container.get_inner_widget() - inner_container.set_item(item) - inner_container.set_tags(self.lib.get_field_attr(field, "content")) + title = f"{field.type.name} (Tag Box)" + + if not is_mixed: + inner_container = container.get_inner_widget() + if isinstance(inner_container, TagBoxWidget): + inner_container.set_field(field) + inner_container.set_tags(list(field.tags)) + try: inner_container.updated.disconnect() except RuntimeError: - pass - # inner_container.updated.connect(lambda f=self.filepath, i=item: self.write_container(item, index, field)) + logger.error("Failed to disconnect inner_container.updated") + else: + logger.info( + "inner_container is not instance of TagBoxWidget", + container=inner_container, + ) inner_container = TagBoxWidget( - item, + field, title, - index, - self.lib, - self.lib.get_field_attr(field, "content"), self.driver, ) container.set_inner_widget(inner_container) - inner_container.field = field + + # inner_container.field = field inner_container.updated.connect( lambda: ( self.write_container(index, field), - self.tags_updated.emit(), + self.update_widgets(), ) ) - # if type(item) == Entry: # NOTE: Tag Boxes have no Edit Button (But will when you can convert field types) - # f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + update_selected_entry(self.driver), + # reload entry and its fields + self.update_widgets(), + ), + ) ) - container.set_copy_callback(None) - container.set_edit_callback(None) else: text = "Mixed Data" - title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)" + title = f"{field.type.name} (Wacky Tag Box)" inner_container = TextWidget(title, text) container.set_inner_widget(inner_container) - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) self.tags_updated.emit() # self.dynamic_widgets.append(inner_container) - elif self.lib.get_field_attr(field, "type") in "text_line": - # logging.info(f'WRITING TEXTLINE FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, "name")) - # container.set_editable(True) + elif field.type.type == FieldTypeEnum.TEXT_LINE: + container.set_title(field.type.name) container.set_inline(False) - # Normalize line endings in any text content. - if not mixed: - text = self.lib.get_field_attr(field, "content").replace("\r", "\n") - else: - text = "Mixed Data" - title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - if not mixed: - modal = PanelModal( - EditTextLine(self.lib.get_field_attr(field, "content")), - title=title, - window_title=f'Edit {self.lib.get_field_attr(field, "name")}', - save_callback=( - lambda content: ( - self.update_field(field, content), - self.update_widgets(), - ) - ), - ) - container.set_edit_callback(modal.show) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) - container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) - ) - container.set_copy_callback(None) - else: - container.set_edit_callback(None) - container.set_copy_callback(None) - container.set_remove_callback(None) - # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - elif self.lib.get_field_attr(field, "type") in "text_box": - # logging.info(f'WRITING TEXTBOX FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, "name")) + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, (str, type(None))) + text = field.value or "" + else: + text = "Mixed Data" + + title = f"{field.type.name} ({field.type.type.value})" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + if not is_mixed: + modal = PanelModal( + EditTextLine(field.value), + title=title, + window_title=f"Edit {field.type.type.value}", + save_callback=( + lambda content: ( + self.update_field(field, content), + self.update_widgets(), + ) + ), + ) + if "pytest" in sys.modules: + # for better testability + container.modal = modal # type: ignore + + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.type.value), + callback=lambda: ( + self.remove_field(field), + self.update_widgets(), + ), + ) + ) + + elif field.type.type == FieldTypeEnum.TEXT_BOX: + container.set_title(field.type.name) # container.set_editable(True) container.set_inline(False) # Normalize line endings in any text content. - if not mixed: - text = self.lib.get_field_attr(field, "content").replace("\r", "\n") + if not is_mixed: + assert isinstance(field.value, (str, type(None))) + text = (field.value or "").replace("\r", "\n") else: text = "Mixed Data" - title = f"{self.lib.get_field_attr(field, 'name')} (Text Box)" + title = f"{field.type.name} (Text Box)" inner_container = TextWidget(title, text) container.set_inner_widget(inner_container) - # if type(item) == Entry: - if not mixed: - container.set_copy_callback(None) + if not is_mixed: modal = PanelModal( - EditTextBox(self.lib.get_field_attr(field, "content")), + EditTextBox(field.value), title=title, - window_title=f'Edit {self.lib.get_field_attr(field, "name")}', + window_title=f"Edit {field.type.name}", save_callback=( lambda content: ( self.update_field(field, content), @@ -919,140 +861,109 @@ class PreviewPanel(QWidget): ), ) container.set_edit_callback(modal.show) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + self.update_widgets(), + ), + ) ) - else: - container.set_edit_callback(None) - container.set_copy_callback(None) - container.set_remove_callback(None) - elif self.lib.get_field_attr(field, "type") == "collation": - # logging.info(f'WRITING COLLATION FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, "name")) - # container.set_editable(True) - container.set_inline(False) - collation = self.lib.get_collation( - self.lib.get_field_attr(field, "content") - ) - title = f"{self.lib.get_field_attr(field, 'name')} (Collation)" - text = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)" - if len(self.selected) == 1: - text += f" - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - container.set_copy_callback(None) - # container.set_edit_callback(None) - # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) - container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) - ) - elif self.lib.get_field_attr(field, "type") == "datetime": - # logging.info(f'WRITING DATETIME FOR ITEM {item.id}') - if not mixed: + + elif field.type.type == FieldTypeEnum.DATETIME: + if not is_mixed: try: - container.set_title(self.lib.get_field_attr(field, "name")) + container.set_title(field.type.name) # container.set_editable(False) container.set_inline(False) # TODO: Localize this and/or add preferences. - date = dt.strptime( - self.lib.get_field_attr(field, "content"), "%Y-%m-%d %H:%M:%S" - ) - title = f"{self.lib.get_field_attr(field, 'name')} (Date)" + date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S") + title = f"{field.type.name} (Date)" inner_container = TextWidget(title, date.strftime("%D - %r")) container.set_inner_widget(inner_container) - except: - container.set_title(self.lib.get_field_attr(field, "name")) + except Exception: + container.set_title(field.type.name) # container.set_editable(False) container.set_inline(False) - title = f"{self.lib.get_field_attr(field, 'name')} (Date) (Unknown Format)" - inner_container = TextWidget( - title, str(self.lib.get_field_attr(field, "content")) - ) + title = f"{field.type.name} (Date) (Unknown Format)" + inner_container = TextWidget(title, str(field.value)) + container.set_inner_widget(inner_container) + # if type(item) == Entry: - container.set_copy_callback(None) - container.set_edit_callback(None) # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + self.update_widgets(), + ), + ) ) else: text = "Mixed Data" - title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Date)" + title = f"{field.type.name} (Wacky Date)" inner_container = TextWidget(title, text) container.set_inner_widget(inner_container) - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) else: - # logging.info(f'[ENTRY PANEL] Unknown Type: {self.lib.get_field_attr(field, "type")}') - container.set_title(self.lib.get_field_attr(field, "name")) + logger.warning("write_container - unknown field", field=field) + container.set_title(field.type.name) # container.set_editable(False) container.set_inline(False) - title = f"{self.lib.get_field_attr(field, 'name')} (Unknown Field Type)" - inner_container = TextWidget( - title, str(self.lib.get_field_attr(field, "content")) - ) + title = f"{field.type.name} (Unknown Field Type)" + inner_container = TextWidget(title, field.type.name) container.set_inner_widget(inner_container) # if type(item) == Entry: - container.set_copy_callback(None) - container.set_edit_callback(None) # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - prompt = f'Are you sure you want to remove this "{self.lib.get_field_attr(field, "name")}" field?' - callback = lambda: (self.remove_field(field), self.update_widgets()) - # callback = lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets()) container.set_remove_callback( - lambda: self.remove_message_box(prompt=prompt, callback=callback) + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + self.update_widgets(), + ), + ) ) + container.edit_button.setHidden(True) container.setHidden(False) self.place_add_field_button() - def remove_field(self, field: dict): - """Removes a field from all selected Entries, given a field object.""" - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY: - entry = self.lib.get_entry(item_pair[1]) - try: - index = entry.fields.index(field) - updated_badges = False - if 8 in entry.fields[index].keys() and ( - 1 in entry.fields[index][8] or 0 in entry.fields[index][8] - ): - updated_badges = True - # TODO: Create a proper Library/Entry method to manage fields. - entry.fields.pop(index) - if updated_badges: - self.driver.update_badges() - except ValueError: - logging.info( - f"[PREVIEW PANEL][ERROR?] Tried to remove field from Entry ({entry.id}) that never had it" - ) - pass + def remove_field(self, field: BaseField): + """Remove a field from all selected Entries.""" + logger.info("removing field", field=field, selected=self.selected) + entry_ids = [] - def update_field(self, field: dict, content): - """Removes a field from all selected Entries, given a field object.""" - field = dict(field) - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY: - entry = self.lib.get_entry(item_pair[1]) - try: - logging.info(field) - index = entry.fields.index(field) - self.lib.update_entry_field(entry.id, index, content, "replace") - except ValueError: - logging.info( - f"[PREVIEW PANEL][ERROR] Tried to update field from Entry ({entry.id}) that never had it" - ) - pass + for grid_idx in self.selected: + entry = self.driver.frame_content[grid_idx] + entry_ids.append(entry.id) - def remove_message_box(self, prompt: str, callback: typing.Callable) -> None: + self.lib.remove_entry_field(field, entry_ids) + + # if the field is meta tags, update the badges + if field.type_key == _FieldID.TAGS_META.value: + self.driver.update_badges(self.selected) + + def update_field(self, field: BaseField, content: str) -> None: + """Update a field in all selected Entries, given a field object.""" + assert isinstance( + field, (TextField, DatetimeField, TagBoxField) + ), f"instance: {type(field)}" + + entry_ids = [] + for grid_idx in self.selected: + entry = self.driver.frame_content[grid_idx] + entry_ids.append(entry.id) + + assert entry_ids, "No entries selected" + self.lib.update_entry_field( + entry_ids, + field, + content, + ) + + def remove_message_box(self, prompt: str, callback: Callable) -> None: remove_mb = QMessageBox() remove_mb.setText(prompt) remove_mb.setWindowTitle("Remove Field") @@ -1060,13 +971,11 @@ class PreviewPanel(QWidget): cancel_button = remove_mb.addButton( "&Cancel", QMessageBox.ButtonRole.DestructiveRole ) - remove_button = remove_mb.addButton( - "&Remove", QMessageBox.ButtonRole.RejectRole - ) + remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel) remove_mb.setDefaultButton(cancel_button) remove_mb.setEscapeButton(cancel_button) result = remove_mb.exec_() # logging.info(result) - if result == 3: + if result == 3: # TODO - what is this magic number? callback() diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index 739369dc..3cbb21f7 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -4,7 +4,6 @@ import math -import os from types import FunctionType from pathlib import Path @@ -13,15 +12,10 @@ from PySide6.QtCore import Signal, Qt, QEvent from PySide6.QtGui import QEnterEvent, QAction from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton -from src.core.library import Library, Tag +from src.core.library import Tag from src.core.palette import ColorType, get_tag_color -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" - - class TagWidget(QWidget): edit_icon_128: Image.Image = Image.open( str(Path(__file__).parents[3] / "resources/qt/images/edit_icon_128.png") @@ -33,7 +27,6 @@ class TagWidget(QWidget): def __init__( self, - library: Library, tag: Tag, has_edit: bool, has_remove: bool, @@ -42,10 +35,9 @@ class TagWidget(QWidget): on_edit_callback: FunctionType = None, ) -> None: super().__init__() - self.lib = library self.tag = tag - self.has_edit: bool = has_edit - self.has_remove: bool = has_remove + self.has_edit = has_edit + self.has_remove = has_remove # self.bg_label = QLabel() # self.setStyleSheet('background-color:blue;') @@ -57,7 +49,7 @@ class TagWidget(QWidget): self.bg_button = QPushButton(self) self.bg_button.setFlat(True) - self.bg_button.setText(tag.display_name(self.lib).replace("&", "&&")) + self.bg_button.setText(tag.name) if has_edit: edit_action = QAction("Edit", self) edit_action.triggered.connect(on_edit_callback) @@ -65,13 +57,8 @@ class TagWidget(QWidget): self.bg_button.addAction(edit_action) # if on_click_callback: self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - # if has_remove: - # remove_action = QAction('Remove', self) - # # remove_action.triggered.connect(on_remove_callback) - # remove_action.triggered.connect(self.on_remove.emit()) - # self.bg_button.addAction(remove_action) + search_for_tag_action = QAction("Search for Tag", self) - # search_for_tag_action.triggered.connect(on_click_callback) search_for_tag_action.triggered.connect(self.on_click.emit) self.bg_button.addAction(search_for_tag_action) add_to_search_action = QAction("Add to Search", self) @@ -106,7 +93,7 @@ class TagWidget(QWidget): f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};" f"border-radius: 6px;" f"border-style:solid;" - f"border-width: {math.ceil(1*self.devicePixelRatio())}px;" + f"border-width: {math.ceil(self.devicePixelRatio())}px;" # f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};' # f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' # f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' @@ -167,35 +154,6 @@ class TagWidget(QWidget): # self.remove_button.clicked.connect(on_remove_callback) self.remove_button.clicked.connect(self.on_remove.emit) - # NOTE: No more edit button! Just make it a right-click option. - # self.edit_button = QPushButton(self) - # self.edit_button.setFlat(True) - # self.edit_button.setText('Edit') - # self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) - # self.edit_button.setIconSize(QSize(14,14)) - # self.edit_button.setHidden(True) - # self.edit_button.setStyleSheet(f'color: {color};' - # f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # f'font-weight: 600;' - # # f"border-color:{'black' if color not in [ - # # 'black', 'gray', 'dark gray', - # # 'cool gray', 'warm gray', 'blue', - # # 'purple', 'violet'] else 'white'};" - # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' - # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' - # f'border-radius: 4px;' - # # f'border-style:solid;' - # # f'border-width:1px;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - # f'padding-bottom: 3px;' - # f'padding-left: 4px;' - # f'font-size: 14px') - # self.edit_button.setMinimumSize(18,18) - # # self.edit_button.setMaximumSize(18,18) - # self.inner_layout.addWidget(self.edit_button) if has_remove: self.inner_layout.addWidget(self.remove_button) @@ -209,32 +167,6 @@ class TagWidget(QWidget): # self.setMinimumSize(50,20) - # def set_name(self, name:str): - # self.bg_label.setText(str) - - # def on_remove(self): - # if self.item and self.item[0] == ItemType.ENTRY: - # if self.field_index >= 0: - # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index) - # else: - # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id) - - # def set_click(self, callback): - # try: - # self.bg_button.clicked.disconnect() - # except RuntimeError: - # pass - # if callback: - # self.bg_button.clicked.connect(callback) - - # def set_click(self, function): - # try: - # self.bg.clicked.disconnect() - # except RuntimeError: - # pass - # # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath)) - # # self.bg.clicked.connect(function) - def enterEvent(self, event: QEnterEvent) -> None: if self.has_remove: self.remove_button.setHidden(False) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 06b8b1fe..567721fa 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -3,15 +3,17 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import math import typing +import structlog from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import QPushButton from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED -from src.core.library import Library, Tag +from src.core.library import Entry, Tag +from src.core.library.alchemy.enums import FilterState +from src.core.library.alchemy.fields import TagBoxField from src.qt.flowlayout import FlowLayout from src.qt.widgets.fields import FieldWidget from src.qt.widgets.tag import TagWidget @@ -19,30 +21,28 @@ from src.qt.widgets.panel import PanelModal from src.qt.modals.build_tag import BuildTagPanel from src.qt.modals.tag_search import TagSearchPanel -# Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) + class TagBoxWidget(FieldWidget): updated = Signal() + error_occurred = Signal(Exception) def __init__( self, - item, - title, - field_index, - library: Library, - tags: list[int], + field: TagBoxField, + title: str, driver: "QtDriver", ) -> None: super().__init__(title) - # QObject.__init__(self) - self.item = item - self.lib = library + + assert isinstance(field, TagBoxField), f"field is {type(field)}" + + self.field = field self.driver = driver # Used for creating tag click callbacks that search entries for that tag. - self.field_index = field_index - self.tags: list[int] = tags self.setObjectName("tagBox") self.base_layout = FlowLayout() self.base_layout.setGridEfficiency(False) @@ -62,11 +62,8 @@ class TagBoxWidget(FieldWidget): f"border-color: #333333;" f"border-radius: 6px;" f"border-style:solid;" - f"border-width:{math.ceil(1*self.devicePixelRatio())}px;" - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' + f"border-width:{math.ceil(self.devicePixelRatio())}px;" f"padding-bottom: 5px;" - # f'padding-left: 4px;' f"font-size: 20px;" f"}}" f"QPushButton::hover" @@ -75,46 +72,44 @@ class TagBoxWidget(FieldWidget): f"background: #555555;" f"}}" ) - tsp = TagSearchPanel(self.lib) + tsp = TagSearchPanel(self.driver.lib) tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) self.add_modal = PanelModal(tsp, title, "Add Tags") self.add_button.clicked.connect( - lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore + lambda: ( + tsp.update_tags(), + self.add_modal.show(), + ) ) - self.set_tags(tags) - # self.add_button.setHidden(True) + self.set_tags(field.tags) - def set_item(self, item): - self.item = item + def set_field(self, field: TagBoxField): + self.field = field - def set_tags(self, tags: list[int]): - logging.info(f"[TAG BOX WIDGET] SET TAGS: T:{tags} for E:{self.item.id}") + def set_tags(self, tags: typing.Iterable[Tag]): is_recycled = False - if self.base_layout.itemAt(0): - # logging.info(type(self.base_layout.itemAt(0).widget())) - while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): - # logging.info(f"I'm deleting { self.base_layout.itemAt(0).widget()}") - self.base_layout.takeAt(0).widget().deleteLater() + 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: - # TODO: Remove space from the special search here (tag_id:x) once that system is finalized. - # tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True, - # on_remove_callback=lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit()), - # on_click_callback=lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)), - # on_edit_callback=lambda checked=False, t=tag: (self.edit_tag(t)) - # ) - tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True) - tw.on_click.connect( - lambda checked=False, q=f"tag_id: {tag}": ( - self.driver.main_window.searchField.setText(q), - self.driver.filter_items(q), + tag_widget = TagWidget(tag, True, True) + tag_widget.on_click.connect( + lambda tag_id=tag.id: ( + self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), + self.driver.filter_items(FilterState(tag_id=tag_id)), ) ) - tw.on_remove.connect(lambda checked=False, t=tag: (self.remove_tag(t))) - tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t))) - self.base_layout.addWidget(tw) - self.tags = tags + + tag_widget.on_remove.connect( + lambda tag_id=tag.id: ( + self.remove_tag(tag_id), + self.driver.preview_panel.update_widgets(), + ) + ) + tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) + self.base_layout.addWidget(tag_widget) # Move or add the '+' button. if is_recycled: @@ -127,62 +122,59 @@ class TagBoxWidget(FieldWidget): if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1): self.base_layout.update() - def edit_tag(self, tag_id: int): - btp = BuildTagPanel(self.lib, tag_id) - # btp.on_edit.connect(lambda x: self.edit_tag_callback(x)) + def edit_tag(self, tag: Tag): + assert isinstance(tag, Tag), f"tag is {type(tag)}" + build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) + self.edit_modal = PanelModal( - btp, - self.lib.get_tag(tag_id).display_name(self.lib), + build_tag_panel, + tag.name, # TODO - display name including subtags "Edit Tag", - done_callback=(self.driver.preview_panel.update_widgets), + done_callback=self.driver.preview_panel.update_widgets, has_save=True, ) # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) - self.edit_modal.saved.connect(lambda: self.lib.update_tag(btp.build_tag())) + # TODO - this was update_tag() + self.edit_modal.saved.connect( + lambda: self.driver.lib.update_tag( + build_tag_panel.build_tag(), + subtag_ids=build_tag_panel.subtags, + ) + ) # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) self.edit_modal.show() def add_tag_callback(self, tag_id: int): - # self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True)) - # self.tags.append(tag) - logging.info( - f"[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}" - ) - logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}") - id: int = list(self.field.keys())[0] # type: ignore - for x in self.driver.selected: - self.driver.lib.get_entry(x[1]).add_tag( - self.driver.lib, tag_id, field_id=id, field_index=-1 - ) - self.updated.emit() + logger.info("add_tag_callback", tag_id=tag_id, selected=self.driver.selected) + + tag = self.driver.lib.get_tag(tag_id=tag_id) + for idx in self.driver.selected: + entry: Entry = self.driver.frame_content[idx] + + if not self.driver.lib.add_field_tag(entry, tag, self.field.type_key): + # TODO - add some visible error + self.error_occurred.emit(Exception("Failed to add tag")) + + self.updated.emit() + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): self.driver.update_badges() - # if type((x[0]) == ThumbButton): - # # TODO: Remove space from the special search here (tag_id:x) once that system is finalized. - # logging.info(f'I want to add tag ID {tag_id} to entry {self.item.filename}') - # self.updated.emit() - # if tag_id not in self.tags: - # self.tags.append(tag_id) - # self.set_tags(self.tags) - # elif type((x[0]) == ThumbButton): - def edit_tag_callback(self, tag: Tag): - self.lib.update_tag(tag) + self.driver.lib.update_tag(tag) def remove_tag(self, tag_id: int): - logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}") - id: int = list(self.field.keys())[0] # type: ignore - for x in self.driver.selected: - index = self.driver.lib.get_field_index_in_entry( - self.driver.lib.get_entry(x[1]), id - ) - self.driver.lib.get_entry(x[1]).remove_tag( - self.driver.lib, tag_id, field_index=index[0] - ) + logger.info( + "remove_tag", + selected=self.driver.selected, + field_type=self.field.type, + ) + + for grid_idx in self.driver.selected: + entry = self.driver.frame_content[grid_idx] + self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) + self.updated.emit() + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): self.driver.update_badges() - - # def show_add_button(self, value:bool): - # self.add_button.setHidden(not value) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 1e6a3ad1..e0045902 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import math from pathlib import Path @@ -22,6 +21,7 @@ from PIL import ( from PIL.Image import DecompressionBombError from PySide6.QtCore import QObject, Signal, QSize from PySide6.QtGui import QPixmap + from src.qt.helpers.gradient import four_corner_gradient_background from src.core.constants import ( PLAINTEXT_TYPES, @@ -29,15 +29,15 @@ from src.core.constants import ( IMAGE_TYPES, RAW_IMAGE_TYPES, ) +import structlog + from src.core.utils.encoding import detect_char_encoding ImageFile.LOAD_TRUNCATED_IMAGES = True -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" -logging.basicConfig(format="%(message)s", level=logging.INFO) +logger = structlog.get_logger(__name__) + register_heif_opener() register_avif_opener() @@ -95,7 +95,10 @@ class ThumbRenderer(QObject): gradient=False, update_on_ratio_change=False, ): - """Internal renderer. Renders an entry/element thumbnail for the GUI.""" + """Internal renderer. Render an entry/element thumbnail for the GUI.""" + + logger.debug("rendering thumbnail", path=filepath) + image: Image.Image = None pixmap: QPixmap = None final: Image.Image = None @@ -133,8 +136,8 @@ class ThumbRenderer(QObject): image = ImageOps.exif_transpose(image) except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e ) elif _filepath.suffix.lower() in RAW_IMAGE_TYPES: @@ -148,15 +151,16 @@ class ThumbRenderer(QObject): decoder_name="raw", ) except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e ) + except ( rawpy._rawpy.LibRawIOError, rawpy._rawpy.LibRawFileUnsupportedError, ) as e: - logging.info( - f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {_filepath.name} ({type(e).__name__})" + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e ) # Videos ======================================================= @@ -179,7 +183,7 @@ class ThumbRenderer(QObject): # Plain Text =================================================== elif _filepath.suffix.lower() in PLAINTEXT_TYPES: encoding = detect_char_encoding(_filepath) - with open(_filepath, "r", encoding=encoding) as text_file: + with open(_filepath, encoding=encoding) as text_file: text = text_file.read(256) bg = Image.new("RGB", (256, 256), color="#1e1e1e") draw = ImageDraw.Draw(bg) @@ -268,9 +272,10 @@ class ThumbRenderer(QObject): UnicodeDecodeError, ) as e: if e is not UnicodeDecodeError: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + logger.error( + "Couldn't Render thumbnail", filepath=filepath, error=e ) + if update_on_ratio_change: self.updated_ratio.emit(1) final = ThumbRenderer.thumb_broken_512.resize( diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 6bb86099..9fc65604 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -3,7 +3,6 @@ import logging -from pathlib import Path import typing from PySide6.QtCore import ( @@ -122,7 +121,7 @@ class VideoPlayer(QGraphicsView): autoplay_action.setCheckable(True) self.addAction(autoplay_action) autoplay_action.setChecked( - self.driver.settings.value(SettingItems.AUTOPLAY, True, bool) # type: ignore + bool(self.driver.settings.value(SettingItems.AUTOPLAY, True, type=bool)) ) autoplay_action.triggered.connect(lambda: self.toggleAutoplay()) self.autoplay = autoplay_action @@ -176,37 +175,30 @@ class VideoPlayer(QGraphicsView): def eventFilter(self, obj: QObject, event: QEvent) -> bool: # This chunk of code is for the video controls. if ( - obj == self.play_pause - and event.type() == QEvent.Type.MouseButtonPress + event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton # type: ignore ): - if self.player.hasVideo(): + if obj == self.play_pause and self.player.hasVideo(): self.pauseToggle() - - if ( - obj == self.mute_button - and event.type() == QEvent.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton # type: ignore - ): - if self.player.hasAudio(): + elif obj == self.mute_button and self.player.hasAudio(): self.muteToggle() - if ( - obj == self.video_preview - and event.type() == QEvent.Type.GraphicsSceneHoverEnter - or event.type() == QEvent.Type.HoverEnter - ): - if self.video_preview.isUnderMouse(): - self.underMouse() - self.hover_fix_timer.start(10) - elif ( - obj == self.video_preview - and event.type() == QEvent.Type.GraphicsSceneHoverLeave - or event.type() == QEvent.Type.HoverLeave - ): - if not self.video_preview.isUnderMouse(): + elif obj == self.video_preview: + if event.type() in ( + QEvent.Type.GraphicsSceneHoverEnter, + QEvent.Type.HoverEnter, + ): + if self.video_preview.isUnderMouse(): + self.underMouse() + self.hover_fix_timer.start(10) + elif ( + event.type() + in (QEvent.Type.GraphicsSceneHoverLeave, QEvent.Type.HoverLeave) + and not self.video_preview.isUnderMouse() + ): self.hover_fix_timer.stop() self.releaseMouse() + return super().eventFilter(obj, event) def checkIfStillHovered(self) -> None: @@ -334,14 +326,13 @@ class VideoPlayer(QGraphicsView): int(self.video_preview.size().height()), ) ) - return class VideoPreview(QGraphicsVideoItem): def boundingRect(self): return QRectF(0, 0, self.size().width(), self.size().height()) - def paint(self, painter, option, widget): + def paint(self, painter, option, widget=None) -> None: # painter.brush().setColor(QColor(0, 0, 0, 255)) # You can set any shape you want here. # RoundedRect is the standard rectangle with rounded corners. diff --git a/tagstudio/tag_studio.py b/tagstudio/tag_studio.py old mode 100644 new mode 100755 index 1861474f..a6b47b6b --- a/tagstudio/tag_studio.py +++ b/tagstudio/tag_studio.py @@ -1,16 +1,23 @@ +#!/usr/bin/env python # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio """TagStudio launcher.""" -from src.core.ts_core import TagStudioCore -from src.cli.ts_cli import CliDriver # type: ignore +import structlog +import logging + from src.qt.ts_qt import QtDriver import argparse import traceback +structlog.configure( + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), +) + + def main(): # appid = "cyanvoxel.tagstudio.9" # ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) @@ -48,27 +55,11 @@ def main(): type=str, help="User interface option for TagStudio. Options: qt, cli (Default: qt)", ) - parser.add_argument( - "--ci", - action=argparse.BooleanOptionalAction, - help="Exit the application after checking it starts without any problem. Meant for CI check.", - ) args = parser.parse_args() + from src.core.library import alchemy as backend - core = TagStudioCore() # The TagStudio Core instance. UI agnostic. - driver = None # The UI driver instance. - ui_name: str = "unknown" # Display name for the UI, used in logs. - - # Driver selection based on parameters. - if args.ui and args.ui == "qt": - driver = QtDriver(core, args) - ui_name = "Qt" - elif args.ui and args.ui == "cli": - driver = CliDriver(core, args) - ui_name = "CLI" - else: - driver = QtDriver(core, args) - ui_name = "Qt" + driver = QtDriver(backend, args) + ui_name = "Qt" # Run the chosen frontend driver. try: diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index 2c5cd225..3a431b82 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -1,42 +1,131 @@ import sys import pathlib +from tempfile import TemporaryDirectory +from unittest.mock import patch, Mock import pytest -from syrupy.extensions.json import JSONSnapshotExtension CWD = pathlib.Path(__file__).parent - +# this needs to be above `src` imports sys.path.insert(0, str(CWD.parent)) -from src.core.library import Tag, Library +from src.core.library import Library, Tag, Entry +from src.core.library.alchemy.enums import TagColor +from src.core.library.alchemy.fields import TagBoxField, _FieldID +from src.core.library import alchemy as backend +from src.qt.ts_qt import QtDriver @pytest.fixture -def test_tag(): - yield Tag( - id=1, - name="Tag Name", - shorthand="TN", - aliases=["First A", "Second A"], - subtags_ids=[2, 3, 4], - color="", - ) +def cwd(): + return CWD @pytest.fixture -def test_library(): - lib_dir = CWD / "fixtures" / "library" +def library(request): + # when no param is passed, use the default + library_path = "/tmp/" + if hasattr(request, "param"): + if isinstance(request.param, TemporaryDirectory): + library_path = request.param.name + else: + library_path = request.param lib = Library() - ret_code = lib.open_library(lib_dir) - assert ret_code == 1 - # create files for the entries - for entry in lib.entries: - (lib_dir / entry.filename).touch() + lib.open_library(library_path, ":memory:") + assert lib.folder + + tag = Tag( + name="foo", + color=TagColor.RED, + ) + assert lib.add_tag(tag) + + subtag = Tag( + name="subbar", + color=TagColor.YELLOW, + ) + + tag2 = Tag( + name="bar", + color=TagColor.BLUE, + subtags={subtag}, + ) + + # default item with deterministic name + entry = Entry( + folder=lib.folder, + path=pathlib.Path("foo.txt"), + fields=lib.default_fields, + ) + + entry.tag_box_fields = [ + TagBoxField(type_key=_FieldID.TAGS.name, tags={tag}, position=0), + TagBoxField( + type_key=_FieldID.TAGS_META.name, + position=0, + ), + ] + + entry2 = Entry( + folder=lib.folder, + path=pathlib.Path("one/two/bar.md"), + fields=lib.default_fields, + ) + entry2.tag_box_fields = [ + TagBoxField( + tags={tag2}, + type_key=_FieldID.TAGS_META.name, + position=0, + ), + ] + + assert lib.add_entries([entry, entry2]) + assert len(lib.tags) == 5 yield lib @pytest.fixture -def snapshot_json(snapshot): - return snapshot.with_defaults(extension_class=JSONSnapshotExtension) +def entry_min(library): + yield next(library.get_entries()) + + +@pytest.fixture +def entry_full(library): + yield next(library.get_entries(with_joins=True)) + + +@pytest.fixture +def qt_driver(qtbot, library): + with TemporaryDirectory() as tmp_dir: + + class Args: + config_file = pathlib.Path(tmp_dir) / "tagstudio.ini" + open = pathlib.Path(tmp_dir) + ci = True + + # patch CustomRunnable + + with patch("src.qt.ts_qt.Consumer"), patch("src.qt.ts_qt.CustomRunnable"): + driver = QtDriver(backend, Args()) + + driver.main_window = Mock() + driver.preview_panel = Mock() + driver.flow_container = Mock() + driver.item_thumbs = [] + + driver.lib = library + # TODO - downsize this method and use it + # driver.start() + driver.frame_content = list(library.get_entries()) + yield driver + + +@pytest.fixture +def generate_tag(): + def inner(name, **kwargs): + params = dict(name=name, color=TagColor.RED) | kwargs + return Tag(**params) + + yield inner diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json deleted file mode 100644 index fe51488c..00000000 --- a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json deleted file mode 100644 index e4e6902c..00000000 --- a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json +++ /dev/null @@ -1,6 +0,0 @@ -[ - [ - "", - 2 - ] -] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json deleted file mode 100644 index 920ff495..00000000 --- a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json +++ /dev/null @@ -1,6 +0,0 @@ -[ - [ - "", - 1 - ] -] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json b/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json deleted file mode 100644 index a576676f..00000000 --- a/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "{'id': 1, 'filename': 'foo.txt', 'path': '.', 'fields': [{6: [1001]}]}", - "{'id': 2, 'filename': 'bar.txt', 'path': '.', 'fields': [{6: [1000]}]}" -] diff --git a/tagstudio/tests/core/test_lib.py b/tagstudio/tests/core/test_lib.py deleted file mode 100644 index 997598f2..00000000 --- a/tagstudio/tests/core/test_lib.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - - -def test_open_library(test_library, snapshot_json): - assert test_library.entries == snapshot_json - - -@pytest.mark.parametrize( - ["query"], - [ - ("First",), - ("Second",), - ("--nomatch--",), - ], -) -def test_library_search(test_library, query, snapshot_json): - res = test_library.search_library(query) - assert res == snapshot_json diff --git a/tagstudio/tests/core/test_tags.py b/tagstudio/tests/core/test_tags.py deleted file mode 100644 index 43cde427..00000000 --- a/tagstudio/tests/core/test_tags.py +++ /dev/null @@ -1,8 +0,0 @@ -def test_subtag(test_tag): - test_tag.remove_subtag(2) - test_tag.remove_subtag(2) - - test_tag.add_subtag(5) - # repeated add should not add the subtag - test_tag.add_subtag(5) - assert test_tag.subtag_ids == [3, 4, 5] diff --git a/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json b/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json deleted file mode 100644 index eeab9fd6..00000000 --- a/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "ts-version": "9.3.1", - "ext_list": [ - ".json", - ".xmp", - ".aae" - ], - "is_exclude_list": true, - "tags": [ - { - "id": 0, - "name": "Archived", - "aliases": [ - "Archive" - ], - "color": "Red" - }, - { - "id": 1, - "name": "Favorite", - "aliases": [ - "Favorited", - "Favorites" - ], - "color": "Yellow" - }, - { - "id": 1000, - "name": "first", - "shorthand": "first", - "color": "magenta" - }, - { - "id": 1001, - "name": "second", - "shorthand": "second", - "color": "blue" - } - ], - "collations": [], - "fields": [], - "macros": [], - "entries": [ - { - "id": 1, - "filename": "foo.txt", - "path": ".", - "fields": [ - { - "6": [ - 1001 - ] - } - ] - }, - { - "id": 2, - "filename": "bar.txt", - "path": ".", - "fields": [ - { - "6": [ - 1000 - ] - } - ] - } - ] -} diff --git a/tagstudio/tests/fixtures/result.dupeguru b/tagstudio/tests/fixtures/result.dupeguru new file mode 100644 index 00000000..31341bb8 --- /dev/null +++ b/tagstudio/tests/fixtures/result.dupeguru @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tagstudio/tests/fixtures/sidecar_newgrounds.json b/tagstudio/tests/fixtures/sidecar_newgrounds.json new file mode 100644 index 00000000..d7271732 --- /dev/null +++ b/tagstudio/tests/fixtures/sidecar_newgrounds.json @@ -0,0 +1,10 @@ +{ + "tags": [ + "ng_tag", + "ng_tag2" + ], + "date": "2024-01-02", + "description": "NG description", + "user": "NG artist", + "post_url": "https://ng.com" +} diff --git a/tagstudio/tests/macros/test_dupe_entries.py b/tagstudio/tests/macros/test_dupe_entries.py new file mode 100644 index 00000000..2272e1fc --- /dev/null +++ b/tagstudio/tests/macros/test_dupe_entries.py @@ -0,0 +1,35 @@ +import pathlib + +from src.core.library import Entry +from src.core.utils.dupe_files import DupeRegistry + +CWD = pathlib.Path(__file__).parent + + +def test_refresh_dupe_files(library): + entry = Entry( + folder=library.folder, + path=pathlib.Path("bar/foo.txt"), + fields=library.default_fields, + ) + + entry2 = Entry( + folder=library.folder, + path=pathlib.Path("foo/foo.txt"), + fields=library.default_fields, + ) + + library.add_entries([entry, entry2]) + + registry = DupeRegistry(library=library) + + dupe_file_path = CWD.parent / "fixtures" / "result.dupeguru" + registry.refresh_dupe_files(dupe_file_path) + + assert len(registry.groups) == 1 + paths = [entry.path for entry in registry.groups[0]] + assert paths == [ + pathlib.Path("bar/foo.txt"), + pathlib.Path("foo.txt"), + pathlib.Path("foo/foo.txt"), + ] diff --git a/tagstudio/tests/macros/test_folders_tags.py b/tagstudio/tests/macros/test_folders_tags.py new file mode 100644 index 00000000..a5263c7a --- /dev/null +++ b/tagstudio/tests/macros/test_folders_tags.py @@ -0,0 +1,9 @@ +from src.qt.modals.folders_to_tags import folders_to_tags + + +def test_folders_to_tags(library): + folders_to_tags(library) + entry = [ + x for x in library.get_entries(with_joins=True) if "bar.md" in str(x.path) + ][0] + assert {x.name for x in entry.tags} == {"two", "bar"} diff --git a/tagstudio/tests/macros/test_missing_files.py b/tagstudio/tests/macros/test_missing_files.py new file mode 100644 index 00000000..68e9c257 --- /dev/null +++ b/tagstudio/tests/macros/test_missing_files.py @@ -0,0 +1,31 @@ +import pathlib +from tempfile import TemporaryDirectory + +import pytest + +from src.core.library import Library +from src.core.library.alchemy.enums import FilterState +from src.core.utils.missing_files import MissingRegistry + +CWD = pathlib.Path(__file__).parent + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_refresh_missing_files(library: Library): + registry = MissingRegistry(library=library) + + # touch the file `one/two/bar.md` but in wrong location to simulate a moved file + (library.library_dir / "bar.md").touch() + + # no files actually exist, so it should return all entries + assert list(registry.refresh_missing_files()) == [0, 1] + + # neither of the library entries exist + assert len(registry.missing_files) == 2 + + # iterate through two files + assert list(registry.fix_missing_files()) == [1, 2] + + # `bar.md` should be relinked to new correct path + _, entries = library.search_library(FilterState(path="bar.md")) + assert entries[0].path == pathlib.Path("bar.md") diff --git a/tagstudio/tests/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py new file mode 100644 index 00000000..f72572de --- /dev/null +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -0,0 +1,19 @@ +import pathlib +from tempfile import TemporaryDirectory + +import pytest +from src.core.utils.refresh_dir import RefreshDirTracker + +CWD = pathlib.Path(__file__).parent + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_refresh_new_files(library): + registry = RefreshDirTracker(library=library) + + # touch new files to simulate new files + (library.library_dir / "foo.md").touch() + + assert not list(registry.refresh_dir()) + + assert registry.files_not_in_library == [pathlib.Path("foo.md")] diff --git a/tagstudio/tests/macros/test_sidecar.py b/tagstudio/tests/macros/test_sidecar.py new file mode 100644 index 00000000..a1f1be35 --- /dev/null +++ b/tagstudio/tests/macros/test_sidecar.py @@ -0,0 +1,37 @@ +import shutil +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from src.core.enums import MacroID +from src.core.library.alchemy.fields import _FieldID + + +@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +def test_sidecar_macro(qt_driver, library, cwd, entry_full): + entry_full.path = Path("newgrounds/foo.txt") + + fixture = cwd / "fixtures/sidecar_newgrounds.json" + dst = library.library_dir / "newgrounds" / (entry_full.path.stem + ".json") + dst.parent.mkdir() + shutil.copy(fixture, dst) + + qt_driver.frame_content = [entry_full] + qt_driver.run_macro(MacroID.SIDECAR, 0) + + entry = next(library.get_entries(with_joins=True)) + new_fields = ( + (_FieldID.DESCRIPTION.name, "NG description"), + (_FieldID.ARTIST.name, "NG artist"), + (_FieldID.SOURCE.name, "https://ng.com"), + (_FieldID.TAGS.name, None), + ) + found = [(field.type.key, field.value) for field in entry.fields] + + # `new_fields` should be subset of `found` + for field in new_fields: + assert field in found, f"Field not found: {field} / {found}" + + expected_tags = {"ng_tag", "ng_tag2"} + assert {x.name in expected_tags for x in entry.tags} diff --git a/tagstudio/tests/qt/__snapshots__/test_folders_to_tags.ambr b/tagstudio/tests/qt/__snapshots__/test_folders_to_tags.ambr new file mode 100644 index 00000000..b54f3fe9 --- /dev/null +++ b/tagstudio/tests/qt/__snapshots__/test_folders_to_tags.ambr @@ -0,0 +1,4 @@ +# serializer version: 1 +# name: test_generate_preview_data + BranchData(dirs={'two': BranchData(dirs={}, files=['bar.md'], tag=)}, files=[], tag=None) +# --- diff --git a/tagstudio/tests/qt/test_driver.py b/tagstudio/tests/qt/test_driver.py new file mode 100644 index 00000000..7116ff6b --- /dev/null +++ b/tagstudio/tests/qt/test_driver.py @@ -0,0 +1,111 @@ +from pathlib import Path +from unittest.mock import Mock + +from src.core.library import Entry +from src.core.library.alchemy.enums import FilterState +from src.core.library.json.library import ItemType +from src.qt.widgets.item_thumb import ItemThumb + + +def test_update_thumbs(qt_driver): + qt_driver.frame_content = [ + Entry( + folder=qt_driver.lib.folder, + path=Path("/tmp/foo"), + fields=qt_driver.lib.default_fields, + ) + ] + + qt_driver.item_thumbs = [] + for i in range(3): + qt_driver.item_thumbs.append( + ItemThumb( + mode=ItemType.ENTRY, + library=qt_driver.lib, + driver=qt_driver, + thumb_size=(100, 100), + grid_idx=i, + ) + ) + + qt_driver.update_thumbs() + + for idx, thumb in enumerate(qt_driver.item_thumbs): + # only first item is visible + assert thumb.isVisible() == (idx == 0) + + +def test_select_item_bridge(qt_driver, entry_min): + # mock some props since we're not running `start()` + qt_driver.autofill_action = Mock() + qt_driver.sort_fields_action = Mock() + + # set the content manually + qt_driver.frame_content = [entry_min] * 3 + + qt_driver.filter.page_size = 3 + qt_driver._init_thumb_grid() + assert len(qt_driver.item_thumbs) == 3 + + # select first item + qt_driver.select_item(0, False, False) + assert qt_driver.selected == [0] + + # add second item to selection + qt_driver.select_item(1, False, bridge=True) + assert qt_driver.selected == [0, 1] + + # add third item to selection + qt_driver.select_item(2, False, bridge=True) + assert qt_driver.selected == [0, 1, 2] + + # select third item only + qt_driver.select_item(2, False, bridge=False) + assert qt_driver.selected == [2] + + qt_driver.select_item(0, False, bridge=True) + assert qt_driver.selected == [0, 1, 2] + + +def test_library_state_update(qt_driver): + # Given + for idx, entry in enumerate(qt_driver.lib.get_entries(with_joins=True)): + thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100), idx) + qt_driver.item_thumbs.append(thumb) + qt_driver.frame_content.append(entry) + + # no filter, both items are returned + qt_driver.filter_items() + assert len(qt_driver.frame_content) == 2 + + # filter by tag + state = FilterState(tag="foo", page_size=10) + qt_driver.filter_items(state) + assert qt_driver.filter.page_size == 10 + assert len(qt_driver.frame_content) == 1 + entry = qt_driver.frame_content[0] + assert list(entry.tags)[0].name == "foo" + + # When state is not changed, previous one is still applied + qt_driver.filter_items() + assert qt_driver.filter.page_size == 10 + assert len(qt_driver.frame_content) == 1 + entry = qt_driver.frame_content[0] + assert list(entry.tags)[0].name == "foo" + + # When state property is changed, previous one is overriden + state = FilterState(path="bar.md") + qt_driver.filter_items(state) + assert len(qt_driver.frame_content) == 1 + entry = qt_driver.frame_content[0] + assert list(entry.tags)[0].name == "bar" + + +def test_close_library(qt_driver): + # Given + qt_driver.close_library() + + # Then + assert len(qt_driver.frame_content) == 0 + assert len(qt_driver.item_thumbs) == 0 + assert qt_driver.selected == [] diff --git a/tagstudio/tests/qt/test_flow_widget.py b/tagstudio/tests/qt/test_flow_widget.py new file mode 100644 index 00000000..ccfac874 --- /dev/null +++ b/tagstudio/tests/qt/test_flow_widget.py @@ -0,0 +1,18 @@ +from PySide6.QtCore import QRect +from PySide6.QtWidgets import QWidget, QPushButton + +from src.qt.flowlayout import FlowLayout + + +def test_flow_layout_happy_path(qtbot): + class Window(QWidget): + def __init__(self): + super().__init__() + + self.flow_layout = FlowLayout(self) + self.flow_layout.setGridEfficiency(True) + self.flow_layout.addWidget(QPushButton("Short")) + + window = Window() + assert window.flow_layout.count() + assert window.flow_layout._do_layout(QRect(0, 0, 0, 0), False) diff --git a/tagstudio/tests/qt/test_folders_to_tags.py b/tagstudio/tests/qt/test_folders_to_tags.py new file mode 100644 index 00000000..b7f079f1 --- /dev/null +++ b/tagstudio/tests/qt/test_folders_to_tags.py @@ -0,0 +1,7 @@ +from src.qt.modals.folders_to_tags import generate_preview_data + + +def test_generate_preview_data(library, snapshot): + preview = generate_preview_data(library) + + assert preview == snapshot diff --git a/tagstudio/tests/qt/test_item_thumb.py b/tagstudio/tests/qt/test_item_thumb.py new file mode 100644 index 00000000..7ed0c5da --- /dev/null +++ b/tagstudio/tests/qt/test_item_thumb.py @@ -0,0 +1,19 @@ +import pytest + +from src.core.library import ItemType +from src.qt.widgets.item_thumb import ItemThumb, BadgeType + + +@pytest.mark.parametrize("new_value", (True, False)) +def test_badge_visual_state(library, qt_driver, entry_min, new_value): + thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100), 0) + + qt_driver.frame_content = [entry_min] + qt_driver.selected = [0] + qt_driver.item_thumbs = [thumb] + + thumb.badges[BadgeType.FAVORITE].setChecked(new_value) + assert thumb.badges[BadgeType.FAVORITE].isChecked() == new_value + # TODO + # assert thumb.favorite_badge.isHidden() == initial_state + assert thumb.is_favorite == new_value diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py new file mode 100644 index 00000000..7d66aa8d --- /dev/null +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -0,0 +1,122 @@ +from pathlib import Path + + +from src.core.library import Entry +from src.core.library.alchemy.enums import FieldTypeEnum +from src.core.library.alchemy.fields import _FieldID, TextField +from src.qt.widgets.preview_panel import PreviewPanel + + +def test_update_widgets_not_selected(qt_driver, library): + qt_driver.frame_content = list(library.get_entries()) + qt_driver.selected = [] + + panel = PreviewPanel(library, qt_driver) + panel.update_widgets() + + assert panel.preview_img.isVisible() + assert panel.file_label.text() == "No Items Selected" + + +def test_update_widgets_single_selected(qt_driver, library): + qt_driver.frame_content = list(library.get_entries()) + qt_driver.selected = [0] + + panel = PreviewPanel(library, qt_driver) + panel.update_widgets() + + assert panel.preview_img.isVisible() + + +def test_update_widgets_multiple_selected(qt_driver, library): + # entry with no tag fields + entry = Entry( + path=Path("test.txt"), + folder=library.folder, + fields=[TextField(type_key=_FieldID.TITLE.name, position=0)], + ) + + assert not entry.tag_box_fields + + library.add_entries([entry]) + assert library.entries_count == 3 + + qt_driver.frame_content = list(library.get_entries()) + qt_driver.selected = [0, 1, 2] + + panel = PreviewPanel(library, qt_driver) + panel.update_widgets() + + assert {f.type_key for f in panel.common_fields} == { + _FieldID.TITLE.name, + } + + assert {f.type_key for f in panel.mixed_fields} == { + _FieldID.TAGS.name, + _FieldID.TAGS_META.name, + } + + +def test_write_container_text_line(qt_driver, entry_full, library): + # Given + panel = PreviewPanel(library, qt_driver) + + field = entry_full.text_fields[0] + assert len(entry_full.text_fields) == 1 + assert field.type.type == FieldTypeEnum.TEXT_LINE + assert field.type.name == "Title" + + # set any value + field.value = "foo" + panel.write_container(0, field) + panel.selected = [0] + + assert len(panel.containers) == 1 + container = panel.containers[0] + widget = container.get_inner_widget() + # test it's not "mixed data" + assert widget.text_label.text() == "foo" + + # When update and submit modal + modal = panel.containers[0].modal + modal.widget.text_edit.setText("bar") + modal.save_button.click() + + # Then reload entry + entry_full = next(library.get_entries(with_joins=True)) + # the value was updated + assert entry_full.text_fields[0].value == "bar" + + +def test_remove_field(qt_driver, library): + # Given + panel = PreviewPanel(library, qt_driver) + entries = list(library.get_entries(with_joins=True)) + qt_driver.frame_content = entries + + # When second entry is selected + panel.selected = [1] + + field = entries[1].text_fields[0] + panel.write_container(0, field) + panel.remove_field(field) + + entries = list(library.get_entries(with_joins=True)) + assert not entries[1].text_fields + + +def test_update_field(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + # select both entries + qt_driver.frame_content = list(library.get_entries())[:2] + qt_driver.selected = [0, 1] + panel.selected = [0, 1] + + # update field + title_field = entry_full.text_fields[0] + panel.update_field(title_field, "meow") + + for entry in library.get_entries(with_joins=True): + field = [x for x in entry.text_fields if x.type_key == title_field.type_key][0] + assert field.value == "meow" diff --git a/tagstudio/tests/qt/test_tag_panel.py b/tagstudio/tests/qt/test_tag_panel.py new file mode 100644 index 00000000..c09d5f77 --- /dev/null +++ b/tagstudio/tests/qt/test_tag_panel.py @@ -0,0 +1,24 @@ +from src.core.library import Tag +from src.qt.modals.build_tag import BuildTagPanel + + +def test_tag_panel(qtbot, library): + panel = BuildTagPanel(library) + + qtbot.addWidget(panel) + + +def test_add_tag_callback(qt_driver): + # Given + assert len(qt_driver.lib.tags) == 5 + qt_driver.add_tag_action_callback() + + # When + qt_driver.modal.widget.name_field.setText("xxx") + qt_driver.modal.widget.color_field.setCurrentIndex(1) + qt_driver.modal.saved.emit() + + # Then + tags: set[Tag] = qt_driver.lib.tags + assert len(tags) == 6 + assert "xxx" in {tag.name for tag in tags} diff --git a/tagstudio/tests/qt/test_tag_search_panel.py b/tagstudio/tests/qt/test_tag_search_panel.py new file mode 100644 index 00000000..6ea6590d --- /dev/null +++ b/tagstudio/tests/qt/test_tag_search_panel.py @@ -0,0 +1,11 @@ +from src.qt.modals.tag_search import TagSearchPanel + + +def test_update_tags(qtbot, library): + # Given + panel = TagSearchPanel(library) + + qtbot.addWidget(panel) + + # When + panel.update_tags() diff --git a/tagstudio/tests/qt/test_tag_widget.py b/tagstudio/tests/qt/test_tag_widget.py new file mode 100644 index 00000000..e983841c --- /dev/null +++ b/tagstudio/tests/qt/test_tag_widget.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + + +from src.core.library.alchemy.fields import _FieldID +from src.qt.widgets.tag import TagWidget +from src.qt.widgets.tag_box import TagBoxWidget +from src.qt.modals.build_tag import BuildTagPanel + + +def test_tag_widget(qtbot, library, qt_driver): + # given + entry = next(library.get_entries(with_joins=True)) + field = entry.tag_box_fields[0] + + tag_widget = TagBoxWidget(field, "title", qt_driver) + + qtbot.add_widget(tag_widget) + + assert not tag_widget.add_modal.isVisible() + + # when/then check no exception is raised + tag_widget.add_button.clicked.emit() + # check `tag_widget.add_modal` is visible + assert tag_widget.add_modal.isVisible() + + +def test_tag_widget_add_existing_raises(library, qt_driver, entry_full): + # Given + tag_field = [ + f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name + ][0] + assert len(entry_full.tags) == 1 + tag = next(iter(entry_full.tags)) + + # When + tag_widget = TagBoxWidget(tag_field, "title", qt_driver) + tag_widget.driver.frame_content = [entry_full] + tag_widget.driver.selected = [0] + + # Then + with patch.object(tag_widget, "error_occurred") as mocked: + tag_widget.add_modal.widget.tag_chosen.emit(tag.id) + assert mocked.emit.called + + +def test_tag_widget_add_new_pass(qtbot, library, qt_driver, generate_tag): + # Given + entry = next(library.get_entries(with_joins=True)) + field = entry.tag_box_fields[0] + + tag = generate_tag(name="new_tag") + library.add_tag(tag) + + tag_widget = TagBoxWidget(field, "title", qt_driver) + + qtbot.add_widget(tag_widget) + + tag_widget.driver.selected = [0] + with patch.object(tag_widget, "error_occurred") as mocked: + # When + tag_widget.add_modal.widget.tag_chosen.emit(tag.id) + + # Then + assert not mocked.emit.called + + +def test_tag_widget_remove(qtbot, qt_driver, library, entry_full): + tag = list(entry_full.tags)[0] + assert tag + + assert entry_full.tag_box_fields + tag_field = [ + f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name + ][0] + + tag_widget = TagBoxWidget(tag_field, "title", qt_driver) + tag_widget.driver.selected = [0] + + qtbot.add_widget(tag_widget) + + tag_widget = tag_widget.base_layout.itemAt(0).widget() + assert isinstance(tag_widget, TagWidget) + + tag_widget.remove_button.clicked.emit() + + entry = next(qt_driver.lib.get_entries(with_joins=True)) + assert not entry.tag_box_fields[0].tags + + +def test_tag_widget_edit(qtbot, qt_driver, library, entry_full): + # Given + tag = list(entry_full.tags)[0] + assert tag + + assert entry_full.tag_box_fields + tag_field = [ + f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name + ][0] + + tag_box_widget = TagBoxWidget(tag_field, "title", qt_driver) + tag_box_widget.driver.selected = [0] + + qtbot.add_widget(tag_box_widget) + + tag_widget = tag_box_widget.base_layout.itemAt(0).widget() + assert isinstance(tag_widget, TagWidget) + + # When + actions = tag_widget.bg_button.actions() + edit_action = [a for a in actions if a.text() == "Edit"][0] + edit_action.triggered.emit() + + # Then + panel = tag_box_widget.edit_modal.widget + assert isinstance(panel, BuildTagPanel) + assert panel.tag.name == tag.name + assert panel.name_field.text() == tag.name diff --git a/tagstudio/tests/test_filter_state.py b/tagstudio/tests/test_filter_state.py new file mode 100644 index 00000000..4add369b --- /dev/null +++ b/tagstudio/tests/test_filter_state.py @@ -0,0 +1,37 @@ +import pytest + +from src.core.library.alchemy.enums import FilterState + + +def test_filter_state_query(): + # Given + query = "tag:foo" + state = FilterState(query=query) + + # When + assert state.tag == "foo" + + +@pytest.mark.parametrize( + ["attribute", "comparator"], + [ + ("tag", str), + ("tag_id", int), + ("path", str), + ("name", str), + ("id", int), + ], +) +def test_filter_state_attrs_compare(attribute, comparator): + # When + state = FilterState(**{attribute: "2"}) + + # Then + # compare the attribute value + assert getattr(state, attribute) == comparator("2") + + # Then + for prop in ("tag", "tag_id", "path", "name", "id"): + if prop == attribute: + continue + assert not getattr(state, prop) diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py new file mode 100644 index 00000000..4bd8bc64 --- /dev/null +++ b/tagstudio/tests/test_library.py @@ -0,0 +1,407 @@ +from pathlib import Path, PureWindowsPath +from tempfile import TemporaryDirectory + +import pytest + +from src.core.constants import LibraryPrefs +from src.core.library.alchemy import Entry +from src.core.library.alchemy import Library +from src.core.library.alchemy.enums import FilterState +from src.core.library.alchemy.fields import _FieldID, TextField + + +def test_library_bootstrap(): + with TemporaryDirectory() as tmp_dir: + lib = Library() + lib.open_library(tmp_dir) + assert lib.engine + + +def test_library_add_file(): + """Check Entry.path handling for insert vs lookup""" + with TemporaryDirectory() as tmp_dir: + # create file in tmp_dir + file_path = Path(tmp_dir) / "bar.txt" + file_path.write_text("bar") + + lib = Library() + lib.open_library(tmp_dir) + + entry = Entry( + path=file_path, + folder=lib.folder, + fields=lib.default_fields, + ) + + assert not lib.has_path_entry(entry.path) + + assert lib.add_entries([entry]) + + assert lib.has_path_entry(entry.path) is True + + +def test_create_tag(library, generate_tag): + # tag already exists + assert not library.add_tag(generate_tag("foo")) + + # new tag name + tag = library.add_tag(generate_tag("xxx", id=123)) + assert tag + assert tag.id == 123 + + tag_inc = library.add_tag(generate_tag("yyy")) + assert tag_inc.id > 1000 + + +def test_library_search(library, generate_tag, entry_full): + assert library.entries_count == 2 + tag = list(entry_full.tags)[0] + + query_count, items = library.search_library( + FilterState( + tag=tag.name, + ), + ) + + assert query_count == 1 + assert len(items) == 1 + + entry = items[0] + assert {x.name for x in entry.tags} == { + "foo", + } + + assert entry.tag_box_fields + + +def test_tag_search(library): + tag = library.tags[0] + + assert library.search_tags( + FilterState(tag=tag.name.lower()), + ) + + assert library.search_tags( + FilterState(tag=tag.name.upper()), + ) + + assert not library.search_tags( + FilterState(tag=tag.name * 2), + ) + + +def test_get_entry(library, entry_min): + assert entry_min.id + cnt, entries = library.search_library(FilterState(id=entry_min.id)) + assert len(entries) == cnt == 1 + assert entries[0].tags + + +def test_entries_count(library): + entries = [ + Entry(path=Path(f"{x}.txt"), folder=library.folder, fields=[]) + for x in range(10) + ] + library.add_entries(entries) + matches, page = library.search_library( + FilterState( + page_size=5, + ) + ) + + assert matches == 12 + assert len(page) == 5 + + +def test_add_field_to_entry(library): + # Given + entry = Entry( + folder=library.folder, + path=Path("xxx"), + fields=library.default_fields, + ) + # meta tags + content tags + assert len(entry.tag_box_fields) == 2 + + library.add_entries([entry]) + + # When + library.add_entry_field_type(entry.id, field_id=_FieldID.TAGS) + + # Then + entry = [x for x in library.get_entries(with_joins=True) if x.path == entry.path][0] + # meta tags and tags field present + assert len(entry.tag_box_fields) == 3 + + +def test_add_field_tag(library, entry_full, generate_tag): + # Given + tag_name = "xxx" + tag = generate_tag(tag_name) + tag_field = entry_full.tag_box_fields[0] + + # When + library.add_field_tag(entry_full, tag, tag_field.type_key) + + # Then + _, entries = library.search_library(FilterState(id=entry_full.id)) + tag_field = entries[0].tag_box_fields[0] + assert [x.name for x in tag_field.tags if x.name == tag_name] + + +def test_subtags_add(library, generate_tag): + # Given + tag = library.tags[0] + assert tag.id is not None + + subtag = generate_tag("subtag1") + subtag = library.add_tag(subtag) + assert subtag.id is not None + + # When + assert library.add_subtag(tag.id, subtag.id) + + # Then + assert tag.id is not None + tag = library.get_tag(tag.id) + assert tag.subtag_ids + + +@pytest.mark.parametrize("is_exclude", [True, False]) +def test_search_filter_extensions(library, is_exclude): + # Given + entries = list(library.get_entries()) + assert len(entries) == 2, entries + + library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, is_exclude) + library.set_prefs(LibraryPrefs.EXTENSION_LIST, ["md"]) + + # When + query_count, items = library.search_library( + FilterState(), + ) + + # Then + assert query_count == 1 + assert len(items) == 1 + + entry = items[0] + assert (entry.path.suffix == ".txt") == is_exclude + + +def test_search_library_case_insensitive(library): + # Given + entries = list(library.get_entries(with_joins=True)) + assert len(entries) == 2, entries + + entry = entries[0] + tag = list(entry.tags)[0] + + # When + query_count, items = library.search_library( + FilterState(tag=tag.name.upper()), + ) + + # Then + assert query_count == 1 + assert len(items) == 1 + + assert items[0].id == entry.id + + +def test_preferences(library): + for pref in LibraryPrefs: + assert library.prefs(pref) == pref.value + + +def test_save_windows_path(library, generate_tag): + # pretend we are on windows and create `Path` + + entry = Entry( + path=PureWindowsPath("foo\\bar.txt"), + folder=library.folder, + fields=library.default_fields, + ) + tag = generate_tag("win_path") + tag_name = tag.name + + library.add_entries([entry]) + # library.add_tag(tag) + library.add_field_tag(entry, tag, create_field=True) + + _, found = library.search_library(FilterState(tag=tag_name)) + assert found + + # path should be saved in posix format + assert str(found[0].path) == "foo/bar.txt" + + +def test_remove_entry_field(library, entry_full): + title_field = entry_full.text_fields[0] + + library.remove_entry_field(title_field, [entry_full.id]) + + entry = next(library.get_entries(with_joins=True)) + assert not entry.text_fields + + +def test_remove_field_entry_with_multiple_field(library, entry_full): + # Given + title_field = entry_full.text_fields[0] + + # When + # add identical field + assert library.add_entry_field_type(entry_full.id, field_id=title_field.type_key) + + # remove entry field + library.remove_entry_field(title_field, [entry_full.id]) + + # Then one field should remain + entry = next(library.get_entries(with_joins=True)) + assert len(entry.text_fields) == 1 + + +def test_update_entry_field(library, entry_full): + title_field = entry_full.text_fields[0] + + library.update_entry_field( + entry_full.id, + title_field, + "new value", + ) + + entry = next(library.get_entries(with_joins=True)) + assert entry.text_fields[0].value == "new value" + + +def test_update_entry_with_multiple_identical_fields(library, entry_full): + # Given + title_field = entry_full.text_fields[0] + + # When + # add identical field + library.add_entry_field_type(entry_full.id, field_id=title_field.type_key) + + # update one of the fields + library.update_entry_field( + entry_full.id, + title_field, + "new value", + ) + + # Then only one should be updated + entry = next(library.get_entries(with_joins=True)) + assert entry.text_fields[0].value == "" + assert entry.text_fields[1].value == "new value" + + +def test_mirror_entry_fields(library, entry_full): + target_entry = Entry( + folder=library.folder, + path=Path("xxx"), + fields=[ + TextField( + type_key=_FieldID.NOTES.name, + value="notes", + position=0, + ) + ], + ) + + entry_id = library.add_entries([target_entry])[0] + + _, entries = library.search_library(FilterState(id=entry_id)) + new_entry = entries[0] + + library.mirror_entry_fields(new_entry, entry_full) + + _, entries = library.search_library(FilterState(id=entry_id)) + entry = entries[0] + + assert len(entry.fields) == 4 + assert {x.type_key for x in entry.fields} == { + _FieldID.TITLE.name, + _FieldID.NOTES.name, + _FieldID.TAGS_META.name, + _FieldID.TAGS.name, + } + + +def test_remove_tag_from_field(library, entry_full): + for field in entry_full.tag_box_fields: + for tag in field.tags: + removed_tag = tag.name + library.remove_tag_from_field(tag, field) + break + + entry = next(library.get_entries(with_joins=True)) + for field in entry.tag_box_fields: + assert removed_tag not in [tag.name for tag in field.tags] + + +@pytest.mark.parametrize( + ["query_name", "has_result"], + [ + ("foo", 1), # filename substring + ("bar", 1), # filename substring + ("one", 0), # path, should not match + ], +) +def test_search_file_name(library, query_name, has_result): + res_count, items = library.search_library( + FilterState(name=query_name), + ) + + assert ( + res_count == has_result + ), f"mismatch with query: {query_name}, result: {res_count}" + + +@pytest.mark.parametrize( + ["query_name", "has_result"], + [ + (1, 1), + ("1", 1), + ("xxx", 0), + (222, 0), + ], +) +def test_search_entry_id(library, query_name, has_result): + res_count, items = library.search_library( + FilterState(id=query_name), + ) + + assert ( + res_count == has_result + ), f"mismatch with query: {query_name}, result: {res_count}" + + +def test_update_field_order(library, entry_full): + # Given + title_field = entry_full.text_fields[0] + + # When add two more fields + library.add_entry_field_type( + entry_full.id, field_id=title_field.type_key, value="first" + ) + library.add_entry_field_type( + entry_full.id, field_id=title_field.type_key, value="second" + ) + + # remove the one on first position + assert title_field.position == 0 + library.remove_entry_field(title_field, [entry_full.id]) + + # recalculate the positions + library.update_field_position( + type(title_field), + title_field.type_key, + entry_full.id, + ) + + # Then + entry = next(library.get_entries(with_joins=True)) + assert entry.text_fields[0].position == 0 + assert entry.text_fields[0].value == "first" + assert entry.text_fields[1].position == 1 + assert entry.text_fields[1].value == "second"