Skip to content

feat: remove and create tags from tag database panel #569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 20, 2024
Merged
1 change: 1 addition & 0 deletions tagstudio/src/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@

TAG_FAVORITE = 1
TAG_ARCHIVED = 0
RESERVED_TAG_IDS = range(0, 999)
33 changes: 33 additions & 0 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,39 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> None:
session.execute(update_stmt)
session.commit()

def remove_tag(self, tag: Tag):
with Session(self.engine, expire_on_commit=False) as session:
try:
subtags = session.scalars(
select(TagSubtag).where(TagSubtag.parent_id == tag.id)
).all()

tags_query = select(Tag).options(
selectinload(Tag.subtags), selectinload(Tag.aliases)
)
tag = session.scalar(tags_query.where(Tag.id == tag.id))

aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id))

for alias in aliases or []:
session.delete(alias)

for subtag in subtags or []:
session.delete(subtag)
session.expunge(subtag)

session.delete(tag)

session.commit()

session.expunge(tag)
return tag

except IntegrityError as e:
logger.exception(e)
session.rollback()
return None

def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None:
with Session(self.engine) as session:
field_ = session.scalars(select(TagBoxField).where(TagBoxField.id == field.id)).one()
Expand Down
56 changes: 55 additions & 1 deletion tagstudio/src/qt/modals/tag_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
QFrame,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from src.core.constants import RESERVED_TAG_IDS
from src.core.library import Library, Tag
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
Expand Down Expand Up @@ -59,8 +62,32 @@ def __init__(self, library: Library):
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)

self.create_tag_button = QPushButton()
self.create_tag_button.setText("Create Tag")
self.create_tag_button.clicked.connect(self.build_tag)

self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.root_layout.addWidget(self.create_tag_button)
self.update_tags()

def build_tag(self):
self.modal = PanelModal(
BuildTagPanel(self.lib),
"New Tag",
"Add Tag",
has_save=True,
)

panel: BuildTagPanel = self.modal.widget
self.modal.saved.connect(
lambda: (
self.lib.add_tag(panel.build_tag(), panel.subtag_ids),
self.modal.hide(),
self.update_tags(),
)
)
self.modal.show()

def on_return(self, text: str):
if text and self.first_tag_id >= 0:
Expand All @@ -84,14 +111,41 @@ def update_tags(self, query: str | None = None):
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
tag_widget = TagWidget(tag, has_edit=True, has_remove=False)

if tag.id in RESERVED_TAG_IDS:
tag_widget = TagWidget(tag, has_edit=False, has_remove=False)
else:
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)

tag_widget.on_edit.connect(lambda checked=False, t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use functools.partial instead of lambda.

Suggested change
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
tag_widget.on_remove.connect(partial(self.remove_tag, tag))

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe, the codebase uses lambda throughout when using slots. Is there a real world benefit to using a partial here (or throughout)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case, partial feels comfortable for me because that makes the intent more explicit and ties directly to the idea of binding tag to self.remove_tag. however i would say it's a matter of style so i'm okay if @DandyDev01 decides to go with lambda here.

Copy link
Contributor Author

@DandyDev01 DandyDev01 Nov 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, I will just stick with the lambda, because it seems that using a partial will not guarantee a memory leak won't occur and @seakrueger didn't run into any leaks while testing #131 (comment). Also, I haven't seen partials used too much elsewhere in the code base, so for consistency and readability (most people already know lambda and might have to lookup partials) lambda make more sense for now.

row.addWidget(tag_widget)
self.scroll_layout.addWidget(container)

self.search_field.setFocus()

def remove_tag(self, tag: Tag):
if tag.id in RESERVED_TAG_IDS:
return

message_box = QMessageBox()
message_box.setWindowTitle("Remove tag")
message_box.setText("Are you sure you want to remove " + tag.name + "?")
message_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) # type: ignore
message_box.setIcon(QMessageBox.Question) # type: ignore

result = message_box.exec()

if result != QMessageBox.Ok: # type: ignore
return

self.lib.remove_tag(tag)
self.update_tags()

def edit_tag(self, tag: Tag):
if tag.id in RESERVED_TAG_IDS:
return

build_tag_panel = BuildTagPanel(self.lib, tag=tag)

self.edit_modal = PanelModal(
Expand Down
6 changes: 5 additions & 1 deletion tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,11 @@ def clear_select_action_callback(self):

def show_tag_database(self):
self.modal = PanelModal(
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
widget=TagDatabasePanel(self.lib),
title="Library Tags",
window_title="Library Tags",
done_callback=self.preview_panel.update_widgets,
has_save=False,
)
self.modal.show()

Expand Down
4 changes: 4 additions & 0 deletions tagstudio/src/qt/widgets/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def __init__(
self.root_layout.setStretch(1, 2)
self.root_layout.addWidget(self.button_container)

def closeEvent(self, event): # noqa: N802
self.done_button.click()
event.accept()


class PanelWidget(QWidget):
"""Used for widgets that go in a modal panel, ex. for editing or searching."""
Expand Down
1 change: 0 additions & 1 deletion tagstudio/src/qt/widgets/thumb_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import cv2
import numpy as np
import pillow_jxl # noqa: F401
import rawpy
import structlog
from mutagen import MutagenError, flac, id3, mp4
Expand Down
11 changes: 11 additions & 0 deletions tagstudio/tests/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ def test_subtags_add(library, generate_tag):
assert tag.subtag_ids


def test_remove_tag(library, generate_tag):
tag = library.add_tag(generate_tag("food", id=123))

assert tag

tag_count = len(library.tags)

library.remove_tag(tag)
assert len(library.tags) == tag_count - 1


@pytest.mark.parametrize("is_exclude", [True, False])
def test_search_filter_extensions(library, is_exclude):
# Given
Expand Down