Skip to content

Commit 225fe98

Browse files
authored
feat: port copy/paste fields and tags to sql (#722)
* implement copy/paste for fields and tags * fix tests * fixed badge refresh on pasting * renamed translation field * ignore duplicate tags on insert * fixes * break into several statements to satisfy tests * chore: format with ruff
1 parent 496c87c commit 225fe98

File tree

4 files changed

+86
-5
lines changed

4 files changed

+86
-5
lines changed

tagstudio/resources/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@
186186
"select.add_tag_to_selected": "Add Tag to Selected",
187187
"select.all": "Select All",
188188
"select.clear": "Clear Selection",
189+
"edit.copy_fields": "Copy Fields",
190+
"edit.paste_fields": "Paste Fields",
189191
"settings.clear_thumb_cache.title": "Clear Thumbnail Cache",
190192
"settings.open_library_on_start": "Open Library on Start",
191193
"settings.show_filenames_in_grid": "Show Filenames in Grid",

tagstudio/src/core/library/alchemy/library.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,19 +1016,21 @@ def add_tag(
10161016

10171017
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
10181018
"""Add one or more tags to an entry."""
1019-
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
1019+
tag_ids = [tag_ids] if isinstance(tag_ids, int) else tag_ids
10201020
with Session(self.engine, expire_on_commit=False) as session:
1021-
try:
1022-
# TODO: Optimize this by using a single query to update.
1023-
for tag_id in tag_ids_:
1021+
for tag_id in tag_ids:
1022+
try:
10241023
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
10251024
session.flush()
1025+
except IntegrityError:
1026+
session.rollback()
1027+
try:
10261028
session.commit()
1027-
return True
10281029
except IntegrityError as e:
10291030
logger.warning("[add_tags_to_entry]", warning=e)
10301031
session.rollback()
10311032
return False
1033+
return True
10321034

10331035
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
10341036
"""Remove one or more tags from an entry."""

tagstudio/src/qt/ts_qt.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,34 @@ def start(self) -> None:
426426
clear_select_action.setToolTip("Esc")
427427
edit_menu.addAction(clear_select_action)
428428

429+
self.copy_buffer: dict = {"fields": [], "tags": []}
430+
431+
self.copy_fields_action = QAction(menu_bar)
432+
Translations.translate_qobject(self.copy_fields_action, "edit.copy_fields")
433+
self.copy_fields_action.triggered.connect(self.copy_fields_action_callback)
434+
self.copy_fields_action.setShortcut(
435+
QtCore.QKeyCombination(
436+
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
437+
QtCore.Qt.Key.Key_C,
438+
)
439+
)
440+
self.copy_fields_action.setToolTip("Ctrl+C")
441+
self.copy_fields_action.setEnabled(False)
442+
edit_menu.addAction(self.copy_fields_action)
443+
444+
self.paste_fields_action = QAction(menu_bar)
445+
Translations.translate_qobject(self.paste_fields_action, "edit.paste_fields")
446+
self.paste_fields_action.triggered.connect(self.paste_fields_action_callback)
447+
self.paste_fields_action.setShortcut(
448+
QtCore.QKeyCombination(
449+
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
450+
QtCore.Qt.Key.Key_V,
451+
)
452+
)
453+
self.paste_fields_action.setToolTip("Ctrl+V")
454+
self.paste_fields_action.setEnabled(False)
455+
edit_menu.addAction(self.paste_fields_action)
456+
429457
self.add_tag_to_selected_action = QAction(menu_bar)
430458
Translations.translate_qobject(
431459
self.add_tag_to_selected_action, "select.add_tag_to_selected"
@@ -814,7 +842,9 @@ def select_all_action_callback(self):
814842
item.thumb_button.set_selected(True)
815843

816844
self.set_macro_menu_viability()
845+
self.set_clipboard_menu_viability()
817846
self.set_add_to_selected_visibility()
847+
818848
self.preview_panel.update_widgets(update_preview=False)
819849

820850
def clear_select_action_callback(self):
@@ -824,6 +854,7 @@ def clear_select_action_callback(self):
824854
item.thumb_button.set_selected(False)
825855

826856
self.set_macro_menu_viability()
857+
self.set_clipboard_menu_viability()
827858
self.preview_panel.update_widgets()
828859

829860
def add_tags_to_selected_callback(self, tag_ids: list[int]):
@@ -1100,6 +1131,36 @@ def _init_thumb_grid(self):
11001131
sa.setWidgetResizable(True)
11011132
sa.setWidget(self.flow_container)
11021133

1134+
def copy_fields_action_callback(self):
1135+
if len(self.selected) > 0:
1136+
entry = self.lib.get_entry_full(self.selected[0])
1137+
if entry:
1138+
self.copy_buffer["fields"] = entry.fields
1139+
self.copy_buffer["tags"] = [tag.id for tag in entry.tags]
1140+
self.set_clipboard_menu_viability()
1141+
1142+
def paste_fields_action_callback(self):
1143+
for id in self.selected:
1144+
entry = self.lib.get_entry_full(id, with_fields=True, with_tags=False)
1145+
if not entry:
1146+
continue
1147+
existing_fields = entry.fields
1148+
for field in self.copy_buffer["fields"]:
1149+
exists = False
1150+
for e in existing_fields:
1151+
if field.type_key == e.type_key and field.value == e.value:
1152+
exists = True
1153+
if not exists:
1154+
self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value)
1155+
self.lib.add_tags_to_entry(id, self.copy_buffer["tags"])
1156+
if len(self.selected) > 1:
1157+
if TAG_ARCHIVED in self.copy_buffer["tags"]:
1158+
self.update_badges({BadgeType.ARCHIVED: True}, origin_id=0, add_tags=False)
1159+
if TAG_FAVORITE in self.copy_buffer["tags"]:
1160+
self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False)
1161+
else:
1162+
self.preview_panel.update_widgets()
1163+
11031164
def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
11041165
"""Toggle the selection of an item in the Thumbnail Grid.
11051166
@@ -1170,12 +1231,24 @@ def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
11701231
it.thumb_button.set_selected(False)
11711232

11721233
self.set_macro_menu_viability()
1234+
self.set_clipboard_menu_viability()
11731235
self.set_add_to_selected_visibility()
1236+
11741237
self.preview_panel.update_widgets()
11751238

11761239
def set_macro_menu_viability(self):
11771240
self.autofill_action.setDisabled(not self.selected)
11781241

1242+
def set_clipboard_menu_viability(self):
1243+
if len(self.selected) == 1:
1244+
self.copy_fields_action.setEnabled(True)
1245+
else:
1246+
self.copy_fields_action.setEnabled(False)
1247+
if self.selected and (self.copy_buffer["fields"] or self.copy_buffer["tags"]):
1248+
self.paste_fields_action.setEnabled(True)
1249+
else:
1250+
self.paste_fields_action.setEnabled(False)
1251+
11791252
def set_add_to_selected_visibility(self):
11801253
if not self.add_tag_to_selected_action:
11811254
return

tagstudio/tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ class Args:
146146
driver.item_thumbs = []
147147
driver.autofill_action = Mock()
148148

149+
driver.copy_buffer = {"fields": [], "tags": []}
150+
driver.copy_fields_action = Mock()
151+
driver.paste_fields_action = Mock()
152+
149153
driver.lib = library
150154
# TODO - downsize this method and use it
151155
# driver.start()

0 commit comments

Comments
 (0)