diff --git a/src/anking_notetypes/notetype_renames.py b/src/anking_notetypes/notetype_renames.py new file mode 100644 index 0000000..66ba179 --- /dev/null +++ b/src/anking_notetypes/notetype_renames.py @@ -0,0 +1,37 @@ +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", +} + + +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: + # 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) + 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..fbacd3a 100644 --- a/src/anking_notetypes/notetype_setting_definitions.py +++ b/src/anking_notetypes/notetype_setting_definitions.py @@ -3,6 +3,13 @@ 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, + matching_notetype_names, +) + try: from anki.models import NotetypeDict # pylint: disable=unused-import except: @@ -675,7 +682,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 +697,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()] @@ -707,16 +732,42 @@ 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, 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. + candidates.sort(key=lambda pair: len(pair[0]), reverse=True) return next( ( - notetype_base_name - for notetype_base_name in anking_notetype_names() - if re.match(rf"{notetype_base_name}($| |-)", model_name) + base_name + for matching_name, base_name in candidates + if re.match(rf"{re.escape(matching_name)}($| |-)", model_name) ), None, ) +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") diff --git a/src/anking_notetypes/utils.py b/src/anking_notetypes/utils.py index 8e7f658..c4d4a52 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,13 @@ 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 or 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..0f13e67 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -1,4 +1,20 @@ +# pylint: disable=protected-access +from unittest.mock import MagicMock, patch + +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, + 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: @@ -25,3 +41,203 @@ 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" + ) + + 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_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)") + == "AnKingMCAT (AnKing-MCAT / AnKingMed)" + ) + + 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" + assert canonical_notetype_name("AnKingOverhaul") == "AnKingOverhaul" + + def test_matching_notetype_names(self): + 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, 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)") + == "Old-AnKing (AnKing / Example)" + ) + + def test_notetype_base_name_recognizes_legacy_name(self): + 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" + + +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"} + mw_mock = MagicMock() + mw_mock.col.models.by_name.side_effect = lambda name: { + "Old-AnKing": legacy_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" + mw_mock.col.models.update_dict.assert_called_once_with(legacy_model) + assert result is legacy_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() + + +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)