diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index cf3a4105f..655432b5b 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -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", diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 5b01088e7..83466280e 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -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.""" diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 0a0d7db65..f04f4349f 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -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" @@ -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): @@ -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]): @@ -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. @@ -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 diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index 99cc3de9f..7ad4a36aa 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -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()