diff --git a/pyproject.toml b/pyproject.toml index dfac463b1..3dc9454be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ packages = ["src/tagstudio"] [tool.pytest.ini_options] #addopts = "-m 'not qt'" qt_api = "pyside6" +pythonpath = ["src"] [tool.pyright] ignore = [ diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index bc728a999..eef19596d 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -1251,32 +1251,33 @@ def search_tags(self, name: str | None, limit: int = 100) -> tuple[list[Tag], li if limit <= 0: limit = sys.maxsize - name = name or "" - name = name.lower() + search_query: str = name.lower() if name else "" def sort_key(text: str): - priority = text.startswith(name) + priority = text.startswith(search_query) p_ordering = len(text) if priority else sys.maxsize - return (not priority, p_ordering, text) + return not priority, p_ordering, text with Session(self.engine) as session: query = select(Tag.id, Tag.name) - if limit > 0 and not name: + if limit > 0 and not search_query: query = query.order_by(Tag.name).limit(limit) - if name: + if search_query: query = query.where( or_( - Tag.name.icontains(name), - Tag.shorthand.icontains(name), + Tag.name.icontains(search_query), + Tag.shorthand.icontains(search_query), ) ) tags = list(session.execute(query)) - if name: - query = select(TagAlias.tag_id, TagAlias.name).where(TagAlias.name.icontains(name)) + if search_query: + query = select(TagAlias.tag_id, TagAlias.name).where( + TagAlias.name.icontains(search_query) + ) tags.extend(session.execute(query)) tags.sort(key=lambda t: sort_key(t[1])) @@ -1286,7 +1287,7 @@ def sort_key(text: str): logger.info( "searching tags", - search=name, + search=search_query, limit=limit, statement=str(query), results=len(tag_ids), @@ -1312,6 +1313,49 @@ def sort_key(text: str): return direct_tags, descendant_tags + def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]: + """Return field template rows matching the query, detached from the session.""" + if limit <= 0: + limit = sys.maxsize + + search_query: str = name.lower() if name else "" + + def sort_key(template: BaseFieldTemplate) -> tuple: + text = template.name.lower() + if not search_query: + return (text,) + priority = text.startswith(search_query) + p_ordering = len(text) if priority else sys.maxsize + return (not priority, p_ordering, text) + + with Session(self.engine) as session: + text_stmt = select(TextFieldTemplate) + datetime_stmt = select(DatetimeFieldTemplate) + if search_query: + text_stmt = text_stmt.where(TextFieldTemplate.name.icontains(search_query)) + datetime_stmt = datetime_stmt.where( + DatetimeFieldTemplate.name.icontains(search_query) + ) + + field_templates: list[BaseFieldTemplate] = [ + *session.scalars(text_stmt), + *session.scalars(datetime_stmt), + ] + field_templates.sort(key=sort_key) + field_templates = field_templates[:limit] + + for ft in field_templates: + session.expunge(ft) + make_transient(ft) + + logger.info( + "Searching field templates", + search=search_query, + limit=limit, + results=len(field_templates), + ) + return field_templates + def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool: """Set the path field of an entry. diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py new file mode 100644 index 000000000..a8c26f4e7 --- /dev/null +++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from collections.abc import Sequence +from warnings import catch_warnings + +import structlog +from PySide6.QtCore import Signal + +from tagstudio.core.library.alchemy.fields import BaseFieldTemplate +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +from tagstudio.qt.controllers.search_panel_controller import SearchPanel +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView +from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget + +logger = structlog.get_logger(__name__) + + +class FieldTemplateSearchModal(PanelModal): + def __init__( + self, + library: Library, + exclude: Sequence[BaseFieldTemplate] | None = None, + is_field_template_chooser: bool = True, + done_callback=None, + save_callback=None, + has_save=False, + ) -> None: + self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel( + library, exclude, is_field_template_chooser + ) + super().__init__( + self.search_panel, + Translations["field.add.plural"], + done_callback=done_callback, + save_callback=save_callback, + has_save=has_save, + ) + + +class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate], FieldTemplateSearchPanelView): + field_template_chosen = Signal(object) + + def __init__( + self, + library: Library, + exclude: Sequence[BaseFieldTemplate] | None = None, + is_field_template_chooser: bool = True, + ) -> None: + super().__init__([], is_field_template_chooser) + self.__lib = library + self._exclude_keys: frozenset[tuple[str, int]] = frozenset( + (t.__class__.__name__, t.id) for t in (exclude or ()) + ) + + self._unlimited_limit_item_label = Translations["field_template.all_field_templates"] + self._create_and_add_button_label_key = "field_template.create_add" + + def _get_max_limit(self) -> int: + return len(self.__lib.field_templates) + + def _is_excluded(self, item: BaseFieldTemplate) -> bool: # type: ignore[override] + return (item.__class__.__name__, item.id) in self._exclude_keys + + def _on_item_create(self) -> None: + # TODO: Allow creation of field templates + pass + + def on_item_edit(self, item: BaseFieldTemplate) -> None: + # TODO: Allow creation of field templates + pass + + def _on_item_remove(self, item: BaseFieldTemplate) -> None: + if self.is_chooser: + return + + # TODO: Allow creation of field templates + pass + + def _on_item_create_and_add(self) -> None: + # TODO: Allow creation of field templates + pass + + def _on_item_chosen(self, item: BaseFieldTemplate) -> None: + self.field_template_chosen.emit(item) + + def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]: + return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), [] + + def set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None: + """Set the field template of a field template widget at a specific index.""" + field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib) + field_template_widget.set_field_template(item) + field_template_widget.setHidden(item is None) + + if item is None: + return + + # field_template_widget.has_remove = not self.is_chooser + + # Disconnect previous callbacks + with catch_warnings(record=True): + # tag_widget.on_edit.disconnect() + # tag_widget.on_remove.disconnect() + field_template_widget.on_click.disconnect() + + # Connect callbacks + # tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) + # tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) + field_template_widget.on_click.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + + def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + # TODO: Allow creation of field templates + pass + + def edit_item(self, edit_item_panel: PanelWidget) -> None: + # TODO: Allow creation of field templates + pass diff --git a/src/tagstudio/qt/controllers/field_template_widget_controller.py b/src/tagstudio/qt/controllers/field_template_widget_controller.py new file mode 100644 index 000000000..3a8a2aa0d --- /dev/null +++ b/src/tagstudio/qt/controllers/field_template_widget_controller.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from tagstudio.core.library.alchemy.fields import BaseFieldTemplate +from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations +from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView + + +class FieldTemplateWidget(FieldTemplateWidgetView): + def __init__(self) -> None: + super().__init__() + + self.__field_template: BaseFieldTemplate | None = None + + def set_field_template(self, field_template: BaseFieldTemplate | None) -> None: + self.__field_template = field_template + + if field_template is None: + return + + field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown") + self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})") diff --git a/src/tagstudio/qt/controllers/preview_panel_controller.py b/src/tagstudio/qt/controllers/preview_panel_controller.py index 7933e488a..287db4f89 100644 --- a/src/tagstudio/qt/controllers/preview_panel_controller.py +++ b/src/tagstudio/qt/controllers/preview_panel_controller.py @@ -5,11 +5,10 @@ import typing from warnings import catch_warnings -from PySide6.QtWidgets import QListWidgetItem - +from tagstudio.core.library.alchemy.fields import BaseFieldTemplate from tagstudio.core.library.alchemy.library import Library -from tagstudio.qt.mixed.add_field import AddFieldModal -from tagstudio.qt.mixed.tag_search import TagSearchModal +from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal from tagstudio.qt.views.preview_panel_view import PreviewPanelView if typing.TYPE_CHECKING: @@ -17,35 +16,37 @@ class PreviewPanel(PreviewPanelView): - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__(library, driver) - self.__add_field_modal = AddFieldModal(self.lib) + self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True) self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) @typing.override - def _add_field_button_callback(self): + def _add_field_button_callback(self) -> None: self.__add_field_modal.show() @typing.override - def _add_tag_button_callback(self): + def _add_tag_button_callback(self) -> None: self.__add_tag_modal.show() @typing.override - def _set_selection_callback(self): + def _set_selection_callback(self) -> None: with catch_warnings(record=True): - self.__add_field_modal.done.disconnect() - self.__add_tag_modal.tsp.tag_chosen.disconnect() + self.__add_field_modal.search_panel.field_template_chosen.disconnect() + self.__add_tag_modal.tsp.item_chosen.disconnect() - self.__add_field_modal.done.connect(self._add_field_to_selected) - self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected) + self.__add_field_modal.search_panel.field_template_chosen.connect( + self._add_field_to_selected + ) + self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected) - def _add_field_to_selected(self, field_list: list[QListWidgetItem]): - self._fields.add_field_to_selected(field_list) + def _add_field_to_selected(self, template: BaseFieldTemplate) -> None: + self._fields.add_field_to_selected(template) if len(self._selected) == 1: self._fields.update_from_entry(self._selected[0]) - def _add_tag_to_selected(self, tag_id: int): + def _add_tag_to_selected(self, tag_id: int) -> None: self._fields.add_tags_to_selected(tag_id) if len(self._selected) == 1: self._fields.update_from_entry(self._selected[0]) diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py new file mode 100644 index 000000000..23f1f58f8 --- /dev/null +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -0,0 +1,216 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +import structlog +from PySide6 import QtCore, QtGui +from PySide6.QtCore import Signal +from PySide6.QtGui import QShowEvent + +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.search_panel_view import SearchPanelView + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + from tagstudio.qt.ts_qt import QtDriver + +T = TypeVar("T") + + +def _item_id(item: object) -> int: + item_id: Any = getattr(item, "id") # noqa: B009 + + if isinstance(item_id, int): + return item_id + else: + raise AttributeError() + + +def _item_name(item: object) -> str: + item_name: Any = getattr(item, "name") # noqa: B009 + + if isinstance(item_name, str): + return item_name + else: + raise AttributeError() + + +class SearchPanel(SearchPanelView, Generic[T]): + item_chosen = Signal(int) + + def __init__(self, exclude: list[int] | None = None, is_chooser: bool = True) -> None: + super().__init__(is_chooser) + self._driver: QtDriver | None = None + self.exclude: list[int] = exclude or [] + + # Limits + self._unlimited_limit_item_label: str = "All Items" + self.__limit_items: list[tuple[str, int]] = [ + ("25", 25), + ("50", 50), + ("100", 100), + ("250", 250), + ("500", 500), + (self._unlimited_limit_item_label, -1), + ] + self.__default_limit_index: int = 0 # 25 Limit (Default) + self.__previous_limit_index: int = self.__default_limit_index + + self.set_limit_items(self.__limit_items) + self.set_limit_index(self.__default_limit_index) + + # Items + self._search_results: list[T] = [] + + self._create_and_add_button_label_key: str = "" + + def set_driver(self, driver: "QtDriver") -> None: + self._driver = driver + + def _on_limit_changed(self, index: int) -> None: + logger.info("[SearchPanel] Updating limit") + + # Method was called outside the limit_combobox callback + if index != self.get_limit_index(): + self.set_limit_index(index) + + if self.__previous_limit_index == index: + return + + self.update_items(self.search_field.text()) + + def _get_limit(self) -> tuple[str, int]: + return self.__limit_items[self.get_limit_index()] + + def _get_previous_limit(self) -> tuple[str, int]: + return self.__limit_items[self.__previous_limit_index] + + def _get_max_limit(self) -> int: + raise NotImplementedError() + + def _on_search_query_changed(self, query: str) -> None: + self.create_and_add_button.setText( + Translations.format(self._create_and_add_button_label_key, query=query) + ) + self.update_items(query) + + def _on_search_query_submitted(self, query: str) -> None: + # Focus search field if no query + if not query: + self.search_field.setFocus() + parent = self.parentWidget() + if parent is not None: + parent.hide() + return + + # Create and add item if no search results + if len(self._search_results) <= 0: + self._on_item_create_and_add() + elif self.is_chooser: + self._on_item_chosen(self._search_results[0]) + + self.clear_search_query() + self.update_items() + + def _on_item_create(self) -> None: + raise NotImplementedError() + + def on_item_edit(self, item: T) -> None: + raise NotImplementedError() + + def _on_item_remove(self, item: T) -> None: + raise NotImplementedError() + + def _on_item_create_and_add(self) -> None: + raise NotImplementedError() + + def _on_item_chosen(self, item: T) -> None: + raise NotImplementedError() + + def _is_excluded(self, item: T) -> bool: + return _item_id(item) in self.exclude + + def update_items(self, query: str | None = None) -> None: + """Update the item list given a search query.""" + logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1]) + + # Remove the "Create & Add" button if one exists + self.remove_create_and_add_button() + + # Get results for the search query + query_lower = "" if not query else query.lower() + search_results: tuple[list[T], list[T]] = self.search_items(query_lower) + + # Sort and prioritize the results + direct_results = list({item for item in search_results[0] if not self._is_excluded(item)}) + direct_results.sort(key=lambda item: _item_name(item).lower()) + + ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)}) + ancestor_results.sort(key=lambda item: _item_name(item).lower()) + + raw_results = list(direct_results + ancestor_results) + priority_results: set[T] = set() + + if query and query.strip(): + for raw_item in raw_results: + if _item_name(raw_item).lower().startswith(query_lower): + priority_results.add(raw_item) + + all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [ + item for item in raw_results if item not in priority_results + ] + if self._get_limit()[1] > 0: + all_results = all_results[: self._get_limit()[1]] + + self._search_results = all_results + logger.info("[SearchPanel] Search results", results=self._search_results) + + # Update every item widget with the new search result data + previous_limit: int = ( + self._get_previous_limit()[1] > 0 and self._get_previous_limit()[1] + ) or self._get_max_limit() + current_limit: int = ( + self._get_limit()[1] > 0 and self._get_limit()[1] + ) or self._get_max_limit() + + for i in range(0, max(previous_limit, current_limit)): + item: T | None = all_results[i] if i < len(all_results) else None + self.set_item_widget(item=item, index=i) + + self.__previous_limit_index = self.get_limit_index() + + # Add back the "Create & Add" button + if query and query.strip(): + self.add_create_and_add_button() + + def search_items(self, query: str) -> tuple[list[T], list[T]]: + raise NotImplementedError() + + def set_item_widget(self, item: T | None, index: int) -> None: + raise NotImplementedError() + + def showEvent(self, event: QShowEvent) -> None: # noqa N802 + self.update_items() + self.scroll_to(0) + self.clear_search_query() + return super().showEvent(event) + + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 + # When Escape is pressed, focus back on the search box. + # If focus is already on the search box, close the modal. + if event.key() == QtCore.Qt.Key.Key_Escape: + if self.search_field.hasFocus(): + super().keyPressEvent(event) + else: + self.focus_search_box(select_all=True) + + def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + raise NotImplementedError() + + def edit_item(self, edit_item_panel: PanelWidget) -> None: + raise NotImplementedError() diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py new file mode 100644 index 000000000..7b06d1f56 --- /dev/null +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from typing import TYPE_CHECKING +from warnings import catch_warnings + +import structlog +from PySide6.QtWidgets import QMessageBox + +from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START +from tagstudio.core.library.alchemy.enums import BrowsingState +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.library.alchemy.models import Tag +from tagstudio.qt.controllers.search_panel_controller import SearchPanel +from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget +from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView + +logger = structlog.get_logger(__name__) + +# Only import for type checking/autocompletion, will not be imported at runtime. +if TYPE_CHECKING: + pass + + +class TagSearchModal(PanelModal): + tsp: "TagSearchPanel" + + def __init__( + self, + library: Library, + exclude: list[int] | None = None, + is_tag_chooser: bool = True, + done_callback=None, + save_callback=None, + has_save=False, + ): + self.tsp = TagSearchPanel(library, exclude, is_tag_chooser) + super().__init__( + self.tsp, + Translations["tag.add.plural"], + done_callback=done_callback, + save_callback=save_callback, + has_save=has_save, + ) + + +class TagSearchPanel(SearchPanel[Tag], TagSearchPanelView): + def __init__( + self, library: Library, exclude: list[int] | None = None, is_tag_chooser: bool = True + ): + super().__init__(exclude, is_tag_chooser) + self.__lib = library + + self._unlimited_limit_item_label = Translations["tag.all_tags"] + self._create_and_add_button_label_key = "tag.create_add" + + def _get_max_limit(self) -> int: + return len(self.__lib.tags) + + def _on_item_create(self) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + query: str = self.get_search_query() + + build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib) + build_tag_modal: PanelModal = PanelModal( + build_tag_panel, + Translations["tag.new"], + has_save=True, + ) + + if query.strip(): + build_tag_panel.name_field.setText(query) + + build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal)) + build_tag_modal.show() + + def on_item_edit(self, item: Tag) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item) + edit_tag_modal: PanelModal = PanelModal( + edit_tag_panel, + self.__lib.tag_display_name(item), + Translations["tag.edit"], + has_save=True, + ) + + edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel)) + edit_tag_modal.show() + + def _on_item_remove(self, item: Tag) -> None: + if self.is_chooser: + return + + if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END): + return + + message_box = QMessageBox( + QMessageBox.Question, # type: ignore + Translations["tag.remove"], + Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)), + QMessageBox.Ok | QMessageBox.Cancel, # type: ignore + ) + + result = message_box.exec() + + if result != QMessageBox.Ok: # type: ignore + return + + self.__lib.remove_tag(item.id) + self.update_items(self.get_search_query()) + + def _on_item_create_and_add(self) -> None: + """Opens "Create Tag" panel to create and add a new tag with given name.""" + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + query: str = self.get_search_query() + + logger.info("Create and Add Tag", name=query) + + build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib) + build_tag_modal: PanelModal = PanelModal( + build_tag_panel, + Translations["tag.new"], + Translations["tag.add"], + has_save=True, + ) + + if query.strip(): + build_tag_panel.name_field.setText(query) + + build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True)) + build_tag_modal.show() + + def _on_item_chosen(self, item: Tag) -> None: + self.item_chosen.emit(item.id) + + def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]: + return self.__lib.search_tags(name=query, limit=self._get_limit()[1]) + + def set_item_widget(self, item: Tag | None, index: int) -> None: + """Set the tag of a tag widget at a specific index.""" + tag_widget: TagWidget = self.get_item_widget(index, self.__lib) + tag_widget.set_tag(item) + tag_widget.setHidden(item is None) + + if item is None: + return + assert item is not None + + tag_widget.has_remove = not self.is_chooser and item.id not in range( + RESERVED_TAG_START, RESERVED_TAG_END + ) + + # Disconnect previous callbacks + with catch_warnings(record=True): + tag_widget.on_edit.disconnect() + tag_widget.on_remove.disconnect() + tag_widget.bg_button.clicked.disconnect() + tag_widget.search_for_tag_action.triggered.disconnect() + + # Connect callbacks + tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag)) + tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag)) + tag_widget.bg_button.clicked.connect( + lambda checked=False, tag=item: self._on_item_chosen(tag) + ) + + # Connect search action + if self._driver is not None: + tag_widget.search_for_tag_action.triggered.connect( + lambda tag_id=item.id: self.search_for_tag(tag_id) + ) + tag_widget.search_for_tag_action.setEnabled(True) + else: + tag_widget.search_for_tag_action.setEnabled(False) + + def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if isinstance(build_item_modal.widget, BuildTagPanel): + tag: Tag = build_item_modal.widget.build_tag() + self.__lib.add_tag( + tag, + parent_ids=build_item_modal.widget.parent_ids, + alias_names=build_item_modal.widget.alias_names, + alias_ids=build_item_modal.widget.alias_ids, + ) + + if choose_item: + self._on_item_chosen(tag) + self.clear_search_query() + + build_item_modal.hide() + self._on_search_query_changed(self.get_search_query()) + + def edit_item(self, edit_item_panel: PanelWidget) -> None: + # TODO: Move this to a top-level import + from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports + + if not isinstance(edit_item_panel, BuildTagPanel): + return + self.__lib.update_tag( + tag=edit_item_panel.build_tag(), + parent_ids=edit_item_panel.parent_ids, + alias_names=edit_item_panel.alias_names, + alias_ids=edit_item_panel.alias_ids, + ) + + self.update_items(self.search_field.text()) + + def search_for_tag(self, tag_id: int) -> None: + if self._driver is None: + return + + self._driver.main_window.search_field.setText(f"tag_id:{tag_id}") + self._driver.update_browsing_state( + BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current) + ) diff --git a/src/tagstudio/qt/mixed/add_field.py b/src/tagstudio/qt/mixed/add_field.py index 52b64e5ea..653b9788c 100644 --- a/src/tagstudio/qt/mixed/add_field.py +++ b/src/tagstudio/qt/mixed/add_field.py @@ -33,13 +33,13 @@ def __init__(self, library: Library): # [Cancel] [Save] super().__init__() self.lib = library - self.setWindowTitle(Translations["library.field.add"]) + self.setWindowTitle(Translations["field.add"]) self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(400, 300) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) - self.title_widget = QLabel(Translations["library.field.add"]) + self.title_widget = QLabel(Translations["field.add"]) self.title_widget.setObjectName("fieldTitle") self.title_widget.setWordWrap(True) self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;") diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index a6aef61ff..9c4e35c31 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -28,9 +28,9 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag, TagColorGroup from tagstudio.core.utils.types import unwrap +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel from tagstudio.qt.mixed.tag_color_preview import TagColorPreview from tagstudio.qt.mixed.tag_color_selection import TagColorSelection -from tagstudio.qt.mixed.tag_search import TagSearchModal, TagSearchPanel from tagstudio.qt.mixed.tag_widget import ( TagWidget, get_border_color, @@ -167,7 +167,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: exclude_ids.append(tag.id) self.add_tag_modal = TagSearchModal(self.lib, exclude_ids) - self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) + self.add_tag_modal.tsp.item_chosen.connect(lambda x: self.add_parent_tag_callback(x)) self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) # Color ---------------------------------------------------------------- @@ -431,7 +431,7 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b has_remove=True, ) tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t)) - tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).edit_tag(t)) + tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).on_item_edit(t)) row.addWidget(tag_widget) # Add Disambiguation Tag Button diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 7a64419f7..688e2e574 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -14,7 +14,6 @@ from PySide6.QtWidgets import ( QFrame, QHBoxLayout, - QListWidgetItem, QMessageBox, QScrollArea, QSizePolicy, @@ -50,7 +49,7 @@ class FieldContainers(QWidget): """The Preview Panel Widget.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.lib = library @@ -103,7 +102,7 @@ def __init__(self, library: Library, driver: "QtDriver"): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) - def update_from_entry(self, entry_id: int, update_badges: bool = True): + def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) @@ -113,7 +112,7 @@ def update_from_entry(self, entry_id: int, update_badges: bool = True): def update_granular( self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True - ): + ) -> None: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -139,7 +138,7 @@ def update_granular( if i > (container_len - 1): c.setHidden(True) - def update_toggled_tag(self, tag_id: int, toggle_value: bool): + def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: """Visually add or remove a tag from the item preview without needing to query the db.""" entry = self.cached_entries[0] tag = self.lib.get_tag(tag_id) @@ -152,7 +151,7 @@ def update_toggled_tag(self, tag_id: int, toggle_value: bool): self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) - def hide_containers(self): + def hide_containers(self) -> None: """Hide all field and tag containers.""" for c in self.containers: c.setHidden(True) @@ -203,29 +202,38 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: return dict((c, d) for c, d in categories.items() if len(d) > 0) def remove_field_prompt(self, name: str) -> str: - return Translations.format("library.field.confirm_remove", name=name) + return Translations.format("field.confirm_remove", name=name) - def add_field_to_selected(self, field_list: list[QListWidgetItem]): - """Add list of entry fields to one or more selected items. + def add_field_to_selected( + self, field_templates: BaseFieldTemplate | list[BaseFieldTemplate] + ) -> None: + """Add list of fields to one or more selected items. Uses the current driver selection, NOT the field containers cache. """ + if isinstance(field_templates, BaseFieldTemplate): + field_templates = [field_templates] + + assert isinstance(field_templates, list) + logger.info( "[FieldContainers][add_field_to_selected]", selected=self.driver.selected, - fields=field_list, + fields=[ + (field_template.class_name, field_template.id) for field_template in field_templates + ], ) + for entry_id in self.driver.selected: - for field in field_list: - template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole) + for field_template in field_templates: logger.info( "[FieldContainers][add_field_to_selected] Adding field", - name=template.name, - type=template.class_name, + name=field_template.name, + type=field_template.class_name, ) - self.lib.add_field_to_entries(entry_id, template.to_field()) + self.lib.add_field_to_entries(entry_id, field_template.to_field()) - def add_tags_to_selected(self, tags: int | list[int]): + def add_tags_to_selected(self, tags: int | list[int]) -> None: """Add list of tags to one or more selected items. Uses the current driver selection, NOT the field containers cache. @@ -239,7 +247,7 @@ def add_tags_to_selected(self, tags: int | list[int]): ) self.driver.add_tags_to_selected_callback(tags) - def write_container(self, index: int, field: BaseField, is_mixed: bool = False): + def write_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None: """Update/Create data for a FieldContainer. Args: @@ -406,7 +414,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): def write_tag_container( self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False - ): + ) -> None: """Update/Create tag data for a FieldContainer. Args: @@ -458,7 +466,7 @@ def write_tag_container( container.set_remove_callback() container.setHidden(False) - def remove_field(self, field: BaseField): + def remove_field(self, field: BaseField) -> None: """Remove a field from all selected Entries.""" logger.info( "[FieldContainers] Removing Field", @@ -468,14 +476,14 @@ def remove_field(self, field: BaseField): entry_ids = [e.id for e in self.cached_entries] self.lib.remove_entry_field(field, entry_ids) - def update_text_field(self, field: TextField, value: str, is_multiline: bool): + def update_text_field(self, field: TextField, value: str, is_multiline: bool) -> None: """Update a text field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" self.lib.update_text_field(entry_ids, field, value, is_multiline) - def update_datetime_field(self, field: DatetimeField, value: str): + def update_datetime_field(self, field: DatetimeField, value: str) -> None: """Update a datetime field across selected entries.""" entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py index 81ea08289..e69de29bb 100644 --- a/src/tagstudio/qt/mixed/tag_database.py +++ b/src/tagstudio/qt/mixed/tag_database.py @@ -1,74 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -import structlog -from PySide6.QtWidgets import QMessageBox, QPushButton - -from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.mixed.build_tag import BuildTagPanel -from tagstudio.qt.mixed.tag_search import TagSearchPanel -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal - -logger = structlog.get_logger(__name__) - -# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel` -# will most likely be enabled in every case -# and the possibility of disabling it can therefore be removed - - -class TagDatabasePanel(TagSearchPanel): - def __init__(self, driver, library: Library): - super().__init__(library, is_tag_chooser=False) - self.driver = driver - - self.create_tag_button = QPushButton(Translations["tag.create"]) - self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text())) - - self.root_layout.addWidget(self.create_tag_button) - - def build_tag(self, name: str): - panel = BuildTagPanel(self.lib) - self.modal = PanelModal( - panel, - Translations["tag.new"], - has_save=True, - ) - if name.strip(): - panel.name_field.setText(name) - - self.modal.saved.connect( - lambda: ( - self.lib.add_tag( - tag=panel.build_tag(), - parent_ids=panel.parent_ids, - alias_names=panel.alias_names, - alias_ids=panel.alias_ids, - ), - self.modal.hide(), - self.update_tags(self.search_field.text()), - ) - ) - self.modal.show() - - def delete_tag(self, tag: Tag): - if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): - return - - message_box = QMessageBox( - QMessageBox.Question, # pyright: ignore[reportAttributeAccessIssue] - Translations["tag.remove"], - Translations.format("tag.confirm_delete", tag_name=self.lib.tag_display_name(tag)), - QMessageBox.Ok | QMessageBox.Cancel, # pyright: ignore[reportAttributeAccessIssue] - ) - - result = message_box.exec() - - if result != QMessageBox.Ok: # pyright: ignore[reportAttributeAccessIssue] - return - - self.lib.remove_tag(tag.id) - self.update_tags() diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py deleted file mode 100644 index e309d5d02..000000000 --- a/src/tagstudio/qt/mixed/tag_search.py +++ /dev/null @@ -1,372 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from typing import TYPE_CHECKING, Union -from warnings import catch_warnings - -import structlog -from PySide6 import QtCore, QtGui -from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import ( - QComboBox, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START -from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.library.alchemy.models import Tag -from tagstudio.qt.mixed.tag_widget import TagWidget -from tagstudio.qt.models.palette import ColorType, get_tag_color -from tagstudio.qt.translations import Translations -from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget - -logger = structlog.get_logger(__name__) - -# Only import for type checking/autocompletion, will not be imported at runtime. -if TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - - -class TagSearchModal(PanelModal): - tsp: "TagSearchPanel" - - def __init__( - self, - library: Library, - exclude: list[int] | None = None, - is_tag_chooser: bool = True, - done_callback=None, - save_callback=None, - has_save=False, - ): - self.tsp = TagSearchPanel(library, exclude, is_tag_chooser) - super().__init__( - self.tsp, - Translations["tag.add.plural"], - done_callback=done_callback, - save_callback=save_callback, - has_save=has_save, - ) - - -class TagSearchPanel(PanelWidget): - tag_chosen = Signal(int) - lib: Library - driver: Union["QtDriver", None] - is_initialized: bool = False - first_tag_id: int | None = None - is_tag_chooser: bool - exclude: list[int] - - _limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]] - _default_limit_idx: int = 0 # 50 Tag Limit (Default) - cur_limit_idx: int = _default_limit_idx - tag_limit: int | str = _limit_items[_default_limit_idx] - - def __init__( - self, - library: Library, - exclude: list[int] | None = None, - is_tag_chooser: bool = True, - ): - super().__init__() - self.lib = library - self.driver = None - self.exclude = exclude or [] - - self.is_tag_chooser = is_tag_chooser - self.create_button_in_layout: bool = False - - self.setMinimumSize(300, 400) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6, 0, 6, 0) - - self.limit_container = QWidget() - self.limit_layout = QHBoxLayout(self.limit_container) - self.limit_layout.setContentsMargins(0, 0, 0, 0) - self.limit_layout.setSpacing(12) - self.limit_layout.addStretch(1) - - self.limit_title = QLabel(Translations["tag.view_limit"]) - self.limit_layout.addWidget(self.limit_title) - - self.limit_combobox = QComboBox() - self.limit_combobox.setEditable(False) - self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items]) - self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx) - self.limit_combobox.currentIndexChanged.connect(self.update_limit) - self.limit_layout.addWidget(self.limit_combobox) - self.limit_layout.addStretch(1) - - self.search_field = QLineEdit() - self.search_field.setObjectName("searchField") - self.search_field.setMinimumSize(QSize(0, 32)) - self.search_field.setPlaceholderText(Translations["home.search_tags"]) - self.search_field.textEdited.connect(lambda text: self.update_tags(text)) - self.search_field.returnPressed.connect(lambda: self.on_return(self.search_field.text())) - - self.scroll_contents = QWidget() - self.scroll_layout = QVBoxLayout(self.scroll_contents) - self.scroll_layout.setContentsMargins(6, 0, 6, 0) - self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - self.scroll_area = QScrollArea() - self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) - self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) - self.scroll_area.setWidget(self.scroll_contents) - - self.root_layout.addWidget(self.limit_container) - self.root_layout.addWidget(self.search_field) - self.root_layout.addWidget(self.scroll_area) - - def set_driver(self, driver): - """Set the QtDriver for this search panel. Used for main window operations.""" - self.driver = driver - - def build_create_button(self, query: str | None): - """Constructs a "Create & Add Tag" QPushButton.""" - create_button = QPushButton(self) - create_button.setFlat(True) - - create_button.setMinimumSize(22, 22) - - create_button.setStyleSheet( - f"QPushButton{{" - f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};" - f"font-weight: 600;" - f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};" - f"border-radius: 6px;" - f"border-style:dashed;" - f"border-width: 2px;" - f"padding-right: 4px;" - f"padding-bottom: 1px;" - f"padding-left: 4px;" - f"font-size: 13px" - f"}}" - f"QPushButton::hover{{" - f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"}}" - f"QPushButton::pressed{{" - f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};" - f"}}" - f"QPushButton::focus{{" - f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};" - f"outline:none;" - f"}}" - ) - - return create_button - - def create_and_add_tag(self, name: str): - """Opens "Create Tag" panel to create and add a new tag with given name.""" - logger.info("Create and Add Tag", name=name) - - def on_tag_modal_saved(): - """Callback for actions to perform when a new tag is confirmed created.""" - tag: Tag = self.build_tag_modal.build_tag() - self.lib.add_tag( - tag, - set(self.build_tag_modal.parent_ids), - set(self.build_tag_modal.alias_names), - set(self.build_tag_modal.alias_ids), - ) - self.add_tag_modal.hide() - - self.tag_chosen.emit(tag.id) - self.search_field.setText("") - self.search_field.setFocus() - self.update_tags() - - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports - - self.build_tag_modal: BuildTagPanel = BuildTagPanel(self.lib) - self.add_tag_modal: PanelModal = PanelModal( - self.build_tag_modal, Translations["tag.new"], Translations["tag.add"], has_save=True - ) - - self.build_tag_modal.name_field.setText(name) - self.add_tag_modal.saved.connect(on_tag_modal_saved) - self.add_tag_modal.show() - - def update_tags(self, query: str | None = None): - """Update the tag list given a search query.""" - logger.info("[TagSearchPanel] Updating Tags") - - # Remove the "Create & Add" button if one exists - if self.create_button_in_layout and self.scroll_layout.count(): - self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater() - self.create_button_in_layout = False - - # Only use the tag limit if it's an actual number (aka not "All Tags") - tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1 - direct_tags, descendant_tags = self.lib.search_tags(name=query, limit=tag_limit) - - all_results = [t for t in direct_tags if t.id not in self.exclude] - all_results.extend(t for t in descendant_tags if t.id not in self.exclude) - - if tag_limit > 0: - all_results = all_results[:tag_limit] - - if all_results: - self.first_tag_id = None - self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id - - else: - self.first_tag_id = None - - # Update every tag widget with the new search result data - for i in range(0, len(all_results)): - tag = all_results[i] - self.set_tag_widget(tag, i) - for i in range(len(all_results), self.scroll_layout.count()): - self.set_tag_widget(None, i) - - # Add back the "Create & Add" button - if query and query.strip(): - cb: QPushButton = self.build_create_button(query) - cb.setText(Translations.format("tag.create_add", query=query)) - with catch_warnings(record=True): - cb.clicked.disconnect() - cb.clicked.connect(lambda: self.create_and_add_tag(query or "")) - self.scroll_layout.addWidget(cb) - self.create_button_in_layout = True - - def set_tag_widget(self, tag: Tag | None, index: int): - """Set the tag of a tag widget at a specific index.""" - # Create any new tag widgets needed up to the given index - if self.scroll_layout.count() <= index: - while self.scroll_layout.count() <= index: - new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib) - new_tw.setHidden(True) - self.scroll_layout.addWidget(new_tw) - - # Assign the tag to the widget at the given index. - tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # pyright: ignore[reportAssignmentType] - assert isinstance(tag_widget, TagWidget) - tag_widget.set_tag(tag) - - # Set tag widget viability and potentially return early - tag_widget.setHidden(bool(not tag)) - if not tag: - return - - # Configure any other aspects of the tag widget - has_remove_button = False - if not self.is_tag_chooser: - has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END) - tag_widget.has_remove = has_remove_button - - with catch_warnings(record=True): - tag_widget.on_edit.disconnect() - tag_widget.on_remove.disconnect() - tag_widget.bg_button.clicked.disconnect() - tag_widget.search_for_tag_action.triggered.disconnect() - - tag_id = tag.id - tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) - tag_widget.on_remove.connect(lambda t=tag: self.delete_tag(t)) - tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id)) - - if self.driver is not None: - tag_widget.search_for_tag_action.triggered.connect( - lambda checked=False, tag_id=tag.id, driver=self.driver: ( - driver.main_window.search_field.setText(f"tag_id:{tag_id}"), - driver.update_browsing_state( - BrowsingState.from_tag_id(tag_id, driver.browsing_history.current) - ), - ) - ) - tag_widget.search_for_tag_action.setEnabled(True) - else: - tag_widget.search_for_tag_action.setEnabled(False) - - def update_limit(self, index: int): - logger.info("[TagSearchPanel] Updating tag limit") - if TagSearchPanel.cur_limit_idx == index: - return - - TagSearchPanel.cur_limit_idx = index - - if index < len(self._limit_items) - 1: - TagSearchPanel.tag_limit = int(self._limit_items[index]) - else: - TagSearchPanel.tag_limit = -1 - - # Method was called outside the limit_combobox callback - if index != self.limit_combobox.currentIndex(): - self.limit_combobox.setCurrentIndex(index) - - self.update_tags(self.search_field.text()) - - def on_return(self, text: str): - if text: - if self.first_tag_id is not None: - if self.is_tag_chooser: - self.tag_chosen.emit(self.first_tag_id) - self.search_field.setText("") - self.update_tags() - else: - self.create_and_add_tag(text) - else: - self.search_field.setFocus() - self.parentWidget().hide() - - def showEvent(self, event: QShowEvent) -> None: # noqa N802 - self.update_limit(TagSearchPanel.cur_limit_idx) - self.update_tags() - self.scroll_area.verticalScrollBar().setValue(0) - self.search_field.setText("") - self.search_field.setFocus() - return super().showEvent(event) - - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802 - # When Escape is pressed, focus back on the search box. - # If focus is already on the search box, close the modal. - if event.key() == QtCore.Qt.Key.Key_Escape: - if self.search_field.hasFocus(): - return super().keyPressEvent(event) - else: - self.search_field.setFocus() - self.search_field.selectAll() - - def delete_tag(self, tag: Tag): - pass - - def edit_tag(self, tag: Tag): - # TODO: Move this to a top-level import - from tagstudio.qt.mixed.build_tag import BuildTagPanel - - def callback(btp: BuildTagPanel): - self.lib.update_tag( - btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids) - ) - self.update_tags(self.search_field.text()) - - build_tag_panel = BuildTagPanel(self.lib, tag=tag) - - self.edit_modal = PanelModal( - build_tag_panel, - self.lib.tag_display_name(tag), - Translations["tag.edit"], - done_callback=(self.update_tags(self.search_field.text())), - has_save=True, - ) - - self.edit_modal.saved.connect(lambda: callback(build_tag_panel)) - self.edit_modal.show() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index edb3cafa5..a26e962cc 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -64,12 +64,16 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox +from tagstudio.qt.controllers.field_template_search_panel_controller import ( + FieldTemplateSearchPanel, +) # this import has side-effect of import PySide resources from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow from tagstudio.qt.controllers.out_of_date_message_box import OutOfDateMessageBox +from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal, TagSearchPanel from tagstudio.qt.global_settings import ( DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, @@ -86,8 +90,6 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.mixed.tag_color_manager import TagColorManager -from tagstudio.qt.mixed.tag_database import TagDatabasePanel -from tagstudio.qt.mixed.tag_search import TagSearchModal from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD @@ -181,8 +183,10 @@ class QtDriver(DriverMixin, QObject): tag_manager_panel: PanelModal | None = None color_manager_panel: TagColorManager | None = None + field_template_manager_panel: PanelModal | None = None ignore_modal: PanelModal | None = None add_tag_modal: PanelModal | None = None + add_field_modal: PanelModal | None = None folders_modal: FoldersToTagsModal about_modal: AboutModal unlinked_modal: FixUnlinkedEntriesModal @@ -364,7 +368,7 @@ def start(self) -> None: # Initialize the Tag Manager panel self.tag_manager_panel = PanelModal( - widget=TagDatabasePanel(self, self.lib), + widget=TagSearchPanel(self.lib, is_tag_chooser=False), title=Translations["tag_manager.title"], done_callback=lambda checked=False: ( self.main_window.preview_panel.set_selection(self.selected, update_preview=False) @@ -375,10 +379,20 @@ def start(self) -> None: # Initialize the Color Group Manager panel self.color_manager_panel = TagColorManager(self) + # Initialize the Field Template Manager panel + self.field_template_manager_panel = PanelModal( + widget=FieldTemplateSearchPanel(self.lib, is_field_template_chooser=False), + title=Translations["field_template_manager.title"], + done_callback=lambda checked=False: ( + self.main_window.preview_panel.set_selection(self.selected, update_preview=False) + ), + has_save=False, + ) + # Initialize the Tag Search panel self.add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True) self.add_tag_modal.tsp.set_driver(self) - self.add_tag_modal.tsp.tag_chosen.connect( + self.add_tag_modal.tsp.item_chosen.connect( lambda chosen_tag: ( self.add_tags_to_selected_callback([chosen_tag]), self.main_window.preview_panel.set_selection(self.selected), @@ -468,6 +482,10 @@ def set_open_last_loaded_on_startup(checked: bool): self.color_manager_panel.show ) + self.main_window.menu_bar.field_template_manager_action.triggered.connect( + self.field_template_manager_panel.show + ) + # endregion # region View Menu ============================================================ @@ -794,6 +812,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.menu_bar.refresh_dir_action.setEnabled(False) self.main_window.menu_bar.tag_manager_action.setEnabled(False) self.main_window.menu_bar.color_manager_action.setEnabled(False) + self.main_window.menu_bar.field_template_manager_action.setEnabled(False) self.main_window.menu_bar.ignore_modal_action.setEnabled(False) self.main_window.menu_bar.new_tag_action.setEnabled(False) self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(False) @@ -1646,6 +1665,7 @@ def _init_library(self, path: Path, open_status: LibraryStatus): self.main_window.menu_bar.refresh_dir_action.setEnabled(True) self.main_window.menu_bar.tag_manager_action.setEnabled(True) self.main_window.menu_bar.color_manager_action.setEnabled(True) + self.main_window.menu_bar.field_template_manager_action.setEnabled(True) self.main_window.menu_bar.ignore_modal_action.setEnabled(True) self.main_window.menu_bar.new_tag_action.setEnabled(True) self.main_window.menu_bar.fix_unlinked_entries_action.setEnabled(True) diff --git a/src/tagstudio/qt/views/field_template_search_panel_view.py b/src/tagstudio/qt/views/field_template_search_panel_view.py new file mode 100644 index 000000000..a1f3f8e32 --- /dev/null +++ b/src/tagstudio/qt/views/field_template_search_panel_view.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from PySide6.QtWidgets import QWidget + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.search_panel_view import SearchPanelView + + +class FieldTemplateSearchPanelView(SearchPanelView): + def __init__(self, is_field_chooser: bool) -> None: + super().__init__(is_field_chooser) + + self.search_field.setPlaceholderText(Translations["home.search_field_templates"]) + self.create_button.setText(Translations["field_template.create"]) + + def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self._scroll_layout.count() <= index: + while self._scroll_layout.count() <= index: + pad_field_template_widget = FieldTemplateWidget() + pad_field_template_widget.setHidden(True) + self._scroll_layout.addWidget(pad_field_template_widget) + + field_template_widget: QWidget = self._scroll_layout.itemAt(index).widget() + assert isinstance(field_template_widget, FieldTemplateWidget) + return field_template_widget diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py new file mode 100644 index 000000000..f4073ff5c --- /dev/null +++ b/src/tagstudio/qt/views/field_template_widget_view.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget + +from tagstudio.core.enums import Theme +from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color +from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color + +primary_color: QColor = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) +border_color: QColor = get_border_color(primary_color) +highlight_color: QColor = get_highlight_color(primary_color) +text_color: QColor = get_text_color(primary_color, highlight_color) + +FIELD_TEMPLATE_BUTTON_STYLESHEET = f""" + QPushButton{{ + background-color: {Theme.COLOR_BG.value}; + font-weight: 600; + border-radius: 6px; + padding-right: 4px; + padding-left: 4px; + font-size: 13px; + text-align: center; + }} + + QPushButton::hover{{ + background-color: {Theme.COLOR_HOVER.value}; + border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} + + QPushButton::pressed{{ + background-color: {Theme.COLOR_PRESSED.value}; + border-color: {get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)}; + border-style: solid; + border-width: 2px; + }} +""" + + +class FieldTemplateWidgetView(QWidget): + on_click = Signal() + on_edit = Signal() + on_remove = Signal() + + def __init__(self) -> None: + super().__init__() + + self.__root_layout = QVBoxLayout(self) + self.__root_layout.setObjectName("root_layout") + + self.__root_layout.setContentsMargins(0, 0, 0, 0) + + # Background button + self._bg_button = QPushButton(self) + self.__root_layout.addWidget(self._bg_button) + + self._bg_button.setFlat(True) + self._bg_button.setMinimumSize(44, 22) + self._bg_button.setMinimumHeight(22) + self._bg_button.setMaximumHeight(22) + self._bg_button.setStyleSheet(FIELD_TEMPLATE_BUTTON_STYLESHEET) + + self.__inner_layout = QHBoxLayout() + self.__inner_layout.setObjectName("inner_layout") + self._bg_button.setLayout(self.__inner_layout) + + self.__inner_layout.setContentsMargins(0, 0, 0, 0) + + # Remove button + self.__remove_button = QPushButton(self) + self.__remove_button.setFlat(True) + self.__remove_button.setText("–") + self.__remove_button.setHidden(True) + self.__remove_button.setMinimumSize(22, 22) + self.__remove_button.setMaximumSize(22, 22) + + self.__inner_layout.addWidget(self.__remove_button) + self.__inner_layout.addStretch(1) + + self.__connect_callbacks() + + def __connect_callbacks(self) -> None: + self._bg_button.clicked.connect(self.on_click.emit) + self.__remove_button.clicked.connect(self.on_remove.emit) diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index 374df6465..b9229fdd5 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -77,6 +77,7 @@ class MainMenuBar(QMenuBar): delete_file_action: QAction ignore_modal_action: QAction tag_manager_action: QAction + field_template_manager_action: QAction color_manager_action: QAction view_menu: QMenu @@ -299,6 +300,13 @@ def setup_edit_menu(self): self.color_manager_action.setEnabled(False) self.edit_menu.addAction(self.color_manager_action) + # Manage Field Templates + self.field_template_manager_action = QAction( + Translations["menu.edit.manage_field_templates"], self + ) + self.field_template_manager_action.setEnabled(False) + self.edit_menu.addAction(self.field_template_manager_action) + assign_mnemonics(self.edit_menu) self.addMenu(self.edit_menu) diff --git a/src/tagstudio/qt/views/preview_panel_view.py b/src/tagstudio/qt/views/preview_panel_view.py index f83045cd9..81ad91fb9 100644 --- a/src/tagstudio/qt/views/preview_panel_view.py +++ b/src/tagstudio/qt/views/preview_panel_view.py @@ -31,7 +31,7 @@ logger = structlog.get_logger(__name__) -BUTTON_STYLE = f""" +BUTTON_STYLE: str = f""" QPushButton{{ background-color: {Theme.COLOR_BG.value}; border-radius: 6px; @@ -61,7 +61,7 @@ class PreviewPanelView(QWidget): _selected: list[int] - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.lib = library @@ -96,7 +96,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__add_tag_button.setMinimumHeight(28) self.__add_tag_button.setStyleSheet(BUTTON_STYLE) - self.__add_field_button = QPushButton(Translations["library.field.add"]) + self.__add_field_button = QPushButton(Translations["field.add"]) self.__add_field_button.setEnabled(False) self.__add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) self.__add_field_button.setMinimumHeight(28) @@ -120,20 +120,20 @@ def __init__(self, library: Library, driver: "QtDriver"): self.__connect_callbacks() - def __connect_callbacks(self): + def __connect_callbacks(self) -> None: self.__add_field_button.clicked.connect(self._add_field_button_callback) self.__add_tag_button.clicked.connect(self._add_tag_button_callback) - def _add_field_button_callback(self): + def _add_field_button_callback(self) -> None: raise NotImplementedError() - def _add_tag_button_callback(self): + def _add_tag_button_callback(self) -> None: raise NotImplementedError() - def _set_selection_callback(self): + def _set_selection_callback(self) -> None: raise NotImplementedError() - def set_selection(self, selected: list[int], update_preview: bool = True): + def set_selection(self, selected: list[int], update_preview: bool = True) -> None: """Render the panel widgets with the newest data from the Library. Args: @@ -193,7 +193,7 @@ def add_buttons_enabled(self) -> bool: # needed for the tests return field @add_buttons_enabled.setter - def add_buttons_enabled(self, enabled: bool): + def add_buttons_enabled(self, enabled: bool) -> None: self.__add_field_button.setEnabled(enabled) self.__add_tag_button.setEnabled(enabled) diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py new file mode 100644 index 000000000..d82902855 --- /dev/null +++ b/src/tagstudio/qt/views/search_panel_view.py @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from typing import Any + +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from tagstudio.core.library.alchemy.enums import TagColorEnum +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.models.palette import ColorType, get_tag_color +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.panel_modal import PanelWidget + +CREATE_BUTTON_STYLESHEET: str = f""" + QPushButton{{ + background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)}; + font-weight: 600; + border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)}; + border-radius: 6px; + border-style: dashed; + border-width: 2px; + padding-right: 4px; + padding-bottom: 1px; + padding-left: 4px; + font-size: 13px + }} + + QPushButton::hover{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + }} + + QPushButton::pressed{{ + background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)}; + }} + + QPushButton::focus{{ + border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)}; + outline: none; + }} +""" + + +class SearchPanelView(PanelWidget): + def __init__(self, is_chooser: bool) -> None: + self.is_chooser: bool = is_chooser + super().__init__() + + self.__root_layout = QVBoxLayout(self) + self.__root_layout.setContentsMargins(6, 0, 6, 0) + self.setMinimumSize(300, 400) + + # Limit container + self.__limit_container = QWidget() + self.__root_layout.addWidget(self.__limit_container) + + self.__limit_layout = QHBoxLayout(self.__limit_container) + self.__limit_layout.setContentsMargins(0, 0, 0, 0) + self.__limit_layout.setSpacing(12) + self.__limit_layout.addStretch(1) + + self.__limit_title = QLabel(Translations["home.search.view_limit"]) + self.__limit_layout.addWidget(self.__limit_title) + + # Limit dropdown + self.limit_combobox = QComboBox() + self.__limit_layout.addWidget(self.limit_combobox) + self.__limit_layout.addStretch(1) + + self.limit_combobox.setEditable(False) + + # Search field + self.search_field = QLineEdit() + self.search_field.setObjectName("search_field") + self.__root_layout.addWidget(self.search_field) + + self.search_field.setMinimumSize(QSize(0, 32)) + + # Scroll area + self.__scroll_contents = QWidget() + + self._scroll_layout = QVBoxLayout(self.__scroll_contents) + self._scroll_layout.setContentsMargins(6, 0, 6, 0) + self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.__scroll_area = QScrollArea() + self.__scroll_area.setWidget(self.__scroll_contents) + self.__root_layout.addWidget(self.__scroll_area) + + self.__scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.__scroll_area.setWidgetResizable(True) + self.__scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.__scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + # Create button + self.create_button = QPushButton("") + + if not self.is_chooser: + self.__root_layout.addWidget(self.create_button) + + # Create and add button + self.create_and_add_button_in_layout: bool = False + + self.create_and_add_button = QPushButton() + self.create_and_add_button.setFlat(True) + self.create_and_add_button.setMinimumSize(22, 22) + self.create_and_add_button.setStyleSheet(CREATE_BUTTON_STYLESHEET) + + self.__connect_callbacks() + + @property + def scroll_layout(self) -> QVBoxLayout: + return self._scroll_layout + + @property + def scroll_area(self) -> QScrollArea: + return self.__scroll_area + + def __connect_callbacks(self) -> None: + self.limit_combobox.currentIndexChanged.connect(self._on_limit_changed) + + self.search_field.textChanged.connect(self._on_search_query_changed) + self.search_field.returnPressed.connect( + lambda: self._on_search_query_submitted(self.get_search_query()) + ) + + self.create_button.clicked.connect(self._on_item_create) + self.create_and_add_button.clicked.connect(self._on_item_create_and_add) + + # Limit dropdown + def _on_limit_changed(self, index: int) -> None: + raise NotImplementedError() + + def set_limit_items(self, limit_items: list[tuple[str, int]]) -> None: + # Remove existing limit items + for i in reversed(range(self.limit_combobox.count())): + self.limit_combobox.removeItem(i) + + # Add new limit items + self.limit_combobox.addItems([limit_item[0] for limit_item in limit_items]) + + def get_limit_index(self) -> int: + return self.limit_combobox.currentIndex() + + def set_limit_index(self, index: int) -> None: + self.limit_combobox.setCurrentIndex(index) + + # Search field + def _on_search_query_changed(self, query: str) -> None: + raise NotImplementedError() + + def _on_search_query_submitted(self, query: str) -> None: + raise NotImplementedError() + + def focus_search_box(self, select_all: bool = False) -> None: + self.search_field.setFocus() + if select_all: + self.search_field.selectAll() + + def get_search_query(self) -> str: + return self.search_field.text() + + def clear_search_query(self) -> None: + self.search_field.setText("") + self.focus_search_box() + + # Item list + def _on_item_add(self) -> None: + raise NotImplementedError() + + def scroll_to(self, position: int) -> None: + self.__scroll_area.verticalScrollBar().setValue(position) + + def get_item_widget(self, index: int, library: Library | None) -> Any: + raise NotImplementedError() + + # Create buttons + def _on_item_create(self) -> None: + raise NotImplementedError() + + def _on_item_create_and_add(self) -> None: + raise NotImplementedError() + + def add_create_and_add_button(self) -> None: + if self.create_and_add_button_in_layout: + return + self._scroll_layout.addWidget(self.create_and_add_button) + self.create_and_add_button.show() + self.create_and_add_button_in_layout = True + + def remove_create_and_add_button(self) -> None: + if not self.create_and_add_button_in_layout: + return + self._scroll_layout.removeWidget(self.create_and_add_button) + self.create_and_add_button.hide() + self.create_and_add_button_in_layout = False diff --git a/src/tagstudio/qt/views/tag_search_panel_view.py b/src/tagstudio/qt/views/tag_search_panel_view.py new file mode 100644 index 000000000..1b081703b --- /dev/null +++ b/src/tagstudio/qt/views/tag_search_panel_view.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from PySide6.QtWidgets import QWidget + +from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.mixed.tag_widget import TagWidget +from tagstudio.qt.translations import Translations +from tagstudio.qt.views.search_panel_view import SearchPanelView + + +class TagSearchPanelView(SearchPanelView): + def __init__(self, is_tag_chooser: bool) -> None: + super().__init__(is_tag_chooser) + + self.search_field.setPlaceholderText(Translations["home.search_tags"]) + self.create_button.setText(Translations["tag.create"]) + + def get_item_widget(self, index: int, library: Library | None) -> TagWidget: + """Gets the item widget at a specific index.""" + # Create any new item widgets needed up to the given index + if self._scroll_layout.count() <= index: + while self._scroll_layout.count() <= index: + pad_tag_widget = TagWidget( + tag=None, has_edit=True, has_remove=True, library=library + ) + pad_tag_widget.setHidden(True) + self._scroll_layout.addWidget(pad_tag_widget) + + tag_widget: QWidget = self._scroll_layout.itemAt(index).widget() + assert isinstance(tag_widget, TagWidget) + return tag_widget diff --git a/src/tagstudio/resources/translations/de.json b/src/tagstudio/resources/translations/de.json index 22062b038..db453c216 100644 --- a/src/tagstudio/resources/translations/de.json +++ b/src/tagstudio/resources/translations/de.json @@ -71,9 +71,13 @@ "entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}", "ffmpeg.missing.description": "FFmpeg und/oder FFprobe wurden nicht gefunden. FFmpeg ist für multimediale Wiedergabe und Thumbnails vonnöten.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", + "field.add": "Feld hinzufügen", + "field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?", "field.copy": "Feld kopieren", "field.edit": "Feld bearbeiten", + "field.mixed_data": "Gemischte Daten", "field.paste": "Feld einfügen", + "field.remove": "Feld entfernen", "field_type.datetime": "Datum - Uhrzeit", "field_type.text": "Text", "field_type.unknown": "Unbekannter Typ", @@ -174,10 +178,6 @@ "json_migration.title.new_lib": "

