From 7157eee81ecddf157f5d4cd2f9b86b43a10cb753 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Mon, 13 Apr 2026 20:46:23 +0200 Subject: [PATCH 01/16] Support MCAT note type rename --- ankiweb_description.html | 2 +- src/anking_notetypes/gui/config_window.py | 24 ++++++++-- .../gui/extra_notetype_versions.py | 24 ++++++++-- .../note_types/AnKingMCAT/AnKingMCAT.json | 4 +- .../note_types/AnKingMCAT/README.md | 2 +- src/anking_notetypes/notetype_renames.py | 42 ++++++++++++++++ .../notetype_setting_definitions.py | 31 ++++++++++-- src/anking_notetypes/utils.py | 14 +++++- tests/expected.json | 2 +- tests/test_unit.py | 48 +++++++++++++++++++ 10 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 src/anking_notetypes/notetype_renames.py diff --git a/ankiweb_description.html b/ankiweb_description.html index 22556df..4351f30 100644 --- a/ankiweb_description.html +++ b/ankiweb_description.html @@ -106,7 +106,7 @@ 2024-01-19: Fix bug in Sketchy-Cloze note type front template -2024-04-08: Add the AnKingAnkisthesia note type - Add a reset button for general settings - Fix cloze color when using b/u/i styling - Fix extra color when using italics styling - Add night mode tables to AnKingMCAT +2024-04-08: Add the AnKingAnkisthesia note type - Add a reset button for general settings - Fix cloze color when using b/u/i styling - Fix extra color when using italics styling - Add night mode tables to AnKing MCAT 2024-04-18: Revert img max height/width for IO one by one to 100% - Change min-width of images in Extra field to 0% diff --git a/src/anking_notetypes/gui/config_window.py b/src/anking_notetypes/gui/config_window.py index 3327659..d2188a4 100644 --- a/src/anking_notetypes/gui/config_window.py +++ b/src/anking_notetypes/gui/config_window.py @@ -11,6 +11,7 @@ from ..ankiaddonconfig import ConfigManager, ConfigWindow from ..ankiaddonconfig.window import ConfigLayout from ..constants import ANKIHUB_NOTETYPE_RE, NOTETYPE_COPY_RE +from ..notetype_renames import canonical_notetype_name, matching_notetype_names from ..notetype_setting import NotetypeSetting, NotetypeSettingException from ..notetype_setting_definitions import ( anking_notetype_model, @@ -390,7 +391,7 @@ def _reset_notetype_and_reload_ui(self, model: "NotetypeDict"): return nt_base_name = notetype_base_name(model["name"]) - for model_version in _note_type_versions(model["name"]): + for model_version in _note_type_versions(nt_base_name): update_notetype_to_newest_version(model_version, nt_base_name) mw.col.models.update_dict(model_version) # type: ignore @@ -631,16 +632,31 @@ def _note_type_versions(nt_base_name: str) -> List["NotetypeDict"]: """Returns a list of all notetype versions of the notetype in the collection. Version of a note type are created by the AnkiHub add-on and by copying the base AnKing note types or importing them from different sources.""" + nt_base_name = canonical_notetype_name(nt_base_name) models = [ mw.col.models.get(x.id) # type: ignore for x in mw.col.models.all_names_and_ids() - if x.name == nt_base_name - or re.match(ANKIHUB_NOTETYPE_RE.format(notetype_base_name=nt_base_name), x.name) - or re.match(NOTETYPE_COPY_RE.format(notetype_base_name=nt_base_name), x.name) + for matching_name in matching_notetype_names(nt_base_name) + if _matches_notetype_version(x.name, matching_name) ] return models +def _matches_notetype_version(model_name: str, notetype_base_name: str) -> bool: + notetype_base_name_re = re.escape(notetype_base_name) + return bool( + model_name == notetype_base_name + or re.match( + ANKIHUB_NOTETYPE_RE.format(notetype_base_name=notetype_base_name_re), + model_name, + ) + or re.match( + NOTETYPE_COPY_RE.format(notetype_base_name=notetype_base_name_re), + model_name, + ) + ) + + def _most_basic_notetype_version(nt_base_name: str) -> Optional["NotetypeDict"]: """Returns the most basic version of a note type, that is the version with the shortest name.""" model_versions = _note_type_versions(nt_base_name) diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index 6ef6176..238ef84 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -1,12 +1,13 @@ import re from concurrent.futures import Future from copy import deepcopy -from typing import Dict, List +from typing import Dict, List, Optional from aqt import mw from aqt.utils import askUser, tooltip from ..constants import NOTETYPE_COPY_RE +from ..notetype_renames import matching_notetype_names from ..notetype_setting_definitions import anking_notetype_names from ..utils import adjust_fields, create_backup @@ -15,18 +16,22 @@ def handle_extra_notetype_versions() -> None: # mids of copies of the AnKing notetype identified by its name copy_mids_by_notetype_base_name: Dict[str, List[int]] = dict() for notetype_base_name in anking_notetype_names(): - if mw.col.models.by_name(notetype_base_name) is None: + matching_names = matching_notetype_names(notetype_base_name) + existing_notetype_name = _first_existing_notetype_name(matching_names) + if existing_notetype_name is None: continue notetype_copy_mids = [ x.id for x in mw.col.models.all_names_and_ids() + for matching_name in matching_names if re.match( - NOTETYPE_COPY_RE.format(notetype_base_name=notetype_base_name), x.name + NOTETYPE_COPY_RE.format(notetype_base_name=re.escape(matching_name)), + x.name, ) ] if notetype_copy_mids: - copy_mids_by_notetype_base_name[notetype_base_name] = notetype_copy_mids + copy_mids_by_notetype_base_name[existing_notetype_name] = notetype_copy_mids if not copy_mids_by_notetype_base_name: return @@ -92,3 +97,14 @@ def convert_extra_notetypes( mw.reset() tooltip("Note types were converted successfully.") + + +def _first_existing_notetype_name(notetype_names: List[str]) -> Optional[str]: + return next( + ( + notetype_name + for notetype_name in notetype_names + if mw.col.models.by_name(notetype_name) is not None + ), + None, + ) diff --git a/src/anking_notetypes/note_types/AnKingMCAT/AnKingMCAT.json b/src/anking_notetypes/note_types/AnKingMCAT/AnKingMCAT.json index 9abfd34..a85323e 100644 --- a/src/anking_notetypes/note_types/AnKingMCAT/AnKingMCAT.json +++ b/src/anking_notetypes/note_types/AnKingMCAT/AnKingMCAT.json @@ -1,6 +1,6 @@ { "id": 1610414929688, - "name": "AnKingMCAT", + "name": "AnKing MCAT", "type": 1, "mod": 1638144691, "usn": -1, @@ -117,4 +117,4 @@ ], "tags": [], "vers": [] -} \ No newline at end of file +} diff --git a/src/anking_notetypes/note_types/AnKingMCAT/README.md b/src/anking_notetypes/note_types/AnKingMCAT/README.md index 91091a1..1acef09 100644 --- a/src/anking_notetypes/note_types/AnKingMCAT/README.md +++ b/src/anking_notetypes/note_types/AnKingMCAT/README.md @@ -1,5 +1,5 @@ ### Features Unique to this Note Type -- AnKingMed alternate styling available here +- AnKingMed alternate styling available here
Replace the customizable portion with the contents of the link above (in styling)

diff --git a/src/anking_notetypes/notetype_renames.py b/src/anking_notetypes/notetype_renames.py new file mode 100644 index 0000000..cc11c14 --- /dev/null +++ b/src/anking_notetypes/notetype_renames.py @@ -0,0 +1,42 @@ +import re +from typing import Dict, List + + +# Add note type renames here as "old bundled name": "new bundled name". +# The old name is still used to find existing note types in users' collections. +NOTETYPE_RENAMES: Dict[str, str] = { + "AnKingMCAT": "AnKing MCAT", +} + +FULL_NOTETYPE_RENAMES: Dict[str, str] = { + "AnKingMCAT (AnKing-MCAT / AnKingMed)": ( + "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" + ), +} + + +def canonical_notetype_name(notetype_name: str) -> str: + return NOTETYPE_RENAMES.get(notetype_name, notetype_name) + + +def legacy_notetype_names(canonical_name: str) -> List[str]: + return [ + old_name + for old_name, new_name in NOTETYPE_RENAMES.items() + if new_name == canonical_name + ] + + +def matching_notetype_names(canonical_name: str) -> List[str]: + return [canonical_name, *legacy_notetype_names(canonical_name)] + + +def renamed_notetype_name(model_name: str) -> str: + if model_name in FULL_NOTETYPE_RENAMES: + return FULL_NOTETYPE_RENAMES[model_name] + + for old_name, new_name in NOTETYPE_RENAMES.items(): + match = re.match(rf"({re.escape(old_name)})(?=$| |-)", model_name) + if match: + return new_name + model_name[match.end() :] + return model_name diff --git a/src/anking_notetypes/notetype_setting_definitions.py b/src/anking_notetypes/notetype_setting_definitions.py index 7088428..78ef146 100644 --- a/src/anking_notetypes/notetype_setting_definitions.py +++ b/src/anking_notetypes/notetype_setting_definitions.py @@ -3,6 +3,12 @@ from pathlib import Path from typing import Any, Dict, List, OrderedDict, Tuple, Union +from .notetype_renames import ( + canonical_notetype_name, + legacy_notetype_names, + matching_notetype_names, +) + try: from anki.models import NotetypeDict # pylint: disable=unused-import except: @@ -675,7 +681,7 @@ def anking_notetype_templates() -> Dict[str, Tuple[str, str, str]]: for x in ANKING_NOTETYPES_PATH.iterdir(): if not x.is_dir(): continue - notetype_name = x.name + notetype_name = canonical_notetype_name(x.name) front_template = (x / "Front Template.html").read_text( encoding="utf-8", errors="ignore" @@ -690,16 +696,34 @@ def anking_notetype_templates() -> Dict[str, Tuple[str, str, str]]: def anking_notetype_model(notetype_name: str) -> "NotetypeDict": + notetype_name = canonical_notetype_name(notetype_name) + notetype_folder_name = _notetype_folder_name(notetype_name) result = json.loads( - (ANKING_NOTETYPES_PATH / notetype_name / f"{notetype_name}.json").read_text() + ( + ANKING_NOTETYPES_PATH + / notetype_folder_name + / f"{notetype_folder_name}.json" + ).read_text() ) front, back, styling = anking_notetype_templates()[notetype_name] + result["name"] = notetype_name result["tmpls"][0]["qfmt"] = front result["tmpls"][0]["afmt"] = back result["css"] = styling return result +def _notetype_folder_name(notetype_name: str) -> str: + if (ANKING_NOTETYPES_PATH / notetype_name).is_dir(): + return notetype_name + + for legacy_name in legacy_notetype_names(notetype_name): + if (ANKING_NOTETYPES_PATH / legacy_name).is_dir(): + return legacy_name + + return notetype_name + + def anking_notetype_models() -> List["NotetypeDict"]: return [anking_notetype_model(name) for name in anking_notetype_names()] @@ -711,7 +735,8 @@ def notetype_base_name(model_name: str) -> str: ( notetype_base_name for notetype_base_name in anking_notetype_names() - if re.match(rf"{notetype_base_name}($| |-)", model_name) + for matching_name in matching_notetype_names(notetype_base_name) + if re.match(rf"{re.escape(matching_name)}($| |-)", model_name) ), None, ) diff --git a/src/anking_notetypes/utils.py b/src/anking_notetypes/utils.py index 8e7f658..bf9d40f 100644 --- a/src/anking_notetypes/utils.py +++ b/src/anking_notetypes/utils.py @@ -12,6 +12,7 @@ ANKIHUB_HTML_END_COMMENT_RE, ANKIHUB_TEMPLATE_SNIPPET_RE, ) +from .notetype_renames import renamed_notetype_name from .notetype_setting_definitions import anking_notetype_model try: @@ -25,7 +26,7 @@ def update_notetype_to_newest_version( ) -> None: new_model = anking_notetype_model(notetype_base_name) new_model["id"] = model["id"] - new_model["name"] = model["name"] # keep the name + new_model["name"] = _updated_notetype_name(model["name"]) new_model["mod"] = int(time.time()) # not sure if this is needed new_model["usn"] = -1 # triggers full sync @@ -41,6 +42,17 @@ def update_notetype_to_newest_version( model.update(new_model) +def _updated_notetype_name(model_name: str) -> str: + new_name = renamed_notetype_name(model_name) + if new_name == model_name: + return model_name + + if mw.col.models.by_name(new_name): + return model_name + + return new_name + + def _retain_ankihub_modifications( old_model: "NotetypeDict", new_model: "NotetypeDict" ) -> "NotetypeDict": diff --git a/tests/expected.json b/tests/expected.json index d73237a..b1efd39 100644 --- a/tests/expected.json +++ b/tests/expected.json @@ -323,7 +323,7 @@ "user_action_6": "undefined", "user_action_7": "undefined", "user_action_8": "undefined"}, - "AnKingMCAT": { + "AnKing MCAT": { "always_one_by_one": false, "autoflip": false, "autoreveal_additional_resources": false, diff --git a/tests/test_unit.py b/tests/test_unit.py index 3101f03..7f7d110 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -1,4 +1,13 @@ +from unittest.mock import patch + from src.anking_notetypes.notetype_setting import order_names +from src.anking_notetypes.notetype_renames import ( + NOTETYPE_RENAMES, + canonical_notetype_name, + matching_notetype_names, + renamed_notetype_name, +) +from src.anking_notetypes.notetype_setting_definitions import notetype_base_name class TestOrderNames: @@ -25,3 +34,42 @@ def test_no_matches(self): assert order_names( new_names=["Apple", "Banana"], current_names=["Cherry", "Date"] ) == ["Apple", "Banana"] + + +class TestNotetypeRenames: + def test_mcat_legacy_name_maps_to_new_name(self): + assert canonical_notetype_name("AnKingMCAT") == "AnKing MCAT" + assert notetype_base_name("AnKingMCAT") == "AnKing MCAT" + assert ( + notetype_base_name("AnKingMCAT (AnKing-MCAT / AnKingMed)") == "AnKing MCAT" + ) + assert ( + renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)") + == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" + ) + + def test_canonical_notetype_name(self): + with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + assert canonical_notetype_name("Old-AnKing") == "AnKingOverhaul" + assert canonical_notetype_name("AnKingOverhaul") == "AnKingOverhaul" + + def test_matching_notetype_names(self): + with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + assert matching_notetype_names("AnKingOverhaul") == [ + "AnKingOverhaul", + "Old-AnKing", + ] + + def test_renamed_notetype_name(self): + with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + assert renamed_notetype_name("Old-AnKing") == "AnKingOverhaul" + assert renamed_notetype_name("Old-AnKing-1dgs0") == "AnKingOverhaul-1dgs0" + assert ( + renamed_notetype_name("Old-AnKing (AnKing / Example)") + == "AnKingOverhaul (AnKing / Example)" + ) + + def test_notetype_base_name_recognizes_legacy_name(self): + with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + assert notetype_base_name("Old-AnKing") == "AnKingOverhaul" + assert notetype_base_name("Old-AnKing-1dgs0") == "AnKingOverhaul" From 3acffa67068ee1935f61c728f14175419c70d575 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 14:07:04 +0200 Subject: [PATCH 02/16] Bump flake8 to 6.1.0 and add pytest to pylint deps flake8 4.0.1 crashes on Python 3.12 due to the importlib_metadata entry_points API change. pytest is added to pylint's additional dependencies so tests that use fixtures lint cleanly. --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a40104d..0a068b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: args: ["--diff"] - repo: https://github.com/pycqa/flake8 - rev: 4.0.1 + rev: 6.1.0 hooks: - id: flake8 @@ -29,7 +29,7 @@ repos: - id: pylint language: python types: [python] - additional_dependencies: [aqt] + additional_dependencies: [aqt, pytest] args: [ "-rn", # Only display messages From 95854212fac01ffa2235954441d688808d23ef2f Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 14:07:09 +0200 Subject: [PATCH 03/16] Add tests for _notetype_folder_name and _updated_notetype_name --- tests/test_unit.py | 77 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/tests/test_unit.py b/tests/test_unit.py index 7f7d110..f1f7700 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -1,14 +1,20 @@ -from unittest.mock import patch +# pylint: disable=protected-access +from unittest.mock import MagicMock, patch -from src.anking_notetypes.notetype_setting import order_names +import pytest + +from src.anking_notetypes import notetype_setting_definitions, utils from src.anking_notetypes.notetype_renames import ( NOTETYPE_RENAMES, canonical_notetype_name, matching_notetype_names, renamed_notetype_name, ) +from src.anking_notetypes.notetype_setting import order_names from src.anking_notetypes.notetype_setting_definitions import notetype_base_name +FAKE_RENAMES = {"Old-AnKing": "AnKingOverhaul"} + class TestOrderNames: def test_basic(self): @@ -49,19 +55,19 @@ def test_mcat_legacy_name_maps_to_new_name(self): ) def test_canonical_notetype_name(self): - with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert canonical_notetype_name("Old-AnKing") == "AnKingOverhaul" assert canonical_notetype_name("AnKingOverhaul") == "AnKingOverhaul" def test_matching_notetype_names(self): - with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert matching_notetype_names("AnKingOverhaul") == [ "AnKingOverhaul", "Old-AnKing", ] def test_renamed_notetype_name(self): - with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert renamed_notetype_name("Old-AnKing") == "AnKingOverhaul" assert renamed_notetype_name("Old-AnKing-1dgs0") == "AnKingOverhaul-1dgs0" assert ( @@ -70,6 +76,65 @@ def test_renamed_notetype_name(self): ) def test_notetype_base_name_recognizes_legacy_name(self): - with patch.dict(NOTETYPE_RENAMES, {"Old-AnKing": "AnKingOverhaul"}): + with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert notetype_base_name("Old-AnKing") == "AnKingOverhaul" assert notetype_base_name("Old-AnKing-1dgs0") == "AnKingOverhaul" + + +@pytest.fixture +def notetypes_path(tmp_path): + with patch.object( + notetype_setting_definitions, "ANKING_NOTETYPES_PATH", tmp_path + ), patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): + yield tmp_path + + +class TestNotetypeFolderName: + def test_prefers_canonical_folder_when_present(self, notetypes_path): + (notetypes_path / "AnKingOverhaul").mkdir() + (notetypes_path / "Old-AnKing").mkdir() + + assert ( + notetype_setting_definitions._notetype_folder_name("AnKingOverhaul") + == "AnKingOverhaul" + ) + + def test_falls_back_to_legacy_folder(self, notetypes_path): + (notetypes_path / "Old-AnKing").mkdir() + + assert ( + notetype_setting_definitions._notetype_folder_name("AnKingOverhaul") + == "Old-AnKing" + ) + + def test_returns_canonical_when_no_folder_exists( + self, notetypes_path # pylint: disable=unused-argument + ): + assert ( + notetype_setting_definitions._notetype_folder_name("AnKingOverhaul") + == "AnKingOverhaul" + ) + + +class TestUpdatedNotetypeName: + def test_returns_unchanged_when_no_rename_applies(self): + with patch.dict(NOTETYPE_RENAMES, {}, clear=True): + assert utils._updated_notetype_name("Unrelated") == "Unrelated" + + def test_renames_when_new_name_not_in_collection(self): + mw_mock = MagicMock() + mw_mock.col.models.by_name.return_value = None + + with patch.object(utils, "mw", mw_mock), patch.dict( + NOTETYPE_RENAMES, FAKE_RENAMES + ): + assert utils._updated_notetype_name("Old-AnKing") == "AnKingOverhaul" + + def test_keeps_old_name_when_new_name_already_exists(self): + mw_mock = MagicMock() + mw_mock.col.models.by_name.return_value = {"id": 1, "name": "AnKingOverhaul"} + + with patch.object(utils, "mw", mw_mock), patch.dict( + NOTETYPE_RENAMES, FAKE_RENAMES + ): + assert utils._updated_notetype_name("Old-AnKing") == "Old-AnKing" From 07908ae54a5611f94b7c3508dd5da79f657e99a6 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 14:10:51 +0200 Subject: [PATCH 04/16] Tidy MCAT rename helpers after review - Collapse `_updated_notetype_name` branches. - Document why `FULL_NOTETYPE_RENAMES` exists separately from `NOTETYPE_RENAMES`. - Hoist `matching_notetype_names` out of the per-model comprehension in `_note_type_versions`. --- src/anking_notetypes/gui/config_window.py | 4 ++-- src/anking_notetypes/notetype_renames.py | 2 ++ src/anking_notetypes/utils.py | 6 +----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/anking_notetypes/gui/config_window.py b/src/anking_notetypes/gui/config_window.py index d2188a4..2b75ed3 100644 --- a/src/anking_notetypes/gui/config_window.py +++ b/src/anking_notetypes/gui/config_window.py @@ -632,11 +632,11 @@ def _note_type_versions(nt_base_name: str) -> List["NotetypeDict"]: """Returns a list of all notetype versions of the notetype in the collection. Version of a note type are created by the AnkiHub add-on and by copying the base AnKing note types or importing them from different sources.""" - nt_base_name = canonical_notetype_name(nt_base_name) + matching_names = matching_notetype_names(canonical_notetype_name(nt_base_name)) models = [ mw.col.models.get(x.id) # type: ignore for x in mw.col.models.all_names_and_ids() - for matching_name in matching_notetype_names(nt_base_name) + for matching_name in matching_names if _matches_notetype_version(x.name, matching_name) ] return models diff --git a/src/anking_notetypes/notetype_renames.py b/src/anking_notetypes/notetype_renames.py index cc11c14..8075f67 100644 --- a/src/anking_notetypes/notetype_renames.py +++ b/src/anking_notetypes/notetype_renames.py @@ -8,6 +8,8 @@ "AnKingMCAT": "AnKing MCAT", } +# Full renames for AnkiHub-qualified names where the deck portion also changed +# and cannot be derived from NOTETYPE_RENAMES alone. FULL_NOTETYPE_RENAMES: Dict[str, str] = { "AnKingMCAT (AnKing-MCAT / AnKingMed)": ( "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" diff --git a/src/anking_notetypes/utils.py b/src/anking_notetypes/utils.py index bf9d40f..c4d4a52 100644 --- a/src/anking_notetypes/utils.py +++ b/src/anking_notetypes/utils.py @@ -44,12 +44,8 @@ def update_notetype_to_newest_version( def _updated_notetype_name(model_name: str) -> str: new_name = renamed_notetype_name(model_name) - if new_name == model_name: + if new_name == model_name or mw.col.models.by_name(new_name): return model_name - - if mw.col.models.by_name(new_name): - return model_name - return new_name From 8ab8ffc24d85802f4020cbc97d10945c05a22d95 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 14:23:43 +0200 Subject: [PATCH 05/16] Extract is_notetype_copy and is_ankihub_notetype_version helpers Replaces three inline re.match(CONSTANT.format(notetype_base_name=re.escape(x)), y) call sites with named predicates that make the intent explicit at each use. --- src/anking_notetypes/gui/config_window.py | 22 +++++++------------ .../gui/extra_notetype_versions.py | 9 ++------ .../notetype_setting_definitions.py | 19 ++++++++++++++++ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/anking_notetypes/gui/config_window.py b/src/anking_notetypes/gui/config_window.py index 2b75ed3..0e1077f 100644 --- a/src/anking_notetypes/gui/config_window.py +++ b/src/anking_notetypes/gui/config_window.py @@ -10,7 +10,6 @@ from ..ankiaddonconfig import ConfigManager, ConfigWindow from ..ankiaddonconfig.window import ConfigLayout -from ..constants import ANKIHUB_NOTETYPE_RE, NOTETYPE_COPY_RE from ..notetype_renames import canonical_notetype_name, matching_notetype_names from ..notetype_setting import NotetypeSetting, NotetypeSettingException from ..notetype_setting_definitions import ( @@ -19,8 +18,10 @@ configurable_fields_for_notetype, general_settings, general_settings_defaults_dict, - setting_configs, + is_ankihub_notetype_version, + is_notetype_copy, notetype_base_name, + setting_configs, ) from ..utils import update_notetype_to_newest_version from .anking_widgets import AnkingIconsLayout, GithubLinkLayout @@ -642,18 +643,11 @@ def _note_type_versions(nt_base_name: str) -> List["NotetypeDict"]: return models -def _matches_notetype_version(model_name: str, notetype_base_name: str) -> bool: - notetype_base_name_re = re.escape(notetype_base_name) - return bool( - model_name == notetype_base_name - or re.match( - ANKIHUB_NOTETYPE_RE.format(notetype_base_name=notetype_base_name_re), - model_name, - ) - or re.match( - NOTETYPE_COPY_RE.format(notetype_base_name=notetype_base_name_re), - model_name, - ) +def _matches_notetype_version(model_name: str, base_name: str) -> bool: + return ( + model_name == base_name + or is_ankihub_notetype_version(model_name, base_name) + or is_notetype_copy(model_name, base_name) ) diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index 238ef84..22a9099 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -1,4 +1,3 @@ -import re from concurrent.futures import Future from copy import deepcopy from typing import Dict, List, Optional @@ -6,9 +5,8 @@ from aqt import mw from aqt.utils import askUser, tooltip -from ..constants import NOTETYPE_COPY_RE from ..notetype_renames import matching_notetype_names -from ..notetype_setting_definitions import anking_notetype_names +from ..notetype_setting_definitions import anking_notetype_names, is_notetype_copy from ..utils import adjust_fields, create_backup @@ -25,10 +23,7 @@ def handle_extra_notetype_versions() -> None: x.id for x in mw.col.models.all_names_and_ids() for matching_name in matching_names - if re.match( - NOTETYPE_COPY_RE.format(notetype_base_name=re.escape(matching_name)), - x.name, - ) + if is_notetype_copy(x.name, matching_name) ] if notetype_copy_mids: copy_mids_by_notetype_base_name[existing_notetype_name] = notetype_copy_mids diff --git a/src/anking_notetypes/notetype_setting_definitions.py b/src/anking_notetypes/notetype_setting_definitions.py index 78ef146..1c21daa 100644 --- a/src/anking_notetypes/notetype_setting_definitions.py +++ b/src/anking_notetypes/notetype_setting_definitions.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Any, Dict, List, OrderedDict, Tuple, Union +from .constants import ANKIHUB_NOTETYPE_RE, NOTETYPE_COPY_RE from .notetype_renames import ( canonical_notetype_name, legacy_notetype_names, @@ -742,6 +743,24 @@ def notetype_base_name(model_name: str) -> str: ) +def is_notetype_copy(model_name: str, base_name: str) -> bool: + return bool( + re.match( + NOTETYPE_COPY_RE.format(notetype_base_name=re.escape(base_name)), + model_name, + ) + ) + + +def is_ankihub_notetype_version(model_name: str, base_name: str) -> bool: + return bool( + re.match( + ANKIHUB_NOTETYPE_RE.format(notetype_base_name=re.escape(base_name)), + model_name, + ) + ) + + def is_io_note_type(model_name: str) -> bool: "Return True if the given note type is an image occlusion type." return notetype_base_name(model_name) in ("IO-one by one", "Physeo-IO one by one") From 142406b2adc369ccdf814201f9c44735180f88a4 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:05:40 +0200 Subject: [PATCH 06/16] Prefer canonical name in rename and cleanup edge cases - Make FULL_NOTETYPE_RENAMES match with an optional copy suffix so AnkiHub-qualified copies like "AnKingMCAT (AnKing-MCAT / AnKingMed)-abcde" rename their deck portion too. - Have _most_basic_notetype_version prefer the canonical name, then any legacy name, before falling back to shortest-name heuristics. Without this, a legacy main (AnKingMCAT, 10 chars) beats the canonical (AnKing MCAT, 11) in the config window. - Rename a legacy main to its canonical name inside convert_extra_notetypes when the canonical main is missing, so canonical copies fold into the canonical name instead of the legacy one. --- src/anking_notetypes/gui/config_window.py | 25 ++++++++++++---- .../gui/extra_notetype_versions.py | 30 +++++++++++++++---- src/anking_notetypes/notetype_renames.py | 6 ++-- tests/test_unit.py | 10 +++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/anking_notetypes/gui/config_window.py b/src/anking_notetypes/gui/config_window.py index 0e1077f..7e4c6c7 100644 --- a/src/anking_notetypes/gui/config_window.py +++ b/src/anking_notetypes/gui/config_window.py @@ -10,7 +10,11 @@ from ..ankiaddonconfig import ConfigManager, ConfigWindow from ..ankiaddonconfig.window import ConfigLayout -from ..notetype_renames import canonical_notetype_name, matching_notetype_names +from ..notetype_renames import ( + canonical_notetype_name, + legacy_notetype_names, + matching_notetype_names, +) from ..notetype_setting import NotetypeSetting, NotetypeSettingException from ..notetype_setting_definitions import ( anking_notetype_model, @@ -652,15 +656,26 @@ def _matches_notetype_version(model_name: str, base_name: str) -> bool: def _most_basic_notetype_version(nt_base_name: str) -> Optional["NotetypeDict"]: - """Returns the most basic version of a note type, that is the version with the shortest name.""" + """Returns the most basic version of a note type. + + Prefers an exact match on the canonical name, then an exact legacy name, + then falls back to the shortest name. Without the canonical/legacy + preference, a legacy main like ``AnKingMCAT`` would beat ``AnKing MCAT`` + on name length alone. + """ + canonical = canonical_notetype_name(nt_base_name) model_versions = _note_type_versions(nt_base_name) - result = min( + versions_by_name = {model["name"]: model for model in model_versions} + + for preferred in [canonical, *legacy_notetype_names(canonical)]: + if preferred in versions_by_name: + return versions_by_name[preferred] + + return min( model_versions, - # sort by length of name and then alphabetically key=lambda model: (len(model["name"]), model["name"]), default=None, ) - return result def _names_of_all_supported_note_types() -> List[str]: diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index 22a9099..313e1fc 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -1,22 +1,24 @@ from concurrent.futures import Future from copy import deepcopy -from typing import Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from aqt import mw from aqt.utils import askUser, tooltip -from ..notetype_renames import matching_notetype_names +from ..notetype_renames import legacy_notetype_names, matching_notetype_names from ..notetype_setting_definitions import anking_notetype_names, is_notetype_copy from ..utils import adjust_fields, create_backup +if TYPE_CHECKING: + from anki.models import NotetypeDict + def handle_extra_notetype_versions() -> None: - # mids of copies of the AnKing notetype identified by its name + # mids of copies of the AnKing notetype, keyed by canonical base name copy_mids_by_notetype_base_name: Dict[str, List[int]] = dict() for notetype_base_name in anking_notetype_names(): matching_names = matching_notetype_names(notetype_base_name) - existing_notetype_name = _first_existing_notetype_name(matching_names) - if existing_notetype_name is None: + if _first_existing_notetype_name(matching_names) is None: continue notetype_copy_mids = [ @@ -26,7 +28,7 @@ def handle_extra_notetype_versions() -> None: if is_notetype_copy(x.name, matching_name) ] if notetype_copy_mids: - copy_mids_by_notetype_base_name[existing_notetype_name] = notetype_copy_mids + copy_mids_by_notetype_base_name[notetype_base_name] = notetype_copy_mids if not copy_mids_by_notetype_base_name: return @@ -63,6 +65,10 @@ def convert_extra_notetypes( for notetype_base_name, copy_mids in copy_mids_by_notetype_base_name.items(): model = mw.col.models.by_name(notetype_base_name) + if model is None: + # Only a legacy-named main exists — rename it to canonical so + # copies (including canonical-named copies) fold into the new name. + model = _rename_legacy_main_to_canonical(notetype_base_name) for copy_mid in copy_mids: model_copy = mw.col.models.get(copy_mid) # type: ignore @@ -103,3 +109,15 @@ def _first_existing_notetype_name(notetype_names: List[str]) -> Optional[str]: ), None, ) + + +def _rename_legacy_main_to_canonical(canonical_name: str) -> Optional["NotetypeDict"]: + for legacy_name in legacy_notetype_names(canonical_name): + legacy_model = mw.col.models.by_name(legacy_name) + if legacy_model is None: + continue + legacy_model["name"] = canonical_name + legacy_model["usn"] = -1 + mw.col.models.update_dict(legacy_model) + return mw.col.models.by_name(canonical_name) + return None diff --git a/src/anking_notetypes/notetype_renames.py b/src/anking_notetypes/notetype_renames.py index 8075f67..bb02109 100644 --- a/src/anking_notetypes/notetype_renames.py +++ b/src/anking_notetypes/notetype_renames.py @@ -34,8 +34,10 @@ def matching_notetype_names(canonical_name: str) -> List[str]: def renamed_notetype_name(model_name: str) -> str: - if model_name in FULL_NOTETYPE_RENAMES: - return FULL_NOTETYPE_RENAMES[model_name] + for old_full, new_full in FULL_NOTETYPE_RENAMES.items(): + match = re.match(rf"{re.escape(old_full)}(?=$|-)", model_name) + if match: + return new_full + model_name[match.end() :] for old_name, new_name in NOTETYPE_RENAMES.items(): match = re.match(rf"({re.escape(old_name)})(?=$| |-)", model_name) diff --git a/tests/test_unit.py b/tests/test_unit.py index f1f7700..d85907e 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -54,6 +54,16 @@ def test_mcat_legacy_name_maps_to_new_name(self): == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" ) + def test_mcat_full_rename_preserves_copy_suffix(self): + assert ( + renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)-abcde") + == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)-abcde" + ) + + def test_renamed_notetype_name_ignores_unrelated_prefix(self): + with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): + assert renamed_notetype_name("Old-AnKingology") == "Old-AnKingology" + def test_canonical_notetype_name(self): with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert canonical_notetype_name("Old-AnKing") == "AnKingOverhaul" From 144fdcd65dc2b4729d0d3e128a016ccc7700d6c3 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:39:25 +0200 Subject: [PATCH 07/16] Test _rename_legacy_main_to_canonical Locks in the rename-in-place semantics (usn bumped, update_dict called, canonical model returned) and the no-legacy-main fallback. --- tests/test_unit.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_unit.py b/tests/test_unit.py index d85907e..72464e9 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -4,6 +4,7 @@ import pytest from src.anking_notetypes import notetype_setting_definitions, utils +from src.anking_notetypes.gui import extra_notetype_versions from src.anking_notetypes.notetype_renames import ( NOTETYPE_RENAMES, canonical_notetype_name, @@ -148,3 +149,41 @@ def test_keeps_old_name_when_new_name_already_exists(self): NOTETYPE_RENAMES, FAKE_RENAMES ): assert utils._updated_notetype_name("Old-AnKing") == "Old-AnKing" + + +class TestRenameLegacyMainToCanonical: + def test_renames_legacy_main_and_returns_canonical_model(self): + legacy_model = {"id": 42, "name": "Old-AnKing"} + canonical_model = {"id": 42, "name": "AnKingOverhaul"} + mw_mock = MagicMock() + mw_mock.col.models.by_name.side_effect = lambda name: { + "Old-AnKing": legacy_model, + "AnKingOverhaul": canonical_model, + }.get(name) + + with patch.object(extra_notetype_versions, "mw", mw_mock), patch.dict( + NOTETYPE_RENAMES, FAKE_RENAMES + ): + result = extra_notetype_versions._rename_legacy_main_to_canonical( + "AnKingOverhaul" + ) + + assert legacy_model["name"] == "AnKingOverhaul" + assert legacy_model["usn"] == -1 + mw_mock.col.models.update_dict.assert_called_once_with(legacy_model) + assert result is canonical_model + + def test_returns_none_when_no_legacy_main_exists(self): + mw_mock = MagicMock() + mw_mock.col.models.by_name.return_value = None + + with patch.object(extra_notetype_versions, "mw", mw_mock), patch.dict( + NOTETYPE_RENAMES, FAKE_RENAMES + ): + assert ( + extra_notetype_versions._rename_legacy_main_to_canonical( + "AnKingOverhaul" + ) + is None + ) + mw_mock.col.models.update_dict.assert_not_called() From 85716bbb2060adbe43fd994b9592973feb0b5c1e Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:43:24 +0200 Subject: [PATCH 08/16] Surface legacy-main renames in the extra-copies dialog Conversion may rename a legacy main to its canonical name when the canonical doesn't exist, which is broader than the dialog's "delete extra copies" framing. List the affected renames in the dialog when any will occur so the user can see what will change. --- .../gui/extra_notetype_versions.py | 48 +++++++++++++++---- tests/test_unit.py | 13 +++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index 313e1fc..d227e79 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -1,6 +1,6 @@ from concurrent.futures import Future from copy import deepcopy -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from aqt import mw from aqt.utils import askUser, tooltip @@ -16,6 +16,8 @@ def handle_extra_notetype_versions() -> None: # mids of copies of the AnKing notetype, keyed by canonical base name copy_mids_by_notetype_base_name: Dict[str, List[int]] = dict() + # (legacy_name, canonical_name) pairs for mains that will be renamed during conversion + legacy_mains_to_rename: List[Tuple[str, str]] = [] for notetype_base_name in anking_notetype_names(): matching_names = matching_notetype_names(notetype_base_name) if _first_existing_notetype_name(matching_names) is None: @@ -27,18 +29,21 @@ def handle_extra_notetype_versions() -> None: for matching_name in matching_names if is_notetype_copy(x.name, matching_name) ] - if notetype_copy_mids: - copy_mids_by_notetype_base_name[notetype_base_name] = notetype_copy_mids + if not notetype_copy_mids: + continue + + copy_mids_by_notetype_base_name[notetype_base_name] = notetype_copy_mids + if mw.col.models.by_name(notetype_base_name) is None: + for legacy_name in legacy_notetype_names(notetype_base_name): + if mw.col.models.by_name(legacy_name) is not None: + legacy_mains_to_rename.append((legacy_name, notetype_base_name)) + break if not copy_mids_by_notetype_base_name: return if not askUser( - "There are extra copies of AnKing note types. Do you want to convert all note types with names like " - 'for example "AnKingOverhaul-1dgs0" to "AnKingOverhaul" respectively?\n\n' - "This will delete the extra note types and require a full upload of the collection " - "the next time you sync with AnkiWeb. A backup will be created before the changes are applied.\n\n" - "No matter what you chose the AnKing Note Types window will open after you select an option.", + _build_confirmation_message(legacy_mains_to_rename), title="Extra copies of AnKing note types", ): return @@ -100,6 +105,33 @@ def convert_extra_notetypes( tooltip("Note types were converted successfully.") +def _build_confirmation_message( + legacy_mains_to_rename: List[Tuple[str, str]], +) -> str: + message = ( + "There are extra copies of AnKing note types. Do you want to convert all " + "note types with names like " + 'for example "AnKingOverhaul-1dgs0" to "AnKingOverhaul" respectively?\n\n' + "This will delete the extra note types and require a full upload of the " + "collection the next time you sync with AnkiWeb. A backup will be created " + "before the changes are applied.\n\n" + ) + if legacy_mains_to_rename: + renames = "\n".join( + f' - "{legacy}" → "{canonical}"' + for legacy, canonical in legacy_mains_to_rename + ) + message += ( + "The following note types will also be renamed to their current " + f"names:\n{renames}\n\n" + ) + message += ( + "No matter what you chose the AnKing Note Types window will open after " + "you select an option." + ) + return message + + def _first_existing_notetype_name(notetype_names: List[str]) -> Optional[str]: return next( ( diff --git a/tests/test_unit.py b/tests/test_unit.py index 72464e9..7b8226e 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -151,6 +151,19 @@ def test_keeps_old_name_when_new_name_already_exists(self): assert utils._updated_notetype_name("Old-AnKing") == "Old-AnKing" +class TestBuildConfirmationMessage: + def test_no_legacy_mains_omits_rename_section(self): + message = extra_notetype_versions._build_confirmation_message([]) + assert "renamed" not in message + + def test_lists_affected_legacy_mains(self): + message = extra_notetype_versions._build_confirmation_message( + [("AnKingMCAT", "AnKing MCAT"), ("Old-AnKing", "AnKingOverhaul")] + ) + assert '"AnKingMCAT" → "AnKing MCAT"' in message + assert '"Old-AnKing" → "AnKingOverhaul"' in message + + class TestRenameLegacyMainToCanonical: def test_renames_legacy_main_and_returns_canonical_model(self): legacy_model = {"id": 42, "name": "Old-AnKing"} From 60eac7b89f141ca1f71dc47ca73f5a268420e080 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:48:43 +0200 Subject: [PATCH 09/16] Simplify extra_notetype_versions cleanup - Return the mutated legacy_model directly instead of re-querying - Hoist all_names_and_ids out of the anking_notetype_names loop - Drop what-not-why comment in convert_extra_notetypes --- src/anking_notetypes/gui/extra_notetype_versions.py | 7 +++---- tests/test_unit.py | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index d227e79..3ffa6bd 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -18,6 +18,7 @@ def handle_extra_notetype_versions() -> None: copy_mids_by_notetype_base_name: Dict[str, List[int]] = dict() # (legacy_name, canonical_name) pairs for mains that will be renamed during conversion legacy_mains_to_rename: List[Tuple[str, str]] = [] + all_models = list(mw.col.models.all_names_and_ids()) for notetype_base_name in anking_notetype_names(): matching_names = matching_notetype_names(notetype_base_name) if _first_existing_notetype_name(matching_names) is None: @@ -25,7 +26,7 @@ def handle_extra_notetype_versions() -> None: notetype_copy_mids = [ x.id - for x in mw.col.models.all_names_and_ids() + for x in all_models for matching_name in matching_names if is_notetype_copy(x.name, matching_name) ] @@ -71,8 +72,6 @@ def convert_extra_notetypes( for notetype_base_name, copy_mids in copy_mids_by_notetype_base_name.items(): model = mw.col.models.by_name(notetype_base_name) if model is None: - # Only a legacy-named main exists — rename it to canonical so - # copies (including canonical-named copies) fold into the new name. model = _rename_legacy_main_to_canonical(notetype_base_name) for copy_mid in copy_mids: model_copy = mw.col.models.get(copy_mid) # type: ignore @@ -151,5 +150,5 @@ def _rename_legacy_main_to_canonical(canonical_name: str) -> Optional["NotetypeD legacy_model["name"] = canonical_name legacy_model["usn"] = -1 mw.col.models.update_dict(legacy_model) - return mw.col.models.by_name(canonical_name) + return legacy_model return None diff --git a/tests/test_unit.py b/tests/test_unit.py index 7b8226e..f27624a 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -167,11 +167,9 @@ def test_lists_affected_legacy_mains(self): class TestRenameLegacyMainToCanonical: def test_renames_legacy_main_and_returns_canonical_model(self): legacy_model = {"id": 42, "name": "Old-AnKing"} - canonical_model = {"id": 42, "name": "AnKingOverhaul"} mw_mock = MagicMock() mw_mock.col.models.by_name.side_effect = lambda name: { "Old-AnKing": legacy_model, - "AnKingOverhaul": canonical_model, }.get(name) with patch.object(extra_notetype_versions, "mw", mw_mock), patch.dict( @@ -184,7 +182,7 @@ def test_renames_legacy_main_and_returns_canonical_model(self): assert legacy_model["name"] == "AnKingOverhaul" assert legacy_model["usn"] == -1 mw_mock.col.models.update_dict.assert_called_once_with(legacy_model) - assert result is canonical_model + assert result is legacy_model def test_returns_none_when_no_legacy_main_exists(self): mw_mock = MagicMock() From 5b43e41b5239577641f6dd6e8bb18c0b4fca6575 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:54:00 +0200 Subject: [PATCH 10/16] Fix notetype_base_name prefix collision with AnKing MCAT "AnKing" is a valid base name and a prefix of "AnKing MCAT". The prior match order was iteration order of anking_notetype_names(), so "AnKing MCAT" / "AnKing MCAT-abcde" / AnkiHub-qualified MCAT names could misclassify as "AnKing". Prefer the longest matching name to avoid this. --- .../notetype_setting_definitions.py | 13 ++++++++++--- tests/test_unit.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/anking_notetypes/notetype_setting_definitions.py b/src/anking_notetypes/notetype_setting_definitions.py index 1c21daa..2c2e651 100644 --- a/src/anking_notetypes/notetype_setting_definitions.py +++ b/src/anking_notetypes/notetype_setting_definitions.py @@ -732,11 +732,18 @@ def anking_notetype_models() -> List["NotetypeDict"]: def notetype_base_name(model_name: str) -> str: """Returns the base name of a note type, that is if it's a version of a an anking note type it will return the base name, otherwise it will return the name itself.""" + candidates = [ + (matching_name, notetype_base_name) + for notetype_base_name in anking_notetype_names() + for matching_name in matching_notetype_names(notetype_base_name) + ] + # Prefer the longest matching name so e.g. "AnKing MCAT" wins over "AnKing" + # when the model is "AnKing MCAT" / "AnKing MCAT-abcde" / AnkiHub-qualified. + candidates.sort(key=lambda pair: len(pair[0]), reverse=True) return next( ( - notetype_base_name - for notetype_base_name in anking_notetype_names() - for matching_name in matching_notetype_names(notetype_base_name) + base_name + for matching_name, base_name in candidates if re.match(rf"{re.escape(matching_name)}($| |-)", model_name) ), None, diff --git a/tests/test_unit.py b/tests/test_unit.py index f27624a..552e6da 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -55,6 +55,16 @@ def test_mcat_legacy_name_maps_to_new_name(self): == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" ) + def test_mcat_canonical_name_not_confused_with_anking_prefix(self): + # "AnKing" is a valid base and prefix of "AnKing MCAT" — verify the + # longer name wins so canonical MCAT names are not misclassified. + assert notetype_base_name("AnKing MCAT") == "AnKing MCAT" + assert notetype_base_name("AnKing MCAT-abcde") == "AnKing MCAT" + assert ( + notetype_base_name("AnKing MCAT (AnKing MCAT Deck / AnKingMed)") + == "AnKing MCAT" + ) + def test_mcat_full_rename_preserves_copy_suffix(self): assert ( renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)-abcde") From 5ed623704d8f4b126c13e43ad5bc0b4dfc7d1ba9 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 15:58:05 +0200 Subject: [PATCH 11/16] Rename shadowing loop var in notetype_base_name --- src/anking_notetypes/notetype_setting_definitions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/anking_notetypes/notetype_setting_definitions.py b/src/anking_notetypes/notetype_setting_definitions.py index 2c2e651..fbacd3a 100644 --- a/src/anking_notetypes/notetype_setting_definitions.py +++ b/src/anking_notetypes/notetype_setting_definitions.py @@ -733,9 +733,9 @@ def notetype_base_name(model_name: str) -> str: """Returns the base name of a note type, that is if it's a version of a an anking note type it will return the base name, otherwise it will return the name itself.""" candidates = [ - (matching_name, notetype_base_name) - for notetype_base_name in anking_notetype_names() - for matching_name in matching_notetype_names(notetype_base_name) + (matching_name, base_name) + for base_name in anking_notetype_names() + for matching_name in matching_notetype_names(base_name) ] # Prefer the longest matching name so e.g. "AnKing MCAT" wins over "AnKing" # when the model is "AnKing MCAT" / "AnKing MCAT-abcde" / AnkiHub-qualified. From b2cb380e591040095289283095e58da91b92476f Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 16:12:29 +0200 Subject: [PATCH 12/16] Stop renaming AnkiHub-qualified notetypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnkiHub-qualified notetypes (`... (deck / owner)`) are owned by the AnkiHub add-on — we can't know whether the deck portion is being renamed upstream, so the previous FULL_NOTETYPE_RENAMES machinery could silently corrupt users' notetype names on sync. Drop FULL_NOTETYPE_RENAMES and tighten the rename regex to only match bare or copy-suffixed forms (lookahead `(?=$|-)` instead of `(?=$| |-)`). Detection via notetype_base_name / matching_notetype_names still recognizes AnkiHub-qualified legacy forms via the legacy-name branch, so config window, update detection, and extra-copy cleanup keep working. --- .claude/scheduled_tasks.lock | 1 + src/anking_notetypes/notetype_renames.py | 19 +++++-------------- tests/test_unit.py | 14 ++++++-------- 3 files changed, 12 insertions(+), 22 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..3224520 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"52f64b5a-dfe7-430b-acde-6573edd143d3","pid":299951,"acquiredAt":1776779784452} diff --git a/src/anking_notetypes/notetype_renames.py b/src/anking_notetypes/notetype_renames.py index bb02109..66ba179 100644 --- a/src/anking_notetypes/notetype_renames.py +++ b/src/anking_notetypes/notetype_renames.py @@ -8,14 +8,6 @@ "AnKingMCAT": "AnKing MCAT", } -# Full renames for AnkiHub-qualified names where the deck portion also changed -# and cannot be derived from NOTETYPE_RENAMES alone. -FULL_NOTETYPE_RENAMES: Dict[str, str] = { - "AnKingMCAT (AnKing-MCAT / AnKingMed)": ( - "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" - ), -} - def canonical_notetype_name(notetype_name: str) -> str: return NOTETYPE_RENAMES.get(notetype_name, notetype_name) @@ -34,13 +26,12 @@ def matching_notetype_names(canonical_name: str) -> List[str]: def renamed_notetype_name(model_name: str) -> str: - for old_full, new_full in FULL_NOTETYPE_RENAMES.items(): - match = re.match(rf"{re.escape(old_full)}(?=$|-)", model_name) - if match: - return new_full + model_name[match.end() :] - + # Match bare name or copy-suffixed form only. AnkiHub-qualified forms + # like "AnKingMCAT (AnKing-MCAT / AnKingMed)" are owned by the AnkiHub + # add-on — we don't rename them here because the deck portion may or + # may not be renamed upstream, and we can't know. for old_name, new_name in NOTETYPE_RENAMES.items(): - match = re.match(rf"({re.escape(old_name)})(?=$| |-)", model_name) + match = re.match(rf"({re.escape(old_name)})(?=$|-)", model_name) if match: return new_name + model_name[match.end() :] return model_name diff --git a/tests/test_unit.py b/tests/test_unit.py index 552e6da..1056ce9 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -50,10 +50,6 @@ def test_mcat_legacy_name_maps_to_new_name(self): assert ( notetype_base_name("AnKingMCAT (AnKing-MCAT / AnKingMed)") == "AnKing MCAT" ) - assert ( - renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)") - == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)" - ) def test_mcat_canonical_name_not_confused_with_anking_prefix(self): # "AnKing" is a valid base and prefix of "AnKing MCAT" — verify the @@ -65,10 +61,11 @@ def test_mcat_canonical_name_not_confused_with_anking_prefix(self): == "AnKing MCAT" ) - def test_mcat_full_rename_preserves_copy_suffix(self): + def test_renamed_notetype_name_leaves_ankihub_qualified_alone(self): + # AnkiHub-qualified notetypes are renamed by the AnkiHub add-on, not here. assert ( - renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)-abcde") - == "AnKing MCAT (AnKing MCAT Deck / AnKingMed)-abcde" + renamed_notetype_name("AnKingMCAT (AnKing-MCAT / AnKingMed)") + == "AnKingMCAT (AnKing-MCAT / AnKingMed)" ) def test_renamed_notetype_name_ignores_unrelated_prefix(self): @@ -91,9 +88,10 @@ def test_renamed_notetype_name(self): with patch.dict(NOTETYPE_RENAMES, FAKE_RENAMES): assert renamed_notetype_name("Old-AnKing") == "AnKingOverhaul" assert renamed_notetype_name("Old-AnKing-1dgs0") == "AnKingOverhaul-1dgs0" + # AnkiHub-qualified form is left alone — AnkiHub owns that rename. assert ( renamed_notetype_name("Old-AnKing (AnKing / Example)") - == "AnKingOverhaul (AnKing / Example)" + == "Old-AnKing (AnKing / Example)" ) def test_notetype_base_name_recognizes_legacy_name(self): From 53a4b7d697057464efa6065a0be4b392e62f217d Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 16:14:12 +0200 Subject: [PATCH 13/16] Untrack .claude/ and ignore it --- .claude/scheduled_tasks.lock | 1 - .gitignore | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 3224520..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"52f64b5a-dfe7-430b-acde-6573edd143d3","pid":299951,"acquiredAt":1776779784452} diff --git a/.gitignore b/.gitignore index d2dd0ac..ec36a18 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ __pycache__/ node_modules yarn-error.log + +.claude/ From c992669ff9c59024ea2a0776d1bc2f8d9e4aef9e Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 16:14:48 +0200 Subject: [PATCH 14/16] Revert .claude/ from .gitignore (use local git exclude instead) --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index ec36a18..d2dd0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ __pycache__/ node_modules yarn-error.log - -.claude/ From 47ad28a21ce655f5d284e8238a9a699874d898e4 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 16:59:31 +0200 Subject: [PATCH 15/16] =?UTF-8?q?Drop=20usn=3D-1=20from=20rename=20?= =?UTF-8?q?=E2=80=94=20rename=20alone=20doesn't=20require=20full=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/anking_notetypes/gui/extra_notetype_versions.py | 1 - tests/test_unit.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/anking_notetypes/gui/extra_notetype_versions.py b/src/anking_notetypes/gui/extra_notetype_versions.py index 3ffa6bd..88e0d2a 100644 --- a/src/anking_notetypes/gui/extra_notetype_versions.py +++ b/src/anking_notetypes/gui/extra_notetype_versions.py @@ -148,7 +148,6 @@ def _rename_legacy_main_to_canonical(canonical_name: str) -> Optional["NotetypeD if legacy_model is None: continue legacy_model["name"] = canonical_name - legacy_model["usn"] = -1 mw.col.models.update_dict(legacy_model) return legacy_model return None diff --git a/tests/test_unit.py b/tests/test_unit.py index 1056ce9..5a35c4e 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -188,7 +188,6 @@ def test_renames_legacy_main_and_returns_canonical_model(self): ) assert legacy_model["name"] == "AnKingOverhaul" - assert legacy_model["usn"] == -1 mw_mock.col.models.update_dict.assert_called_once_with(legacy_model) assert result is legacy_model From 4fa86330dbddca2a38a1cd072dd5fb02d31891f2 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Tue, 21 Apr 2026 17:09:01 +0200 Subject: [PATCH 16/16] Test canonical-main-present skips legacy rename in convert flow --- tests/test_unit.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_unit.py b/tests/test_unit.py index 5a35c4e..0f13e67 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -205,3 +205,39 @@ def test_returns_none_when_no_legacy_main_exists(self): is None ) mw_mock.col.models.update_dict.assert_not_called() + + +class TestConvertExtraNotetypes: + def test_canonical_main_present_skips_legacy_rename(self): + canonical_model = { + "id": 1, + "name": "AnKingOverhaul", + "flds": [{"name": "Front"}], + } + copy_model = { + "id": 2, + "name": "AnKingOverhaul-abcde", + "flds": [{"name": "Front"}], + } + mw_mock = MagicMock() + mw_mock.col.models.by_name.side_effect = lambda name: ( + canonical_model if name == "AnKingOverhaul" else None + ) + mw_mock.col.models.get.return_value = copy_model + mw_mock.col.find_notes.return_value = [] + + with patch.object(extra_notetype_versions, "mw", mw_mock), patch.dict( + NOTETYPE_RENAMES, FAKE_RENAMES + ), patch.object( + extra_notetype_versions, "_rename_legacy_main_to_canonical" + ) as rename_mock, patch.object( + extra_notetype_versions, "adjust_fields", side_effect=lambda old, new: new + ), patch.object( + extra_notetype_versions, "tooltip" + ): + extra_notetype_versions.convert_extra_notetypes( + MagicMock(), {"AnKingOverhaul": [2]} + ) + + rename_mock.assert_not_called() + mw_mock.col.models.remove.assert_called_once_with(2)