From 6d935eb5a6277cc09918285552d6a13898a47ad0 Mon Sep 17 00:00:00 2001 From: Dev-Zaius Date: Mon, 4 May 2026 16:45:52 +0200 Subject: [PATCH 1/3] AnkiEDN maintener request: add an auto-protect feature for learned cards - Add auto_protect.py with two functions: * on_card_reviewed(): hook that protects a note when its card transitions to Review state (card.type == 2) * protect_all_learned(): batch that protects all learned notes - Register reviewer_did_answer_card hook in hooks_init() - Add two opt-in checkboxes and batch button in Global Settings - Fix case-insensitive tag detection in note.py (line 429) to handle Anki's automatic tag lowercasing --- plugin_source/auto_protect.py | 89 +++++++++++++++++++ .../crowd_anki/representation/note.py | 5 +- plugin_source/hooks.py | 4 + plugin_source/menu.py | 35 +++++++- 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 plugin_source/auto_protect.py diff --git a/plugin_source/auto_protect.py b/plugin_source/auto_protect.py new file mode 100644 index 0000000..295ea27 --- /dev/null +++ b/plugin_source/auto_protect.py @@ -0,0 +1,89 @@ +from aqt import mw +from typing import Literal +import aqt.utils +from anki.cards import Card + +from .var_defs import PREFIX_PROTECTED_FIELDS +from .utils import get_logger + +logger = get_logger("ankicollab.auto_protect") + +AUTO_PROTECT_TAG = f"{PREFIX_PROTECTED_FIELDS}::All" + + +def _is_auto_protect_on_review_enabled() -> bool: + config = mw.addonManager.getConfig(__name__) + if not config: + return False + return bool(config.get("settings", {}).get("auto_protect_on_review", False)) + + +def _is_auto_protect_learned_enabled() -> bool: + config = mw.addonManager.getConfig(__name__) + if not config: + return False + return bool(config.get("settings", {}).get("auto_protect_learned", False)) + + +def on_card_reviewed(reviewer, card: Card, ease: Literal[1, 2, 3, 4]) -> None: + if not _is_auto_protect_on_review_enabled(): + return + if card.type != 2: + return + + note = card.note() + if any(t.lower() == AUTO_PROTECT_TAG.lower() for t in note.tags): + return + + note.tags.append(AUTO_PROTECT_TAG) + try: + undo_id = mw.col.add_custom_undo_entry("Auto-protect note") + mw.col.update_note(note) + mw.col.merge_undo_entries(undo_id) + logger.info(f"Auto-protected note {note.id}") + except Exception as e: + logger.error(f"Failed to auto-protect note {note.id}: {e}") + + +def protect_all_learned() -> None: + if not mw.col: + aqt.utils.showInfo("Collection non disponible.") + return + + def _task(): + card_ids = mw.col.find_cards("is:review") + note_ids = set() + for cid in card_ids: + card = mw.col.get_card(cid) + note_ids.add(card.nid) + + notes_to_update = [] + for nid in note_ids: + note = mw.col.get_note(nid) + if not any(t.lower() == AUTO_PROTECT_TAG.lower() for t in note.tags): + note.tags.append(AUTO_PROTECT_TAG) + notes_to_update.append(note) + + if notes_to_update: + undo_id = mw.col.add_custom_undo_entry("Protect all learned notes") + mw.col.update_notes(notes_to_update) + mw.col.merge_undo_entries(undo_id) + + return len(notes_to_update) + + def _on_done(future): + try: + count = future.result() + aqt.utils.showInfo( + f"{count} note(s) protégée(s) avec {AUTO_PROTECT_TAG}.\n" + f"Ces notes ne seront plus écrasées lors des mises à jour AnkiCollab." + ) + except Exception as e: + aqt.utils.showInfo(f"Erreur : {e}") + logger.exception("Error in protect_all_learned") + + mw.taskman.with_progress( + task=_task, + on_done=_on_done, + label="Protection des cartes learned...", + ) diff --git a/plugin_source/crowd_anki/representation/note.py b/plugin_source/crowd_anki/representation/note.py index b07558e..b2716b0 100644 --- a/plugin_source/crowd_anki/representation/note.py +++ b/plugin_source/crowd_anki/representation/note.py @@ -426,9 +426,8 @@ def handle_import_config_changes(self, import_config, note_model, field_mapping= if self.anki_object and hasattr(self.anki_object, 'tags'): protected_tags = [ tag for tag in self.anki_object.tags - if tag.startswith(PREFIX_PROTECTED_FIELDS) + if tag.lower().startswith(PREFIX_PROTECTED_FIELDS.lower()) ] - # Preserve DEFAULT_PROTECTED_TAGS (leech, marked, missing-media) from local note # These tags are stripped during export but should be kept during import updates preserved_default_tags = [] @@ -559,4 +558,4 @@ def remove_tags(self, tags): # Option to remove personal tags from notes before ] # Remove any tags that are just whitespace - self.anki_object.tags = [tag for tag in self.anki_object.tags if tag.strip()] \ No newline at end of file + self.anki_object.tags = [tag for tag in self.anki_object.tags if tag.strip()] diff --git a/plugin_source/hooks.py b/plugin_source/hooks.py index f8a3830..d7de47a 100644 --- a/plugin_source/hooks.py +++ b/plugin_source/hooks.py @@ -28,6 +28,7 @@ from .notifications_center import refresh_notifications, register_sync_refresh_hook from .utils import get_logger import requests +from .auto_protect import on_card_reviewed, protect_all_learned logger = get_logger("ankicollab.hooks") @@ -698,3 +699,6 @@ def hooks_init(): # Context Menus (callbacks have internal checks) gui_hooks.browser_sidebar_will_show_context_menu.append(add_sidebar_context_menu) gui_hooks.browser_will_show_context_menu.append(context_menu_bulk_suggest) + + # Reviewer related + gui_hooks.reviewer_did_answer_card.append(on_card_reviewed) diff --git a/plugin_source/menu.py b/plugin_source/menu.py index 0e28eb8..4b3b9e2 100644 --- a/plugin_source/menu.py +++ b/plugin_source/menu.py @@ -35,7 +35,7 @@ from .import_manager import * from .export_manager import handle_export from .media_import import on_media_btn -from .hooks import async_update, update_hooks_for_login_state +from .hooks import async_update, update_hooks_for_login_state, protect_all_learned from .dialogs import LoginDialog from .auth_manager import auth_manager from .notifications_center import ( @@ -916,7 +916,36 @@ def show_global_settings_dialog(parent_dialog): error_reporting_cb.setStyleSheet(checkbox_style) error_reporting_cb.setChecked(bool(settings.get("error_reporting_enabled", False))) error_reporting_cb.setToolTip("Help us fix bugs faster - no personal data is collected") - + + auto_protect_separator = QLabel("─" * 30) + auto_protect_separator.setStyleSheet(f"color: {colors['border']};") + global_layout.addWidget(auto_protect_separator) + + auto_protect_review_cb = QCheckBox("Protects new card from change automatically") + auto_protect_review_cb.setStyleSheet(checkbox_style) + auto_protect_review_cb.setChecked(bool(settings.get("auto_protect_on_review", False))) + auto_protect_review_cb.setToolTip( + "Add Protect All Fields tag to your card immediately after you reviewed it " + "so the note won't be erased on updates" + ) + global_layout.addWidget(auto_protect_review_cb) + + auto_protect_learned_cb = QCheckBox("Protect all Learned cards from change") + auto_protect_learned_cb.setStyleSheet(checkbox_style) + auto_protect_learned_cb.setChecked(bool(settings.get("auto_protect_learned", False))) + auto_protect_learned_cb.setToolTip( + "Add a button to protect all reviewed cards" + ) + global_layout.addWidget(auto_protect_learned_cb) + + protect_now_btn = QPushButton("Protect now all learned cards") + protect_now_btn.setStyleSheet(get_button_style('neutral', 'small')) + protect_now_btn.setToolTip("Launch the protection tag on all learned cards") + protect_now_btn.setEnabled(bool(settings.get("auto_protect_learned", False))) + auto_protect_learned_cb.toggled.connect(protect_now_btn.setEnabled) + protect_now_btn.clicked.connect(protect_all_learned) + global_layout.addWidget(protect_now_btn) + global_layout.addWidget(pull_on_startup_cb) global_layout.addWidget(suspend_new_cards_cb) global_layout.addWidget(move_cards_cb) @@ -1002,6 +1031,8 @@ def save_global_settings(): settings["auto_move_cards"] = move_cards_cb.isChecked() settings["keep_empty_subdecks"] = keep_empty_subdecks_cb.isChecked() settings["error_reporting_enabled"] = error_reporting_cb.isChecked() + settings["auto_protect_on_review"] = auto_protect_review_cb.isChecked() + settings["auto_protect_learned"] = auto_protect_learned_cb.isChecked() mw.addonManager.writeConfig(__name__, strings_data) auth_manager.set_auto_approve(auto_approve_cb.isChecked()) # Apply telemetry setting immediately From b57c3dac663c9d824617901ca713b93b11040710 Mon Sep 17 00:00:00 2001 From: Dev-Zaius Date: Mon, 4 May 2026 19:06:43 +0200 Subject: [PATCH 2/3] fix: translate superior language to crappy one --- plugin_source/auto_protect.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin_source/auto_protect.py b/plugin_source/auto_protect.py index 295ea27..179d17e 100644 --- a/plugin_source/auto_protect.py +++ b/plugin_source/auto_protect.py @@ -47,7 +47,7 @@ def on_card_reviewed(reviewer, card: Card, ease: Literal[1, 2, 3, 4]) -> None: def protect_all_learned() -> None: if not mw.col: - aqt.utils.showInfo("Collection non disponible.") + aqt.utils.showInfo("Collection not available.") return def _task(): @@ -75,15 +75,15 @@ def _on_done(future): try: count = future.result() aqt.utils.showInfo( - f"{count} note(s) protégée(s) avec {AUTO_PROTECT_TAG}.\n" - f"Ces notes ne seront plus écrasées lors des mises à jour AnkiCollab." + f"{count} notes protected with {AUTO_PROTECT_TAG}.\n" + f"the notes won't be erased with new card update." ) except Exception as e: - aqt.utils.showInfo(f"Erreur : {e}") + aqt.utils.showInfo(f"Error : {e}") logger.exception("Error in protect_all_learned") mw.taskman.with_progress( task=_task, on_done=_on_done, - label="Protection des cartes learned...", + label="Protecting learned card", ) From e3db32230599c3fbbacf0b01323d00552d81b22d Mon Sep 17 00:00:00 2001 From: Dev-Zaius Date: Mon, 4 May 2026 19:20:18 +0200 Subject: [PATCH 3/3] fix: make remove_tags(insensitive --- plugin_source/crowd_anki/representation/note.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugin_source/crowd_anki/representation/note.py b/plugin_source/crowd_anki/representation/note.py index b2716b0..3a8d9a3 100644 --- a/plugin_source/crowd_anki/representation/note.py +++ b/plugin_source/crowd_anki/representation/note.py @@ -548,13 +548,15 @@ def remove_tags(self, tags): # Option to remove personal tags from notes before return for personal_tag in tags: - # Remove exact matches - if personal_tag in self.anki_object.tags: - self.anki_object.tags.remove(personal_tag) - # Remove tags that start with the personal_tag prefix (hierarchical tags) + # Remove exact matches (case-insensitive) self.anki_object.tags = [ tag for tag in self.anki_object.tags - if not tag.startswith(f"{personal_tag}::") + if tag.lower() != personal_tag.lower() + ] + # Remove tags that start with the personal_tag prefix (case-insensitive) + self.anki_object.tags = [ + tag for tag in self.anki_object.tags + if not tag.lower().startswith(f"{personal_tag.lower()}::") ] # Remove any tags that are just whitespace