Skip to content

feat: copy/paste fields and tags #722

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 9 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@
"select.add_tag_to_selected": "Add Tag to Selected",
"select.all": "Select All",
"select.clear": "Clear Selection",
"edit.copy_fields": "Copy Fields",
"edit.paste_fields": "Paste Fields",
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
"settings.open_library_on_start": "Open Library on Start",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
Expand Down
12 changes: 7 additions & 5 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,19 +1016,21 @@ def add_tag(

def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Add one or more tags to an entry."""
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
tag_ids = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
try:
# TODO: Optimize this by using a single query to update.
for tag_id in tag_ids_:
for tag_id in tag_ids:
try:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
except IntegrityError:
session.rollback()
try:
session.commit()
return True
except IntegrityError as e:
logger.warning("[add_tags_to_entry]", warning=e)
session.rollback()
return False
return True

def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Remove one or more tags from an entry."""
Expand Down
73 changes: 73 additions & 0 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,34 @@ def start(self) -> None:
clear_select_action.setToolTip("Esc")
edit_menu.addAction(clear_select_action)

self.copy_buffer: dict = {"fields": [], "tags": []}

self.copy_fields_action = QAction(menu_bar)
Translations.translate_qobject(self.copy_fields_action, "edit.copy_fields")
self.copy_fields_action.triggered.connect(self.copy_fields_action_callback)
self.copy_fields_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_C,
)
)
self.copy_fields_action.setToolTip("Ctrl+C")
self.copy_fields_action.setEnabled(False)
edit_menu.addAction(self.copy_fields_action)

self.paste_fields_action = QAction(menu_bar)
Translations.translate_qobject(self.paste_fields_action, "edit.paste_fields")
self.paste_fields_action.triggered.connect(self.paste_fields_action_callback)
self.paste_fields_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_V,
)
)
self.paste_fields_action.setToolTip("Ctrl+V")
self.paste_fields_action.setEnabled(False)
edit_menu.addAction(self.paste_fields_action)

self.add_tag_to_selected_action = QAction(menu_bar)
Translations.translate_qobject(
self.add_tag_to_selected_action, "select.add_tag_to_selected"
Expand Down Expand Up @@ -814,7 +842,9 @@ def select_all_action_callback(self):
item.thumb_button.set_selected(True)

self.set_macro_menu_viability()
self.set_clipboard_menu_viability()
self.set_add_to_selected_visibility()

self.preview_panel.update_widgets(update_preview=False)

def clear_select_action_callback(self):
Expand All @@ -824,6 +854,7 @@ def clear_select_action_callback(self):
item.thumb_button.set_selected(False)

self.set_macro_menu_viability()
self.set_clipboard_menu_viability()
self.preview_panel.update_widgets()

def add_tags_to_selected_callback(self, tag_ids: list[int]):
Expand Down Expand Up @@ -1100,6 +1131,36 @@ def _init_thumb_grid(self):
sa.setWidgetResizable(True)
sa.setWidget(self.flow_container)

def copy_fields_action_callback(self):
if len(self.selected) > 0:
entry = self.lib.get_entry_full(self.selected[0])
if entry:
self.copy_buffer["fields"] = entry.fields
self.copy_buffer["tags"] = [tag.id for tag in entry.tags]
self.set_clipboard_menu_viability()

def paste_fields_action_callback(self):
for id in self.selected:
entry = self.lib.get_entry_full(id, with_fields=True, with_tags=False)
if not entry:
continue
existing_fields = entry.fields
for field in self.copy_buffer["fields"]:
exists = False
for e in existing_fields:
if field.type_key == e.type_key and field.value == e.value:
exists = True
if not exists:
self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value)
self.lib.add_tags_to_entry(id, self.copy_buffer["tags"])
if len(self.selected) > 1:
if TAG_ARCHIVED in self.copy_buffer["tags"]:
self.update_badges({BadgeType.ARCHIVED: True}, origin_id=0, add_tags=False)
if TAG_FAVORITE in self.copy_buffer["tags"]:
self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False)
else:
self.preview_panel.update_widgets()

def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
"""Toggle the selection of an item in the Thumbnail Grid.

Expand Down Expand Up @@ -1170,12 +1231,24 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
it.thumb_button.set_selected(False)

self.set_macro_menu_viability()
self.set_clipboard_menu_viability()
self.set_add_to_selected_visibility()

self.preview_panel.update_widgets()

def set_macro_menu_viability(self):
self.autofill_action.setDisabled(not self.selected)

def set_clipboard_menu_viability(self):
if len(self.selected) == 1:
self.copy_fields_action.setEnabled(True)
else:
self.copy_fields_action.setEnabled(False)
if self.selected and (self.copy_buffer["fields"] or self.copy_buffer["tags"]):
self.paste_fields_action.setEnabled(True)
else:
self.paste_fields_action.setEnabled(False)

def set_add_to_selected_visibility(self):
if not self.add_tag_to_selected_action:
return
Expand Down
4 changes: 4 additions & 0 deletions tagstudio/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ class Args:
driver.item_thumbs = []
driver.autofill_action = Mock()

driver.copy_buffer = {"fields": [], "tags": []}
driver.copy_fields_action = Mock()
driver.paste_fields_action = Mock()

driver.lib = library
# TODO - downsize this method and use it
# driver.start()
Expand Down