feat: add basic field template editor

This commit is contained in:
Travis Abendshien
2026-06-24 03:55:20 -07:00
parent 85676c0836
commit 1db6f716ff
16 changed files with 527 additions and 129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}<br>{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": "<h2>v9.5+ Library</h2>",
"json_migration.title.old_lib": "<h2>v9.4 Library</h2>",
"landing.open_create_library": "Open/Create Library {shortcut}",
"library.missing": "Library Location is Missing",
"library.name": "Library",
"library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
"library_info.cleanup": "Cleanup",
"library_info.cleanup.backups": "Library Backups:",
"library_info.cleanup.dupe_files": "Duplicate Files:",
@@ -213,6 +212,13 @@
"library_object.name_required": "Name (Required)",
"library_object.slug": "ID Slug",
"library_object.slug_required": "ID Slug (Required)",
"library.missing": "Library Location is Missing",
"library.name": "Library",
"library.refresh.scanning_preparing": "Scanning Directories for New Files...\nPreparing...",
"library.refresh.scanning.plural": "Scanning Directories for New Files...\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files...\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
@@ -323,11 +329,12 @@
"status.library_version_found": "Found:",
"status.library_version_mismatch": "Library Version Mismatch!",
"status.results": "Results",
"status.results.invalid_syntax": "Invalid Search Syntax:",
"status.results_found": "{count} Results Found ({time_span})",
"status.results.invalid_syntax": "Invalid Search Syntax:",
"tag_manager.title": "Library Tags",
"tag.add": "Add Tag",
"tag.add.plural": "Add Tags",
"tag.add_to_search": "Add to Search",
"tag.add.plural": "Add Tags",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.choose_color": "Choose Tag Color",
@@ -348,7 +355,6 @@
"tag.search_for_tag": "Search for Tag",
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag_manager.title": "Library Tags",
"trash.context.ambiguous": "Move file(s) to {trash_term}",
"trash.context.plural": "Move files to {trash_term}",
"trash.context.singular": "Move file to {trash_term}",

View File

@@ -80,7 +80,7 @@ def test_build_tag_panel_remove_alias_callback(
alias: TagAlias = unwrap(library.get_alias(tag.id, tag.alias_ids[0]))
panel.remove_alias_callback(alias.name, alias.id)
panel.remove_alias_callback(alias.id)
assert len(panel.alias_ids) == 1
assert len(panel.alias_names) == 1