v9.5+ Bibliothek

", "json_migration.title.old_lib": "

v9.4 Bibliothek

", "landing.open_create_library": "Bibliothek öffnen/erstellen {shortcut}", - "library.field.add": "Feld hinzufügen", - "library.field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?", - "library.field.mixed_data": "Gemischte Daten", - "library.field.remove": "Feld entfernen", "library.missing": "Dateiort fehlt", "library.name": "Bibliothek", "library.refresh.scanning.plural": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Dateien durchsucht, {found_count} neue Dateien gefunden", @@ -337,7 +337,7 @@ "tag.search_for_tag": "Nach Tag suchen", "tag.shorthand": "Kürzel", "tag.tag_name_required": "Tag Name (Pflichtfeld)", - "tag.view_limit": "Anzeige-Limit:", + "home.search.view_limit": "Anzeige-Limit:", "tag_manager.title": "Bibliothek Tags", "trash.context.ambiguous": "Datei(en) nach {trash_term} verschieben", "trash.context.plural": "Dateien nach {trash_term} verschieben", diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 911b7072e..1035c1512 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -9,7 +9,6 @@ "app.git": "Git Commit", "app.pre_release": "Pre-Release", "app.title": "{base_title} - Library '{library_dir}'", - "color_manager.title": "Manage Tag Colors", "color.color_border": "Use Secondary Color for Border", "color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?", "color.delete": "Delete Tag", @@ -19,10 +18,11 @@ "color.namespace.delete.title": "Delete Color Namespace", "color.new": "New Color", "color.placeholder": "Color", - "color.primary_required": "Primary Color (Required)", "color.primary": "Primary Color", + "color.primary_required": "Primary Color (Required)", "color.secondary": "Secondary Color", "color.title.no_color": "No Color", + "color_manager.title": "Manage Tag Colors", "dependency.missing.title": "{dependency} Not Found", "drop_import.description": "The following files match file paths that already exist in the library", "drop_import.duplicates_choice.plural": "The following {count} files match file paths that already exist in the library.", @@ -34,26 +34,27 @@ "drop_import.title": "Conflicting File(s)", "edit.color_manager": "Manage Tag Colors", "edit.copy_fields": "Copy Fields", + "edit.field_template_manager": "Manage Field Templates", "edit.paste_fields": "Paste Fields", "edit.tag_manager": "Manage Tags", - "entries.duplicate.merge.label": "Merging Duplicate Entries...", "entries.duplicate.merge": "Merge Duplicate Entries", + "entries.duplicate.merge.label": "Merging Duplicate Entries...", "entries.duplicate.refresh": "Refresh Duplicate Entries", "entries.duplicates.description": "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.", "entries.generic.refresh_alt": "&Refresh", - "entries.generic.remove.removing_count": "Removing {count} Entries...", "entries.generic.remove.removing": "Removing Entries", + "entries.generic.remove.removing_count": "Removing {count} Entries...", "entries.ignored.description": "File entries are considered to be \"ignored\" if they were added to the library before the user's ignore rules (via the '.ts_ignore' file) were updated to exclude it. Ignored files are kept in the library by default in order to prevent accidental data loss when updating ignore rules.", "entries.ignored.ignored_count": "Ignored Entries: {count}", - "entries.ignored.remove_alt": "Remo&ve Ignored Entries", "entries.ignored.remove": "Remove Ignored Entries", + "entries.ignored.remove_alt": "Remo&ve Ignored Entries", "entries.ignored.scanning": "Scanning Library for Ignored Entries...", "entries.ignored.title": "Fix Ignored Entries", + "entries.mirror": "&Mirror", "entries.mirror.confirmation": "Are you sure you want to mirror the following {count} Entries?", "entries.mirror.label": "Mirroring {idx}/{total} Entries...", "entries.mirror.title": "Mirroring Entries", "entries.mirror.window_title": "Mirror Entries", - "entries.mirror": "&Mirror", "entries.remove.plural.confirm": "Are you sure you want to remove these {count} entries from your library? No files on disk will be deleted.", "entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.", "entries.running.dialog.new_entries": "Adding {total} New File Entries...", @@ -63,20 +64,29 @@ "entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked", "entries.unlinked.relink.manual": "&Manual Relink", "entries.unlinked.relink.title": "Relinking Entries", - "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", "entries.unlinked.remove": "Remove Unlinked Entries", + "entries.unlinked.remove_alt": "Remo&ve Unlinked Entries", "entries.unlinked.scanning": "Scanning Library for Unlinked Entries...", "entries.unlinked.search_and_relink": "&Search && Relink", "entries.unlinked.title": "Fix Unlinked Entries", "entries.unlinked.unlinked_count": "Unlinked Entries: {count}", "ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.", "ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}
{ffprobe}: {ffprobe_status}", - "field_type.datetime": "Datetime", - "field_type.text": "Text", - "field_type.unknown": "Unknown Type", + "field.add": "Add Field", + "field.add.plural": "Add Fields", + "field.confirm_remove": "Are you sure you want to remove this \"{name}\" field?", "field.copy": "Copy Field", "field.edit": "Edit Field", + "field.mixed_data": "Mixed Data", "field.paste": "Paste Field", + "field.remove": "Remove Field", + "field_template.all_field_templates": "All Field Templates", + "field_template.create": "Create Field Template", + "field_template.create_add": "Create && Add \"{query}\"", + "field_template_manager.title": "Library Field Templates", + "field_type.datetime": "Datetime", + "field_type.text": "Text", + "field_type.unknown": "Unknown Type", "file.date_added": "Date Added", "file.date_created": "Date Created", "file.date_modified": "Date Modified", @@ -88,14 +98,14 @@ "file.duplicates.dupeguru.no_file": "No DupeGuru File Selected", "file.duplicates.dupeguru.open_file": "Open DupeGuru Results File", "file.duplicates.fix": "Fix Duplicate Files", - "file.duplicates.matches_uninitialized": "Duplicate File Matches: N/A", "file.duplicates.matches": "Duplicate File Matches: {count}", - "file.duplicates.mirror_entries": "&Mirror Entries", + "file.duplicates.matches_uninitialized": "Duplicate File Matches: N/A", "file.duplicates.mirror.description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.", + "file.duplicates.mirror_entries": "&Mirror Entries", "file.duration": "Length", "file.not_found": "File Not Found", - "file.open_file_with": "Open file with", "file.open_file": "Open file", + "file.open_file_with": "Open file with", "file.open_location.generic": "Show file in file explorer", "file.open_location.mac": "Reveal in Finder", "file.open_location.windows": "Show in File Explorer", @@ -106,56 +116,58 @@ "folders_to_tags.open_all": "Open All", "folders_to_tags.title": "Create Tags From Folders", "generic.add": "Add", - "generic.apply_alt": "&Apply", "generic.apply": "Apply", - "generic.cancel_alt": "&Cancel", + "generic.apply_alt": "&Apply", "generic.cancel": "Cancel", + "generic.cancel_alt": "&Cancel", "generic.close": "Close", "generic.continue": "Continue", "generic.copy": "Copy", "generic.cut": "Cut", - "generic.delete_alt": "&Delete", "generic.delete": "Delete", - "generic.done_alt": "&Done", + "generic.delete_alt": "&Delete", "generic.done": "Done", - "generic.edit_alt": "&Edit", + "generic.done_alt": "&Done", "generic.edit": "Edit", + "generic.edit_alt": "&Edit", "generic.filename": "Filename", "generic.missing": "Missing", "generic.navigation.back": "Back", "generic.navigation.next": "Next", "generic.no": "No", "generic.none": "None", - "generic.overwrite_alt": "&Overwrite", "generic.overwrite": "Overwrite", + "generic.overwrite_alt": "&Overwrite", "generic.paste": "Paste", "generic.recent_libraries": "Recent Libraries", - "generic.remove_alt": "&Remove", "generic.remove": "Remove", - "generic.rename_alt": "&Rename", + "generic.remove_alt": "&Remove", "generic.rename": "Rename", + "generic.rename_alt": "&Rename", "generic.reset": "Reset", "generic.save": "Save", - "generic.skip_alt": "&Skip", "generic.skip": "Skip", + "generic.skip_alt": "&Skip", "generic.yes": "Yes", + "home.search": "Search", + "home.search.view_limit": "View Limit:", "home.search_entries": "Search Entries", + "home.search_field_templates": "Search Field Templates", "home.search_library": "Search Library", "home.search_tags": "Search Tags", - "home.search": "Search", + "home.show_hidden_entries": "Show Hidden Entries", + "home.thumbnail_size": "Thumbnail Size", "home.thumbnail_size.extra_large": "Extra Large Thumbnails", "home.thumbnail_size.large": "Large Thumbnails", "home.thumbnail_size.medium": "Medium Thumbnails", "home.thumbnail_size.mini": "Mini Thumbnails", "home.thumbnail_size.small": "Small Thumbnails", - "home.thumbnail_size": "Thumbnail Size", - "home.show_hidden_entries": "Show Hidden Entries", "ignore.open_file": "Show \"{ts_ignore}\" File on Disk", "json_migration.checking_for_parity": "Checking for Parity...", "json_migration.creating_database_tables": "Creating SQL Database Tables...", "json_migration.description": "
Start and preview the results of the library migration process. The converted library will not be used unless you click \"Finish Migration\".

Library data should either have matching values or feature a \"Matched\" label. Values that do not match will be displayed in red and feature a \"(!)\" symbol next to them.
This process may take up to several minutes for larger libraries.
", - "json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.", "json_migration.discrepancies_found": "Library Discrepancies Found", + "json_migration.discrepancies_found.description": "Discrepancies were found between the original and converted library formats. Please review and choose to whether continue with the migration or to cancel.", "json_migration.finish_migration": "Finish Migration", "json_migration.heading.aliases": "Aliases:", "json_migration.heading.colors": "Colors:", @@ -168,43 +180,39 @@ "json_migration.heading.shorthands": "Shorthands:", "json_migration.info.description": "Library save files created with TagStudio versions 9.4 and below will need to be migrated to the new v9.5+ format.

What you need to know:

What's changed: