diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py
index eef19596..15bdf89a 100644
--- a/src/tagstudio/core/library/alchemy/library.py
+++ b/src/tagstudio/core/library/alchemy/library.py
@@ -30,6 +30,7 @@ from sqlalchemy import (
Engine,
NullPool,
ScalarResult,
+ Update,
and_,
asc,
create_engine,
@@ -1313,6 +1314,113 @@ class Library:
return direct_tags, descendant_tags
+ def add_field_template(self, field_template: BaseFieldTemplate) -> bool:
+ """Add a new field template to the library."""
+ if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))):
+ logger.error("[Library] BaseFieldTemplate attempted to be added to the library.")
+ return False
+
+ with Session(self.engine) as session:
+ try:
+ session.add(field_template)
+ session.commit()
+ except IntegrityError as e:
+ logger.error(e)
+ session.rollback()
+ return False
+
+ return True
+
+ def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool:
+ """Update a field template in the library.
+
+ old_field_class:str
+ field_template: BaseFieldTemplate
+ """
+ with Session(self.engine) as session:
+ logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}")
+ is_same_type: bool = old_field_type == field_template.class_name
+ try:
+ update_stmt: Update | None = None
+ # If the template is changing type, remove the old one and add the updated
+ # template to the proper table.
+ if not is_same_type:
+ old_template: BaseFieldTemplate | None = None
+ if old_field_type == "TextFieldTemplate":
+ old_template = session.scalar(
+ select(TextFieldTemplate)
+ .where(TextFieldTemplate.id == field_template.id)
+ .limit(1)
+ )
+ elif old_field_type == "DatetimeFieldTemplate":
+ old_template = session.scalar(
+ select(DatetimeFieldTemplate)
+ .where(DatetimeFieldTemplate.id == field_template.id)
+ .limit(1)
+ )
+ if old_template is None:
+ logger.error("[Library] old_template is None")
+ return False
+ session.delete(old_template)
+ session.flush()
+ field_template.id = None # The id should not transfer between tables
+ session.add(field_template)
+ session.commit()
+ # Otherwise, update the existing template in-place
+ elif isinstance(field_template, TextFieldTemplate):
+ update_stmt = (
+ update(TextFieldTemplate)
+ .where(TextFieldTemplate.id == field_template.id)
+ .values(name=field_template.name, is_multiline=field_template.is_multiline)
+ )
+ elif isinstance(field_template, DatetimeFieldTemplate):
+ update_stmt = (
+ update(DatetimeFieldTemplate)
+ .where(DatetimeFieldTemplate.id == field_template.id)
+ .values(name=field_template.name)
+ )
+ if is_same_type:
+ if update_stmt is None:
+ return False
+ session.execute(update_stmt)
+ session.commit()
+
+ except IntegrityError as e:
+ logger.error(e)
+ session.rollback()
+ return False
+
+ return True
+
+ def remove_field_template(self, field_template: BaseFieldTemplate) -> bool:
+ """Remove a field template from the library."""
+ with Session(self.engine) as session:
+ try:
+ session_item: BaseFieldTemplate | None = None
+ if isinstance(field_template, TextFieldTemplate):
+ session_item = session.scalar(
+ select(TextFieldTemplate)
+ .where(TextFieldTemplate.id == field_template.id)
+ .limit(1)
+ )
+ elif isinstance(field_template, DatetimeFieldTemplate):
+ session_item = session.scalar(
+ select(DatetimeFieldTemplate)
+ .where(DatetimeFieldTemplate.id == field_template.id)
+ .limit(1)
+ )
+
+ if session_item is not None:
+ session.delete(session_item)
+ session.commit()
+
+ except IntegrityError as e:
+ logger.error(e)
+ session.rollback()
+ return False
+
+ return True
+
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:
@@ -1320,7 +1428,7 @@ class Library:
search_query: str = name.lower() if name else ""
- def sort_key(template: BaseFieldTemplate) -> tuple:
+ def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]:
text = template.name.lower()
if not search_query:
return (text,)
diff --git a/src/tagstudio/qt/controllers/edit_field_template_modal.py b/src/tagstudio/qt/controllers/edit_field_template_modal.py
new file mode 100644
index 00000000..04edd5c2
--- /dev/null
+++ b/src/tagstudio/qt/controllers/edit_field_template_modal.py
@@ -0,0 +1,116 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+import structlog
+
+from tagstudio.core.library.alchemy.fields import (
+ BaseFieldTemplate,
+ DatetimeFieldTemplate,
+ TextFieldTemplate,
+)
+from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
+
+logger = structlog.get_logger(__name__)
+
+
+class EditFieldTemplateModal(EditFieldTemplateModalView):
+ field_type_map: dict[str, str] = {
+ "TextFieldTemplate": Translations["field_type.text"],
+ "DatetimeFieldTemplate": Translations["field_type.datetime"],
+ }
+
+ def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
+ super().__init__()
+ self.__field_id: int = field_template.id if field_template else -1
+ self.__field_name: str = ""
+ self.__field_type: str | None = field_template.class_name if field_template else None
+ self.__text_field_is_multiline: bool = False
+ self.old_field_type: str = ""
+
+ for k, v in EditFieldTemplateModal.field_type_map.items():
+ self._type_combobox.addItem(v, k)
+
+ self.__connect_callbacks()
+ self.set_field_template(field_template)
+
+ def __connect_callbacks(self) -> None:
+ self.name_field.textChanged.connect(self.__on_name_changed)
+ self._type_combobox.currentIndexChanged.connect(self.__on_type_changed)
+
+ def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None:
+ """Populate the modal with pre-existing field template values, or fallback to defaults."""
+ logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template)
+
+ # Indicates a new template, set default values
+ if field_template is None:
+ self.__field_name = Translations["field_template.new"]
+ self.__field_type = None
+ return
+ # Populate common values for any field type
+ else:
+ self.__field_name = field_template.name
+ self.__field_type = field_template.class_name
+ self.old_field_type = field_template.class_name # Only set on init
+
+ # Update widgets
+ self.name_field.setText(self.__field_name)
+ self._type_combobox.setCurrentIndex(
+ list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name)
+ )
+
+ # Populate values for specific field types
+ if isinstance(field_template, TextFieldTemplate):
+ self.__text_field_is_multiline = field_template.is_multiline
+
+ def __on_name_changed(self):
+ is_empty = not self.name_field.text().strip()
+
+ self.name_field.setStyleSheet(
+ f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
+ if is_empty
+ else ""
+ )
+
+ if self.panel_save_button is not None:
+ self.panel_save_button.setDisabled(is_empty)
+
+ def __on_type_changed(self, index: int):
+ self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
+
+ def build_field_template(self) -> BaseFieldTemplate:
+ if self.__field_type == "TextFieldTemplate":
+ return TextFieldTemplate(
+ id=self.__field_id,
+ name=self.name_field.text(),
+ is_multiline=self.__text_field_is_multiline,
+ )
+ elif self.__field_type == "DatetimeFieldTemplate":
+ return DatetimeFieldTemplate(
+ id=self.__field_id,
+ name=self.name_field.text(),
+ )
+ else:
+ logger.warning(
+ "[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate",
+ field_type=self.__field_type,
+ example=TextFieldTemplate,
+ )
+ return TextFieldTemplate(
+ name=self.name_field.text(),
+ is_multiline=self.__text_field_is_multiline,
+ )
+
+ # def parent_post_init(self):
+ # self.setTabOrder(self.name_field, self.shorthand_field)
+ # self.setTabOrder(self.shorthand_field, self.aliases_add_button)
+ # self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
+ # self.setTabOrder(self.parent_tags_add_button, self.color_button)
+ # self.setTabOrder(self.color_button, unwrap(self.panel_cancel_button))
+ # self.setTabOrder(unwrap(self.panel_cancel_button), unwrap(self.panel_save_button))
+ # self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
+ # self.name_field.selectAll()
+ # self.name_field.setFocus()
+ # self._set_aliases()
diff --git a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py
index f1bff002..360ffee8 100644
--- a/src/tagstudio/qt/controllers/field_template_search_panel_controller.py
+++ b/src/tagstudio/qt/controllers/field_template_search_panel_controller.py
@@ -2,13 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-only
+from collections.abc import Callable
+from typing import override
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Signal
+from PySide6.QtWidgets import QMessageBox
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
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
@@ -23,9 +27,9 @@ class FieldTemplateSearchModal(PanelModal):
self,
library: Library,
is_field_template_chooser: bool = True,
- done_callback=None,
- save_callback=None,
- has_save=False,
+ done_callback: Callable[..., None] | None = None,
+ save_callback: Callable[..., None] | None = None,
+ has_save: bool = False,
) -> None:
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
library,
@@ -60,34 +64,79 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
self._create_and_add_button_label_key = "field_template.create_add"
+ @override
def _get_max_limit(self) -> int:
return len(self.__lib.field_templates)
+ @override
def on_item_create(self) -> None:
# TODO: Allow creation of field templates
pass
+ @override
def on_item_edit(self, item: BaseFieldTemplate) -> None:
- # TODO: Allow creation of field templates
- pass
+ panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
+ modal: PanelModal = PanelModal(
+ panel,
+ item.name,
+ Translations["field_template.edit"],
+ has_save=True,
+ )
+
+ modal.saved.connect(lambda: self.edit_item(panel))
+ modal.show()
+
+ @override
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
- if self.is_chooser:
+
+ message_box = QMessageBox(
+ QMessageBox.Icon.Question,
+ Translations["field_template.delete"],
+ Translations.format("field_template.confirm_delete", field_template_name=item.name),
+ QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
+ )
+
+ result = message_box.exec()
+
+ if result != QMessageBox.StandardButton.Ok:
return
- # TODO: Allow creation of field templates
- pass
+ self.__lib.remove_field_template(item)
+ self.update_items(self.get_search_query())
+ @override
def on_item_create_and_add(self) -> None:
- # TODO: Allow creation of field templates
- pass
+ """Opens "Create Field Template" panel to create a new field template.
+ Populates name field using current search query.
+ """
+ query: str = self.get_search_query()
+ logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
+
+ panel: EditFieldTemplateModal = EditFieldTemplateModal()
+ modal: PanelModal = PanelModal(
+ panel,
+ Translations["field_template.new"],
+ Translations["field.add"],
+ has_save=True,
+ )
+
+ if query.strip():
+ panel.name_field.setText(query)
+
+ modal.saved.connect(lambda: self.create_item(panel, choose_item=True))
+ modal.show()
+
+ @override
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
self.field_template_chosen.emit(item)
+ @override
def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
+ @override
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)
@@ -97,25 +146,39 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
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_edit.disconnect()
+ field_template_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_edit.connect(lambda item_=item: self.on_item_edit(item_))
+ field_template_widget.on_remove.connect(lambda item_=item: self._on_item_remove(item_))
field_template_widget.on_click.connect(
- lambda checked=False, tag=item: self._on_item_chosen(tag)
+ lambda checked=False, item_=item: self._on_item_chosen(item_)
)
- def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
- # TODO: Allow creation of field templates
- pass
+ @override
+ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
+ if isinstance(edit_item_panel, EditFieldTemplateModal):
+ template: BaseFieldTemplate = edit_item_panel.build_field_template()
+ self.__lib.add_field_template(template)
+
+ if choose_item:
+ self._on_item_chosen(template)
+ self.clear_search_query()
+
+ edit_item_panel.hide()
+ self.on_search_query_changed(self.get_search_query())
+
+ @override
def edit_item(self, edit_item_panel: PanelWidget) -> None:
- # TODO: Allow creation of field templates
- pass
+ if not isinstance(edit_item_panel, EditFieldTemplateModal):
+ return
+
+ self.__lib.update_field_template(
+ edit_item_panel.old_field_type, edit_item_panel.build_field_template()
+ )
+ self.update_items(self.search_field.text())
diff --git a/src/tagstudio/qt/controllers/field_template_widget_controller.py b/src/tagstudio/qt/controllers/field_template_widget_controller.py
index 3a8a2aa0..f7c129ee 100644
--- a/src/tagstudio/qt/controllers/field_template_widget_controller.py
+++ b/src/tagstudio/qt/controllers/field_template_widget_controller.py
@@ -1,6 +1,11 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
+from typing import override
+
+from PySide6.QtCore import QEvent, Qt
+from PySide6.QtGui import QAction, QEnterEvent
+
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
@@ -12,6 +17,14 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
self.__field_template: BaseFieldTemplate | None = None
+ # Add actions
+ edit_action = QAction(self)
+ edit_action.setText(Translations["generic.edit"])
+ edit_action.triggered.connect(self.on_edit.emit)
+ self.addAction(edit_action)
+
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
+
def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
self.__field_template = field_template
@@ -20,3 +33,15 @@ class FieldTemplateWidget(FieldTemplateWidgetView):
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]})")
+
+ @override
+ def enterEvent(self, event: QEnterEvent) -> None:
+ self._delete_button.setHidden(False)
+ self.update()
+ return super().enterEvent(event)
+
+ @override
+ def leaveEvent(self, event: QEvent) -> None:
+ self._delete_button.setHidden(True)
+ self.update()
+ return super().leaveEvent(event)
diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py
index a90a9b78..2b29d0e9 100644
--- a/src/tagstudio/qt/controllers/search_panel_controller.py
+++ b/src/tagstudio/qt/controllers/search_panel_controller.py
@@ -2,16 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-only
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, override
import structlog
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Signal
from PySide6.QtGui import QShowEvent
-from PySide6.QtWidgets import QVBoxLayout
+from PySide6.QtWidgets import QVBoxLayout, QWidget
+from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
-from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
+from tagstudio.qt.views.panel_modal import PanelWidget
from tagstudio.qt.views.search_panel_view import SearchPanelView
logger = structlog.get_logger(__name__)
@@ -22,7 +23,7 @@ if TYPE_CHECKING:
def _item_id(item: object) -> int:
- item_id: Any = getattr(item, "id") # noqa: B009
+ item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_id, int):
return item_id
@@ -31,7 +32,7 @@ def _item_id(item: object) -> int:
def _item_name(item: object) -> str:
- item_name: Any = getattr(item, "name") # noqa: B009
+ item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
if isinstance(item_name, str):
return item_name
@@ -93,15 +94,13 @@ class SearchPanel[T](PanelWidget):
def clear_search_query(self) -> None:
self.view.clear_search_query()
- def get_item_widget(self, index: int, library: Any):
+ def get_item_widget(self, index: int, library: Library):
return self.view.get_item_widget(index, library)
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.view.get_limit_index():
self.view.set_limit_index(index)
@@ -130,8 +129,8 @@ class SearchPanel[T](PanelWidget):
# Focus search field if no query
if not query:
self.search_field.setFocus()
- parent = self.parentWidget()
- if parent is not None:
+ parent: QWidget | None = self.parentWidget()
+ if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
parent.hide()
return
@@ -147,16 +146,16 @@ class SearchPanel[T](PanelWidget):
def on_item_create(self) -> None:
raise NotImplementedError()
- def on_item_edit(self, item: T) -> None:
+ def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
- def _on_item_remove(self, item: T) -> None:
+ def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def on_item_create_and_add(self) -> None:
raise NotImplementedError()
- def _on_item_chosen(self, item: T) -> None:
+ def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
def _is_excluded(self, item: T) -> bool:
@@ -215,18 +214,20 @@ class SearchPanel[T](PanelWidget):
if query and query.strip():
self.view.add_create_and_add_button()
- def search_items(self, query: str) -> tuple[list[T], list[T]]:
+ def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
- def set_item_widget(self, item: T | None, index: int) -> None:
+ def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
+ @override
def showEvent(self, event: QShowEvent) -> None: # noqa N802
self.update_items()
self.view.scroll_to(0)
self.view.clear_search_query()
return super().showEvent(event)
+ @override
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.
@@ -236,8 +237,8 @@ class SearchPanel[T](PanelWidget):
else:
self.view.focus_search_box(select_all=True)
- def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
+ def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
- def edit_item(self, edit_item_panel: PanelWidget) -> None:
+ def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
raise NotImplementedError()
diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py
index 161c7f08..1f0684f6 100644
--- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py
+++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py
@@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-only
-from typing import TYPE_CHECKING
+from collections.abc import Callable
+from typing import TYPE_CHECKING, override
from warnings import catch_warnings
import structlog
@@ -33,9 +34,9 @@ class TagSearchModal(PanelModal):
library: Library,
exclude: list[int] | None = None,
is_tag_chooser: bool = True,
- done_callback=None,
- save_callback=None,
- has_save=False,
+ done_callback: Callable[..., None] | None = None,
+ save_callback: Callable[..., None] | None = None,
+ has_save: bool = False,
):
self.tsp = TagSearchPanel(
library,
@@ -70,28 +71,31 @@ class TagSearchPanel(SearchPanel[Tag]):
self._unlimited_limit_item_label = Translations["tag.all_tags"]
self._create_and_add_button_label_key = "tag.create_add"
+ @override
def _get_max_limit(self) -> int:
return len(self.__lib.tags)
+ @override
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,
+ panel: BuildTagPanel = BuildTagPanel(self.__lib)
+ modal: PanelModal = PanelModal(
+ panel,
Translations["tag.new"],
has_save=True,
)
if query.strip():
- build_tag_panel.name_field.setText(query)
+ panel.name_field.setText(query)
- build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal))
- build_tag_modal.show()
+ modal.saved.connect(lambda: self.create_item(panel))
+ modal.show()
+ @override
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
@@ -107,6 +111,7 @@ class TagSearchPanel(SearchPanel[Tag]):
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
edit_tag_modal.show()
+ @override
def _on_item_remove(self, item: Tag) -> None:
if self.is_chooser:
return
@@ -115,22 +120,26 @@ class TagSearchPanel(SearchPanel[Tag]):
return
message_box = QMessageBox(
- QMessageBox.Question, # type: ignore
+ QMessageBox.Icon.Question,
Translations["tag.remove"],
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
- QMessageBox.Ok | QMessageBox.Cancel, # type: ignore
+ QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
)
result = message_box.exec()
- if result != QMessageBox.Ok: # type: ignore
+ if result != QMessageBox.StandardButton.Ok:
return
self.__lib.remove_tag(item.id)
self.update_items(self.get_search_query())
+ @override
def on_item_create_and_add(self) -> None:
- """Opens "Create Tag" panel to create and add a new tag with given name."""
+ """Opens "Create Tag" panel to create and add a new tag.
+
+ Populates name field using current search query.
+ """
# TODO: Move this to a top-level import
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
@@ -138,26 +147,29 @@ class TagSearchPanel(SearchPanel[Tag]):
logger.info("Create and Add Tag", name=query)
- build_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib)
- build_tag_modal: PanelModal = PanelModal(
- build_tag_panel,
+ panel: BuildTagPanel = BuildTagPanel(self.__lib)
+ modal: PanelModal = PanelModal(
+ panel,
Translations["tag.new"],
Translations["tag.add"],
has_save=True,
)
if query.strip():
- build_tag_panel.name_field.setText(query)
+ panel.name_field.setText(query)
- build_tag_modal.saved.connect(lambda: self.create_item(build_tag_modal, choose_item=True))
- build_tag_modal.show()
+ modal.saved.connect(lambda: self.create_item(panel, choose_item=True))
+ modal.show()
+ @override
def _on_item_chosen(self, item: Tag) -> None:
self.item_chosen.emit(item.id)
+ @override
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
+ @override
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)
@@ -195,39 +207,41 @@ class TagSearchPanel(SearchPanel[Tag]):
else:
tag_widget.search_for_tag_action.setEnabled(False)
- def create_item(self, build_item_modal: PanelModal, choose_item: bool = False) -> None:
+ @override
+ def create_item(self, edit_item_panel: PanelWidget, 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()
+ if isinstance(edit_item_panel, BuildTagPanel):
+ tag: Tag = edit_item_panel.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,
+ parent_ids=edit_item_panel.parent_ids,
+ alias_names=edit_item_panel.alias_names,
+ alias_ids=edit_item_panel.alias_ids,
)
if choose_item:
self._on_item_chosen(tag)
self.clear_search_query()
- build_item_modal.hide()
+ edit_item_panel.hide()
self.on_search_query_changed(self.get_search_query())
+ @override
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:
diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py
index f42d14d6..0f8a860a 100644
--- a/src/tagstudio/qt/mixed/build_tag.py
+++ b/src/tagstudio/qt/mixed/build_tag.py
@@ -3,6 +3,7 @@
import sys
+from collections.abc import Callable
from typing import cast, override
import structlog
@@ -47,13 +48,19 @@ logger = structlog.get_logger(__name__)
class CustomTableItem(QLineEdit):
- def __init__(self, text, on_return, on_backspace, parent=None):
+ def __init__(
+ self,
+ text: str,
+ on_return: Callable[..., None],
+ on_backspace: Callable[..., None],
+ parent: QWidget | None = None,
+ ):
super().__init__(parent)
self.setText(text)
- self.on_return = on_return
- self.on_backspace = on_backspace
+ self.on_return: Callable[..., None] = on_return
+ self.on_backspace: Callable[..., None] = on_backspace
- def set_id(self, id):
+ def set_id(self, id: int):
self.id = id
@override
@@ -301,7 +308,7 @@ class BuildTagPanel(PanelWidget):
self.parent_ids: set[int] = set()
self.alias_ids: list[int] = []
self.alias_names: list[str] = []
- self.new_alias_names: dict = {}
+ self.new_alias_names: dict[int, str] = {}
self.new_item_id = sys.maxsize
self.set_tag(tag or Tag(name=Translations["tag.new"]))
@@ -317,7 +324,7 @@ class BuildTagPanel(PanelWidget):
item = self.aliases_table.cellWidget(i, 1)
if (
isinstance(item, CustomTableItem)
- and cast(CustomTableItem, item).id == cast(CustomTableItem, focused_widget).id
+ and item.id == cast(CustomTableItem, focused_widget).id
):
cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click()
remove_row = i
@@ -359,7 +366,7 @@ class BuildTagPanel(PanelWidget):
item = self.aliases_table.cellWidget(row, 1)
item.setFocus()
- def remove_alias_callback(self, alias_name: str, alias_id: int):
+ def remove_alias_callback(self, alias_id: int):
logger.info("remove_alias_callback")
self.alias_ids.remove(alias_id)
@@ -530,7 +537,7 @@ class BuildTagPanel(PanelWidget):
for alias_id in self.alias_ids:
alias = self.lib.get_alias(self.tag.id, alias_id)
- alias_name = alias.name if alias else self.new_alias_names[alias_id]
+ alias_name: str = alias.name if alias else self.new_alias_names[alias_id]
# handel when an alias name changes
if alias_id in self.new_alias_names:
@@ -539,9 +546,7 @@ class BuildTagPanel(PanelWidget):
self.alias_names.append(alias_name)
remove_btn = QPushButton("-")
- remove_btn.clicked.connect(
- lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id)
- )
+ remove_btn.clicked.connect(lambda id=alias_id: self.remove_alias_callback(id))
row = self.aliases_table.rowCount()
new_item = CustomTableItem(alias_name, self.enter, self.backspace)
@@ -619,6 +624,7 @@ class BuildTagPanel(PanelWidget):
logger.info("built tag", tag=tag)
return tag
+ @override
def parent_post_init(self):
self.setTabOrder(self.name_field, self.shorthand_field)
self.setTabOrder(self.shorthand_field, self.aliases_add_button)
diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/tagstudio/qt/mixed/tag_widget.py b/src/tagstudio/qt/mixed/tag_widget.py
index 5907fd18..03a0db0f 100644
--- a/src/tagstudio/qt/mixed/tag_widget.py
+++ b/src/tagstudio/qt/mixed/tag_widget.py
@@ -156,15 +156,15 @@ class TagWidget(QWidget):
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
- 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.remove_button.clicked.connect(self.on_remove.emit)
- self.remove_button.setHidden(True)
- self.inner_layout.addWidget(self.remove_button)
+ self._delete_button = QPushButton(self)
+ self._delete_button.setFlat(True)
+ self._delete_button.setText("–")
+ self._delete_button.setHidden(True)
+ self._delete_button.setMinimumSize(22, 22)
+ self._delete_button.setMaximumSize(22, 22)
+ self._delete_button.clicked.connect(self.on_remove.emit)
+ self._delete_button.setHidden(True)
+ self.inner_layout.addWidget(self._delete_button)
self.inner_layout.addStretch(1)
self.bg_button.setLayout(self.inner_layout)
@@ -236,7 +236,7 @@ class TagWidget(QWidget):
f"}}"
)
- self.remove_button.setStyleSheet(
+ self._delete_button.setStyleSheet(
f"QPushButton{{"
f"color: rgba{primary_color.toTuple()};"
f"background: rgba{text_color.toTuple()};"
@@ -275,14 +275,14 @@ class TagWidget(QWidget):
@override
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
- self.remove_button.setHidden(False)
+ self._delete_button.setHidden(False)
self.update()
return super().enterEvent(event)
@override
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
- self.remove_button.setHidden(True)
+ self._delete_button.setHidden(True)
self.update()
return super().leaveEvent(event)
diff --git a/src/tagstudio/qt/translations.py b/src/tagstudio/qt/translations.py
index 44946a3f..60f0dab1 100644
--- a/src/tagstudio/qt/translations.py
+++ b/src/tagstudio/qt/translations.py
@@ -83,7 +83,7 @@ class Translator:
for k, v in self._strings.items():
self._strings[k] = remove_mnemonic_marker(v)
- def __format(self, text: str, **kwargs) -> str:
+ def __format(self, text: str, **kwargs: ...) -> str:
try:
return text.format(**kwargs)
except (KeyError, ValueError):
@@ -93,11 +93,11 @@ class Translator:
kwargs=kwargs,
language=self.__lang,
)
- params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}")
+ params: defaultdict[str, Any] = defaultdict(lambda: "{unknown_key}") # pyright: ignore[reportExplicitAny]
params.update(kwargs)
return text.format_map(params)
- def format(self, key: str, **kwargs) -> str:
+ def format(self, key: str, **kwargs: ...) -> str:
return self.__format(self[key], **kwargs)
def __getitem__(self, key: str) -> str:
diff --git a/src/tagstudio/qt/views/edit_field_template_modal_view.py b/src/tagstudio/qt/views/edit_field_template_modal_view.py
new file mode 100644
index 00000000..71ef3414
--- /dev/null
+++ b/src/tagstudio/qt/views/edit_field_template_modal_view.py
@@ -0,0 +1,59 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+import structlog
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QComboBox,
+ QLabel,
+ QLineEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+from tagstudio.qt.translations import Translations
+from tagstudio.qt.views.panel_modal import PanelWidget
+
+logger = structlog.get_logger(__name__)
+
+
+class EditFieldTemplateModalView(PanelWidget):
+ def __init__(self) -> None:
+ super().__init__()
+
+ # Layout Init
+ self.setMinimumSize(460, 200)
+ self.root_layout = QVBoxLayout(self)
+ self.root_layout.setContentsMargins(6, 0, 6, 0)
+ self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ # Field Name -------------------------------------------------------------------------------
+ self.__name_widget = QWidget()
+ self.__name_layout = QVBoxLayout(self.__name_widget)
+ self.__name_layout.setStretch(1, 1)
+ self.__name_layout.setContentsMargins(0, 0, 0, 0)
+ self.__name_layout.setSpacing(0)
+ self.__name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
+ self.__name_title = QLabel(Translations["field.name"])
+ self.__name_layout.addWidget(self.__name_title)
+ self.name_field = QLineEdit()
+ self.name_field.setFixedHeight(24)
+ self.name_field.setPlaceholderText(Translations["field.field_name_required"])
+ self.__name_layout.addWidget(self.name_field)
+
+ # Field Type -------------------------------------------------------------------------------
+ self.__type_widget = QWidget()
+ self.__type_layout = QVBoxLayout(self.__type_widget)
+ self.__type_layout.setStretch(1, 1)
+ self.__type_layout.setContentsMargins(0, 0, 0, 0)
+ self.__type_layout.setSpacing(0)
+ self.__type_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
+ self.__type_title = QLabel(Translations["field.type"])
+ self.__type_layout.addWidget(self.__type_title)
+ self._type_combobox = QComboBox()
+ self.__type_layout.addWidget(self._type_combobox)
+
+ # Add Widgets to Layout ====================================================================
+ self.root_layout.addWidget(self.__name_widget)
+ self.root_layout.addWidget(self.__type_widget)
diff --git a/src/tagstudio/qt/views/field_template_widget_view.py b/src/tagstudio/qt/views/field_template_widget_view.py
index f4073ff5..46c2285b 100644
--- a/src/tagstudio/qt/views/field_template_widget_view.py
+++ b/src/tagstudio/qt/views/field_template_widget_view.py
@@ -72,18 +72,18 @@ class FieldTemplateWidgetView(QWidget):
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._delete_button = QPushButton(self)
+ self._delete_button.setFlat(True)
+ self._delete_button.setText("–")
+ self._delete_button.setHidden(True)
+ self._delete_button.setMinimumSize(22, 22)
+ self._delete_button.setMaximumSize(22, 22)
- self.__inner_layout.addWidget(self.__remove_button)
+ self.__inner_layout.addWidget(self._delete_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)
+ self._delete_button.clicked.connect(self.on_remove.emit)
diff --git a/src/tagstudio/qt/views/panel_modal.py b/src/tagstudio/qt/views/panel_modal.py
index b754c118..3d9a3176 100644
--- a/src/tagstudio/qt/views/panel_modal.py
+++ b/src/tagstudio/qt/views/panel_modal.py
@@ -25,8 +25,8 @@ class PanelModal(QWidget):
widget: "PanelWidget",
title: str = "",
window_title: str | None = None,
- done_callback: Callable[[], None] | None = None,
- save_callback: Callable[[str], None] | None = None,
+ done_callback: Callable[..., None] | None = None,
+ save_callback: Callable[..., None] | None = None,
has_save: bool = False,
):
# [Done]
@@ -128,7 +128,7 @@ class PanelWidget(QWidget):
def parent_post_init(self) -> None:
pass
- def add_callback(self, callback: Callable[[], None], event: str = "returnPressed"):
+ def add_callback(self, callback: Callable[..., None], event: str = "returnPressed"): # pyright: ignore[reportUnusedParameter]
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
@override
diff --git a/src/tagstudio/qt/views/search_panel_view.py b/src/tagstudio/qt/views/search_panel_view.py
index 50fd527b..4114b513 100644
--- a/src/tagstudio/qt/views/search_panel_view.py
+++ b/src/tagstudio/qt/views/search_panel_view.py
@@ -130,7 +130,7 @@ class SearchPanelView(PanelWidget):
def scroll_area(self) -> QScrollArea:
return self.__scroll_area
- def connect_callbacks(self, controller: "SearchPanel[Any]") -> None:
+ def connect_callbacks(self, controller: "SearchPanel[Any]") -> None: # pyright: ignore[reportExplicitAny]
self.limit_combobox.currentIndexChanged.connect(controller.on_limit_changed)
self.search_field.textChanged.connect(controller.on_search_query_changed)
@@ -171,7 +171,7 @@ class SearchPanelView(PanelWidget):
def scroll_to(self, position: int) -> None:
self.__scroll_area.verticalScrollBar().setValue(position)
- def get_item_widget(self, index: int, library: Library | None) -> Any:
+ def get_item_widget(self, index: int, library: Library | None) -> Any: # pyright: ignore[reportUnusedParameter, reportExplicitAny]
raise NotImplementedError()
def add_create_and_add_button(self) -> None:
diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json
index 1035c151..f847f198 100644
--- a/src/tagstudio/resources/translations/en.json
+++ b/src/tagstudio/resources/translations/en.json
@@ -9,6 +9,7 @@
"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",
@@ -22,7 +23,6 @@
"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,7 +34,6 @@
"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": "Merge Duplicate Entries",
@@ -72,21 +71,28 @@
"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_template_manager.title": "Library Field Templates",
+ "field_template.all_field_templates": "All Field Templates",
+ "field_template.confirm_delete": "Are you sure you want to delete the field template \"{field_template_name}\"?",
+ "field_template.create": "Create Field Template",
+ "field_template.create_add": "Create && Add \"{query}\"",
+ "field_template.delete": "Delete Field Template",
+ "field_template.edit": "Edit Field Template",
+ "field_template.new": "New Field Template",
+ "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.field_name_required": "Field Name (Required)",
"field.mixed_data": "Mixed Data",
+ "field.name": "Name",
"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",
+ "field.type": "Type",
"file.date_added": "Date Added",
"file.date_created": "Date Created",
"file.date_modified": "Date Modified",
@@ -100,8 +106,8 @@
"file.duplicates.fix": "Fix Duplicate Files",
"file.duplicates.matches": "Duplicate File Matches: {count}",
"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.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.duration": "Length",
"file.not_found": "File Not Found",
"file.open_file": "Open file",
@@ -150,11 +156,11 @@
"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.view_limit": "View Limit:",
"home.show_hidden_entries": "Show Hidden Entries",
"home.thumbnail_size": "Thumbnail Size",
"home.thumbnail_size.extra_large": "Extra Large Thumbnails",
@@ -187,13 +193,6 @@
"json_migration.title.new_lib": "