diff --git a/.github/workflows/apprun.yaml b/.github/workflows/apprun.yaml deleted file mode 100644 index 9894f168e..000000000 --- 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 374a1c5e3..3d7b4c18e 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 e8a458aa8..93f54e611 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 730ab4412..897dbdc5d 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 291c3bdea..6d665667b 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 d60908da6..3fe8ffd04 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 81de1b330..b6b2c6e65 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 a353c70c8..18be43fab 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 fba30860d..000000000 --- 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 7efee56fd..64ff62142 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 @@ + 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 7610a2ccc..8907ba179 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 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 OpenStatus(enum.IntEnum): + NOT_FOUND = 0 + SUCCESS = 1 + CORRUPTED = 2 + + +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 29ffdc35b..000000000 --- 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 000000000..98b662e52 --- /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 000000000..993e1aa0c --- /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 000000000..f1a23f5d3 --- /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 000000000..f7595e624 --- /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 000000000..6b079b0b4 --- /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 000000000..b3f4b711f --- /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 000000000..72b3e4d39 --- /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 000000000..3c4e0402d --- /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 000000000..e69de29bb diff --git a/tagstudio/src/core/library/json/fields.py b/tagstudio/src/core/library/json/fields.py new file mode 100644 index 000000000..5e3509e40 --- /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 7eedb8b82..c95a2c791 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 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 @@ def __eq__(self, __value: object) -> bool: 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 @@ def remove_tag(self, library: "Library", tag_id: int, field_index=-1): 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 @@ def add_tag( ): # 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 @@ def display_name(self, library: "Library") -> str: 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 @@ def __eq__(self, __value: object) -> bool: __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 @@ def __init__(self) -> None: # 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 @@ def __init__(self) -> None: # 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 @@ def create_library(self, path: Path) -> int: 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 @@ def _fix_lib_path(self, path) -> Path: """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 @@ def verify_ts_folders(self) -> None: 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 @@ def verify_default_tags(self, tag_list: list[JsonTag]) -> list[JsonTag]: 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 @@ def open_library(self, path: str | Path) -> int: "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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def open_library(self, path: str | Path) -> int: 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 @@ def to_json(self): 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 @@ def to_json(self): 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 @@ def save_library_to_disk(self): ) # , 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 @@ def save_library_backup_to_disk(self) -> str: 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 @@ def save_library_backup_to_disk(self) -> str: 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 @@ def refresh_dir(self) -> Generator: # 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 @@ def remove_entry(self, entry_id: int) -> None: # 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 @@ def refresh_dupe_entries(self): 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 @@ def merge_dupe_entries(self): `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 @@ def merge_dupe_entries(self): 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 @@ def merge_dupe_entries(self): # 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 @@ def remove_missing_files(self): # 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 @@ def add_new_files_as_entries(self) -> list[int]: 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 @@ def search_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 @@ def add_entry(entry: Entry): 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 @@ def add_entry(entry: Entry): 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 @@ def filter_field_templates(self, query: str) -> list[int]: """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 @@ def add_field_to_entry(self, entry_id: int, field_id: int) -> None: 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 @@ def get_field_obj(self, field_id: int) -> dict: 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 886e0bd6c..74a30fc0d 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 -class ColorType(int, Enum): +logger = structlog.get_logger(__name__) + + +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 @@ class ColorType(int, Enum): 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 @@ class ColorType(int, Enum): 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 @@ class ColorType(int, Enum): } -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 63ac30e63..aeed64094 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 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 not json_dump: + return {} + + 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 @@ def get_gdl_sidecar(self, filepath: str | Path, source: str = "") -> dict: # # # 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] - - 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 ( - 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 - - def build_url(self, entry_id: int, source: str): - """Tries to rebuild a source URL given a specific filename structure.""" + 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 not match: + return False + + if not c.get("fields"): + return False + + 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 @@ def _build_instagram_url(self, entry_id: int): # 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 000000000..719470189 --- /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 44ba1e88a..000000000 --- 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 000000000..8ecf9a4c5 --- /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 000000000..b265f3953 --- /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 7e3662214..05bcc1b09 100644 --- a/tagstudio/src/qt/flowlayout.py +++ b/tagstudio/src/qt/flowlayout.py @@ -72,14 +72,12 @@ def heightForWidth(self, width): 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 @@ def minimumSize(self): ) 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 @@ def _do_layout(self, rect, test_only): 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 76ef36666..d9afcd738 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 @@ def mousePressEvent(self, event): """ 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 197f90a62..770e2846b 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 a77f87447..ce6b1e338 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 @@ def toggle_landing_page(self, enabled: bool): 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 c0137da2d..9e1ef5c7f 100644 --- a/tagstudio/src/qt/modals/add_field.py +++ b/tagstudio/src/qt/modals/add_field.py @@ -10,23 +10,24 @@ 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 @@ def __init__(self, library: "Library"): 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 @@ def __init__(self, library: "Library"): 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 c7fa543e1..5507fb09a 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 @@ 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 @@ def __init__(self, library, tag_id: int = -1): self.subtags_layout.setContentsMargins(0, 0, 0, 0) self.subtags_layout.setSpacing(0) self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.subtags_title = QLabel() self.subtags_title.setText("Parent Tags") self.subtags_layout.addWidget(self.subtags_title) @@ -140,15 +136,18 @@ def __init__(self, library, tag_id: int = -1): 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 @@ def __init__(self, library, tag_id: int = -1): 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 - - # 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) - - # 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() + color = self.color_field.currentData() or TagColor.DEFAULT + + tag = self.tag + + 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 453148f91..19a3af08f 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -15,7 +15,7 @@ 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 @@ 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 c95b520bb..c631cd50b 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -19,13 +19,17 @@ 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 @@ def __init__(self, library: "Library"): 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 @@ def __init__(self, library: "Library"): 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 @@ def update_list_mode(self, mode: int): 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 b471f0763..e323c20e3 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 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def set_filename(self, filename: str): 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 72d115cda..b933aa6d3 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 @@ 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): """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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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), - ) - 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_missing_count(len(self.lib.missing_files)), - self.delete_modal.refresh_list(), - self.refresh_dupe_entries(), - ) + maximum=self.lib.entries_count, ) - 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 = 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_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(), + self.set_missing_count(self.tracker.missing_files_count), + self.delete_modal.refresh_list(), ) ) @@ -208,23 +143,8 @@ def set_missing_count(self, count: int): 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 2486a76e9..ae6dacb3f 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 @@ 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 - cut_branches_adding_nothing(tree) + 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def on_open(self, event): 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 @@ def __init__(self, data: dict, parentTag: Tag): 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 @@ def hide_show(self): 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 @@ def __init__(self, tag: Tag, parentTag: Tag) -> None: 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 249e6d113..470512334 100644 --- a/tagstudio/src/qt/modals/merge_dupe_entries.py +++ b/tagstudio/src/qt/modals/merge_dupe_entries.py @@ -7,6 +7,7 @@ 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def merge_entries(self): 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 09e4bab0c..97a11357b 100644 --- a/tagstudio/src/qt/modals/mirror_entities.py +++ b/tagstudio/src/qt/modals/mirror_entities.py @@ -17,7 +17,7 @@ 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 @@ 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def __init__(self, library: "Library", driver: "QtDriver"): 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 @@ def mirror_entries(self): 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 15af5cd3f..36e8da822 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 6101b737a..9b9f11e4c 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -12,7 +12,8 @@ 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 @@ 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 @@ def __init__(self, library): 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 @@ def __init__(self, library): 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 @@ def on_return(self, text: str): 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] - - first_id_set = False - for tag_id in tags: - if not first_id_set: - self.first_tag_id = tag_id - first_id_set = True + tags = self.lib.search_tags(FilterState(path=query, page_size=self.tag_limit)) + + 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 896f634f7..8459f11a1 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 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 @@ def __init__(self, library): 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 @@ def __init__(self, library): 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 @@ def on_return(self, text: str): 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 904dd19f0..174bc8452 100644 --- a/tagstudio/src/qt/pagination.py +++ b/tagstudio/src/qt/pagination.py @@ -10,7 +10,6 @@ from PySide6.QtWidgets import ( QWidget, QHBoxLayout, - QPushButton, QLabel, QLineEdit, QSizePolicy, @@ -274,7 +273,7 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True): 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 @@ def update_buttons(self, page_count: int, index: int, emit: bool = True): 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 @@ def _assign_click(self, button: QPushButtonWrapper, index): 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 @@ def _populate_buffer_buttons(self): # 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 0db8bb194..137363bb9 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 be7403683..eb041dd45 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.QtWidgets import ( QApplication, QWidget, - QHBoxLayout, QPushButton, QLineEdit, QScrollArea, @@ -55,30 +54,36 @@ ) 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.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 @@ 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 @@ def run(self): 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.search_mode = SearchMode.AND + self.frame_content = [] + self.filter = FilterState() + self.pages_count = 0 - # 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 @@ def __init__(self, core: TagStudioCore, args): 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 @@ def __init__(self, core: TagStudioCore, args): "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 @@ def __init__(self, core: TagStudioCore, args): 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 @@ def setup_signals(self): 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 @@ def start(self) -> None: # 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def start(self) -> None: 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 @@ def init_library_window(self): # 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 @@ def init_library_window(self): ) 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 @@ def init_library_window(self): 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.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.pagination.index.connect(lambda i: self.page_move(page_id=i)) - # self.main_window.raise_() self.splash.finish(self.main_window) self.preview_panel.update_widgets() @@ -631,11 +553,10 @@ def handleSIGTERM(self): 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 @@ def shutdown(self): 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) + logger.info("Closing Library...") + self.main_window.statusbar.showMessage("Closing Library...") + start_time = time.time() + self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir) + self.settings.sync() - 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) + title_text = f"{self.base_title}" + self.main_window.setWindowTitle(title_text) - end_time = time.time() - self.main_window.statusbar.showMessage( - f"Library Saved and Closed! ({format_timespan(end_time - start_time)})" - ) + 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 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("Backing Up Library...") + self.main_window.statusbar.showMessage("Saving Library...") start_time = time.time() - fn = self.lib.save_library_backup_to_disk() + target_path = self.lib.save_library_backup_to_disk() 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 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 @@ def show_file_extension_modal(self): "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.""" + + tracker = RefreshDirTracker(self.lib) - # 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() - - # # 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 @@ def add_new_files_callback(self): 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 @@ def add_new_files_runnable(self): # 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 @@ def new_file_macros_runnable(self, new_ids): # 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() - - 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}" + self.page_move(-1) + + 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 @@ def nav_forward( # |[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() - - 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 - - # 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) - - # 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)) + # sb: QScrollArea = self.main_window.scrollArea + # sb_pos = sb.verticalScrollBar().value() + + 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)) + + self.filter.page_index = page_index + self.filter_items() + + 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 @@ def _init_thumb_grid(self): # 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 @@ def _init_thumb_grid(self): 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() - - # 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}') - - 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)) + select_from = min(self.selected) + select_to = max(self.selected) + + if select_to < grid_index: + index_range = range(select_from, grid_index + 1) + else: + index_range = range(grid_index, select_to + 1) + + 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 @@ def select_item(self, type: ItemType, id: int, append: bool, bridge: bool): # 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 @@ def update_thumbs(self): 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 - - 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) - - # 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) - - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (time.time(), filepath, base_size, ratio, False, True), + for idx, (entry, item_thumb) in enumerate( + zip_longest(self.frame_content, self.item_thumbs) + ): + if not entry: + item_thumb.hide() + continue + + filepath = self.lib.library_dir / entry.path + item_thumb = self.item_thumbs[idx] + item_thumb.set_mode(ItemType.ENTRY) + item_thumb.set_item_id(entry) + + # TODO - show after item is rendered + item_thumb.show() + + 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') - - def update_badges(self): - for i, item_thumb in enumerate(self.item_thumbs, start=0): - item_thumb.update_badges() - - 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 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]), + ) + + # Restore Selected Borders + is_selected = (item_thumb.mode, item_thumb.item_id) in self.selected + item_thumb.thumb_button.set_selected(is_selected) + + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (time.time(), filepath, base_size, ratio, False, True), + ) + ) + + def update_badges(self, 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))) + + 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)) - def filter_items(self, query: str = ""): - if self.lib: - # logging.info('Filtering...') + end_time = time.time() + if self.filter.summary: self.main_window.statusbar.showMessage( - f'Searching Library for "{query}"...' + 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)})" ) - 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() + # 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 set_search_type(self, mode=SearchMode.AND): - self.search_mode = mode - self.filter_items(self.main_window.searchField.text()) + def set_search_type(self, mode: SearchMode = SearchMode.AND): + self.filter_items( + FilterState( + search_mode=mode, + path=self.main_window.searchField.text(), + ) + ) def remove_recent_library(self, item_key: str): self.settings.beginGroup(SettingItems.LIBS_LIST) @@ -1410,8 +1049,7 @@ def remove_recent_library(self, item_key: str): 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 @@ def update_libs_list(self, path: Path): 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() - 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 + # page (re)rendering, extract eventually + self.filter_items() - 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)})' - ) + self.main_window.toggle_landing_page(False) diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index b9234d7d2..b8382a493 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 @@ def render( 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 @@ def render( # 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 @@ def render( ) # 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 @@ def render( # 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 @@ def render( # 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 355a0fa94..fc7a1fd89 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 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: 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 @@ def set_copy_callback(self, callback: Optional[MethodType]): 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 @@ def set_edit_callback(self, callback: Optional[MethodType]): 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 @@ def set_inner_widget(self, widget: "FieldWidget"): 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 @@ def leaveEvent(self, event: QEvent) -> None: 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 5be2e3fbe..05a02d060 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 @@ 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 @@ 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 + +logger = structlog.get_logger(__name__) + + +class BadgeType(Enum): + FAVORITE = "Favorite" + ARCHIVED = "Archived" + + +BADGE_TAGS = { + BadgeType.FAVORITE: TAG_FAVORITE, + BadgeType.ARCHIVED: TAG_ARCHIVED, +} -ERROR = f"[ERROR]" -WARNING = f"[WARNING]" -INFO = f"[INFO]" +def badge_update_lock(func): + """Prevent recursively triggering badge updates.""" -logging.basicConfig(format="%(message)s", level=logging.INFO) + @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 @@ def __init__( 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 @@ def __init__( # 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.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) - # 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) - - # 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 @@ def set_mode(self, mode: Optional[ItemType]) -> None: # 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 @@ def set_mode(self, mode: Optional[ItemType]) -> None: 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 @@ def set_count(self, count: str) -> None: 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 @@ def update_thumb(self, timestamp: float, image: QPixmap = None): 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 @@ def update_clickable(self, clickable: typing.Callable): 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 @@ def leaveEvent(self, event: QEvent) -> None: 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) - - 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) - - 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) - - # 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) + @badge_update_lock + def on_badge_check(self, badge_type: BadgeType): + if self.mode is None: + return + + toggle_value = self.badges[badge_type].isChecked() + + self.badge_active[badge_type] = toggle_value + tag_id = BADGE_TAGS[badge_type] + + # check if current item is selected. if so, update all selected items + if self.grid_idx in self.driver.selected: + update_items = self.driver.selected else: - # 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] + + 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.panel.isOpen: - self.panel.update_widgets() - self.panel.driver.update_badges() + 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 9df3fa4d6..e5df97527 100644 --- a/tagstudio/src/qt/widgets/landing.py +++ b/tagstudio/src/qt/widgets/landing.py @@ -24,7 +24,7 @@ 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 @@ def __init__(self, driver: "QtDriver", pixel_ratio: float): 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 2d2538b27..1448d73e1 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 @@ def __init__( 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 27320aabf..386fa9ddc 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 @@ 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 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_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 @@ def __init__(self, library: Library, driver: "QtDriver"): 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 @@ def __init__(self, library: Library, driver: "QtDriver"): 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 @@ def __init__(self, library: Library, driver: "QtDriver"): 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 @@ def clear_layout(layout_item: QVBoxLayout): 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 @@ def set_button_style( 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 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): 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) - - 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 update_widgets(self, item: Union[Entry, Collation, Tag]): - def update_widgets(self): + 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_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) -> 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 @@ def update_widgets(self): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - ratio: float = self.devicePixelRatio() + ratio = self.devicePixelRatio() self.thumb_renderer.render( time.time(), "", @@ -471,7 +480,7 @@ def update_widgets(self): ) 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 @@ def update_widgets(self): 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.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - self.preview_vid.show() + self.preview_img.setContextMenuPolicy( + Qt.ContextMenuPolicy.ActionsContextMenu + ) + self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - # Stats for specific file types are displayed here. - if image and filepath.suffix.lower() in ( - IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES + 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" + ) + 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})" - ) - - 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})" + # 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 ( - 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 @@ def update_widgets(self): ) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - ratio: float = self.devicePixelRatio() + ratio = self.devicePixelRatio() self.thumb_renderer.render( time.time(), "", @@ -644,63 +661,38 @@ def update_widgets(self): 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) - - # 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")), - ) + # 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 = [] + + # 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 @@ def update_widgets(self): 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,103 +715,104 @@ def set_tags_updated_slot(self, slot: object): 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") + if not is_mixed: + assert isinstance(field.value, (str, type(None))) + text = field.value or "" else: text = "Mixed Data" - title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)" + + title = f"{field.type.name} ({field.type.type.value})" inner_container = TextWidget(title, text) container.set_inner_widget(inner_container) - # if type(item) == Entry: - if not mixed: + if not is_mixed: modal = PanelModal( - EditTextLine(self.lib.get_field_attr(field, "content")), + EditTextLine(field.value), title=title, - window_title=f'Edit {self.lib.get_field_attr(field, "name")}', + window_title=f"Edit {field.type.type.value}", save_callback=( lambda content: ( self.update_field(field, content), @@ -878,39 +820,39 @@ def write_container(self, index, field, mixed=False): ) ), ) + if "pytest" in sys.modules: + # for better testability + container.modal = modal # type: ignore + 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.type.value), + callback=lambda: ( + self.remove_field(field), + self.update_widgets(), + ), + ) ) - 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")) + 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 @@ def write_container(self, index, field, mixed=False): ), ) 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 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 + def remove_field(self, field: BaseField): + """Remove a field from all selected Entries.""" + logger.info("removing field", field=field, selected=self.selected) + entry_ids = [] + + for grid_idx in self.selected: + entry = self.driver.frame_content[grid_idx] + entry_ids.append(entry.id) + + 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: typing.Callable) -> None: + 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 @@ def remove_message_box(self, prompt: str, callback: typing.Callable) -> None: 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 739369dcf..3cbb21f78 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.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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( # 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 @@ def __init__( # 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 06b8b1fe5..567721fa1 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.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 @@ def __init__( 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 @@ def __init__( 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 @@ def set_tags(self, tags: list[int]): 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 1e6a3ad17..e0045902b 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.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 @@ 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 @@ def render( 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 @@ def render( 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 @@ def render( 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 @@ def render( # 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 @@ def render( 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 6bb860993..9fc65604c 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 @@ def __init__(self, driver: "QtDriver") -> None: 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 @@ def wheelEvent(self, event: QWheelEvent) -> None: 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 @@ def resizeEvent(self, event: QResizeEvent) -> None: 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 1861474f3..a6b47b6bc --- 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 2c5cd225d..3a431b82c 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 fe51488c7..000000000 --- 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 e4e6902ca..000000000 --- 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 920ff4959..000000000 --- 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 a576676f5..000000000 --- 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 997598f22..000000000 --- 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 43cde4270..000000000 --- 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 eeab9fd64..000000000 --- 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 000000000..31341bb8b --- /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 000000000..d72717324 --- /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 000000000..2272e1fcd --- /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 000000000..a5263c7a7 --- /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 000000000..68e9c2572 --- /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 000000000..f72572de1 --- /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 000000000..a1f1be35b --- /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 000000000..b54f3fe97 --- /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 000000000..7116ff6b4 --- /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 000000000..ccfac874a --- /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 000000000..b7f079f18 --- /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 000000000..7ed0c5dae --- /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 000000000..7d66aa8d7 --- /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 000000000..c09d5f777 --- /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 000000000..6ea6590d3 --- /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 000000000..e983841cf --- /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 000000000..4add369bb --- /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 000000000..4bd8bc641 --- /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"