diff --git a/ankihub/config.json b/ankihub/config.json index 25f168b50..27ffdebb2 100644 --- a/ankihub/config.json +++ b/ankihub/config.json @@ -6,6 +6,8 @@ "debug_level_logs": false, "use_staging": false, "ankihub_ai_chatbot": true, - "boards_and_beyond": true, - "first_aid_forward": true + "boards_and_beyond_step_1": true, + "boards_and_beyond_step_2": true, + "first_aid_forward_step_1": true, + "first_aid_forward_step_2": true } diff --git a/ankihub/gui/config_dialog.py b/ankihub/gui/config_dialog.py index 82b1baaa5..643ba7fb5 100644 --- a/ankihub/gui/config_dialog.py +++ b/ankihub/gui/config_dialog.py @@ -9,6 +9,9 @@ from typing import cast +from aqt import qconnect +from aqt.qt import QCheckBox, Qt + from ..settings import config _config_dialog_manager = None @@ -56,8 +59,14 @@ def _general_tab(conf_window) -> None: if config.get_feature_flags().get("mh_integration"): tab.text("Sidebar", bold=True) tab.checkbox("ankihub_ai_chatbot", "AnkiHub AI Chatbot") - tab.checkbox("boards_and_beyond", "Boards and Beyond") - tab.checkbox("first_aid_forward", "First Aid Forward") + + add_nested_checkboxes( + tab, key_prefix="boards_and_beyond", description="Boards and Beyond" + ) + add_nested_checkboxes( + tab, key_prefix="first_aid_forward", description="First Aid Forward" + ) + tab.hseparator() tab.space(8) @@ -66,3 +75,47 @@ def _general_tab(conf_window) -> None: tab.checkbox("debug_level_logs", "Verbose logs (restart required)") tab.stretch() + + +def add_nested_checkboxes(config_layout, key_prefix: str, description: str) -> None: + + from .ankiaddonconfig.window import ConfigLayout + + config_layout = cast(ConfigLayout, config_layout) + + main_checkbox = QCheckBox(description) + config_layout.addWidget(main_checkbox) + + container_outer = config_layout.hcontainer() + container_outer.setContentsMargins(0, 2, 0, 2) + + container_inner = container_outer.vcontainer() + container_inner.setContentsMargins(30, 0, 0, 0) + + step_1_checkbox = container_inner.checkbox( + f"{key_prefix}_step_1", description="USMLE Step 1" + ) + step_2_checkbox = container_inner.checkbox( + f"{key_prefix}_step_2", description="USMLE Step 2" + ) + + def update_main_checkbox() -> None: + checkboxes = [step_1_checkbox, step_2_checkbox] + checked_count = sum(checkbox.isChecked() for checkbox in checkboxes) + + if checked_count == 0: + main_checkbox.setCheckState(Qt.CheckState.Unchecked) + elif checked_count == len(checkboxes): + main_checkbox.setCheckState(Qt.CheckState.Checked) + else: + main_checkbox.setCheckState(Qt.CheckState.PartiallyChecked) + + def on_main_checkbox_clicked() -> None: + is_checked = main_checkbox.checkState() != Qt.CheckState.Unchecked + main_checkbox.setChecked(is_checked) + step_1_checkbox.setChecked(is_checked) + step_2_checkbox.setChecked(is_checked) + + qconnect(step_1_checkbox.stateChanged, update_main_checkbox) + qconnect(step_2_checkbox.stateChanged, update_main_checkbox) + qconnect(main_checkbox.clicked, on_main_checkbox_clicked) diff --git a/ankihub/gui/reviewer.py b/ankihub/gui/reviewer.py index 62231f4b2..b9cc77465 100644 --- a/ankihub/gui/reviewer.py +++ b/ankihub/gui/reviewer.py @@ -1,7 +1,6 @@ """Modifies Anki's reviewer UI (aqt.reviewer).""" import uuid -from dataclasses import dataclass from enum import Enum from textwrap import dedent from typing import Any, Callable, Dict, List, Optional, Set, Tuple @@ -27,8 +26,8 @@ from ..db import ankihub_db from ..gui.menu import AnkiHubLogin from ..gui.webview import AuthenticationRequestInterceptor, CustomWebPage # noqa: F401 -from ..main.utils import mh_tag_to_resource_title_and_slug -from ..settings import config, url_login, url_mh_integrations_preview +from ..main.utils import Resource, mh_tag_to_resource +from ..settings import config, url_login from .js_message_handling import VIEW_NOTE_PYCMD, parse_js_message_kwargs from .utils import get_ah_did_of_deck_or_ancestor_deck, using_qt5 from .web.templates import ( @@ -71,12 +70,6 @@ class ResourceType(Enum): } -@dataclass(frozen=True) -class Resource: - title: str - url: str - - class ReviewerSidebar: def __init__(self, reviewer: Reviewer): self.reviewer = reviewer @@ -426,26 +419,21 @@ def _inject_ankihub_features_and_setup_sidebar( def _get_enabled_buttons_list() -> List[str]: - buttons_map = {} + result = [] feature_flags = config.get_feature_flags() if feature_flags.get("chatbot"): - buttons_map["ankihub_ai_chatbot"] = "chatbot" + if config.public_config.get("ankihub_ai_chatbot"): + result.append("chatbot") if feature_flags.get("mh_integration"): - buttons_map.update( - { - "boards_and_beyond": "b&b", - "first_aid_forward": "fa4", - } - ) + if _get_enabled_steps_for_resource_type(ResourceType.BOARDS_AND_BEYOND): + result.append("b&b") + if _get_enabled_steps_for_resource_type(ResourceType.FIRST_AID): + result.append("fa4") - return [ - buttons_map[key] - for key, value in config.public_config.items() - if key in buttons_map and value - ] + return result def _related_ah_deck_has_note_embeddings(note: Note) -> bool: @@ -554,14 +542,29 @@ def _show_resources_for_current_card(resource_type: ResourceType) -> None: def _get_resources(tags: List[str], resource_type: ResourceType) -> List[Resource]: resource_tags = _get_resource_tags(tags, resource_type) result = { - Resource(title=title, url=url_mh_integrations_preview(slug)) + resource for tag in resource_tags - if (title_and_slug := mh_tag_to_resource_title_and_slug(tag)) - for title, slug in [title_and_slug] + if ( + (resource := mh_tag_to_resource(tag)).usmle_step + in _get_enabled_steps_for_resource_type(resource_type) + ) } return list(sorted(result, key=lambda x: x.title)) +def _get_enabled_steps_for_resource_type(resource_type: ResourceType) -> Set[int]: + resource_type_to_config_key_prefix = { + ResourceType.BOARDS_AND_BEYOND: "boards_and_beyond", + ResourceType.FIRST_AID: "first_aid_forward", + } + config_key_prefix = resource_type_to_config_key_prefix[resource_type] + return { + step + for step in [1, 2] + if config.public_config.get(f"{config_key_prefix}_step_{step}") + } + + def _get_resource_tags(tags: List[str], resource_type: ResourceType) -> Set[str]: """Get all (v12) tags matching a specific resource type.""" search_pattern = f"v12::{RESOURCE_TYPE_TO_TAG_PART[resource_type]}".lower() diff --git a/ankihub/main/utils.py b/ankihub/main/utils.py index c6e7b23da..999b98745 100644 --- a/ankihub/main/utils.py +++ b/ankihub/main/utils.py @@ -4,6 +4,7 @@ import time from collections import defaultdict from concurrent.futures import Future +from dataclasses import dataclass from pathlib import Path from textwrap import dedent from typing import Any, Collection, Dict, Iterable, List, Optional, Sequence, Set, Tuple @@ -24,6 +25,7 @@ ANKIHUB_NOTE_TYPE_FIELD_NAME, ANKIHUB_NOTE_TYPE_MODIFICATION_STRING, ANKIHUB_TEMPLATE_END_COMMENT, + url_mh_integrations_preview, url_view_note, ) @@ -659,7 +661,14 @@ def collection_schema() -> int: return aqt.mw.col.db.scalar("select scm from col") -def mh_tag_to_resource_title_and_slug(tag: str) -> Optional[Tuple[str, str]]: +@dataclass(frozen=True) +class Resource: + title: str + url: str + usmle_step: int + + +def mh_tag_to_resource(tag: str) -> Optional[Resource]: """Converts a McGrawHill tag to a title and URL for the MH resource preview. Example: @@ -695,4 +704,4 @@ def mh_tag_to_resource_title_and_slug(tag: str) -> Optional[Tuple[str, str]]: # We want to ignore any tags that don't match the expected format return None - return title, slug + return Resource(title=title, url=url_mh_integrations_preview(slug), usmle_step=step) diff --git a/ankihub/public_config_migrations.py b/ankihub/public_config_migrations.py index b905606a7..309c2163e 100644 --- a/ankihub/public_config_migrations.py +++ b/ankihub/public_config_migrations.py @@ -26,3 +26,11 @@ def migrate_public_config() -> None: if "ankihub_url" in addon_config: addon_config.pop("ankihub_url") aqt.mw.addonManager.writeConfig(__name__, addon_config) + + if "boards_and_beyond" in addon_config: + addon_config.pop("boards_and_beyond") + aqt.mw.addonManager.writeConfig(__name__, addon_config) + + if "first_aid_forward" in addon_config: + addon_config.pop("first_aid_forward") + aqt.mw.addonManager.writeConfig(__name__, addon_config) diff --git a/tests/addon/test_unit.py b/tests/addon/test_unit.py index 53bbec79f..392ff5109 100644 --- a/tests/addon/test_unit.py +++ b/tests/addon/test_unit.py @@ -28,24 +28,6 @@ from requests import Response from requests_mock import Mocker -from ankihub.ankihub_client.ankihub_client import ( - DEFAULT_API_URL, - AnkiHubRequestException, -) -from ankihub.ankihub_client.models import ( # type: ignore - CardReviewData, - DailyCardReviewSummary, - UserDeckExtensionRelation, -) -from ankihub.gui import menu -from ankihub.gui.config_dialog import setup_config_dialog_manager -from ankihub.gui.exceptions import DeckDownloadAndInstallError -from ankihub.main.review_data import ( - get_daily_review_summaries_since_last_sync, - send_daily_review_summaries, -) -from ankihub.settings import ANKIHUB_TEMPLATE_END_COMMENT, DatadogLogHandler - from ..factories import ( DeckExtensionFactory, DeckFactory, @@ -54,6 +36,7 @@ ) from ..fixtures import ( # type: ignore AddAnkiNote, + ImportAHNote, ImportAHNoteType, InstallAHDeck, LatestInstanceTracker, @@ -64,7 +47,6 @@ create_anki_deck, record_review_for_anki_nid, ) -from .test_integration import ImportAHNote # workaround for vscode test discovery not using pytest.ini which sets this env var # has to be set before importing ankihub @@ -79,6 +61,16 @@ SuggestionType, TagGroupValidationResponse, ) +from ankihub.ankihub_client.ankihub_client import ( + DEFAULT_API_URL, + DEFAULT_APP_URL, + AnkiHubRequestException, +) +from ankihub.ankihub_client.models import ( # type: ignore + CardReviewData, + DailyCardReviewSummary, + UserDeckExtensionRelation, +) from ankihub.db.db import _AnkiHubDB from ankihub.db.exceptions import IntegrityError from ankihub.db.models import AnkiHubNote, DeckMedia, get_peewee_database @@ -87,6 +79,8 @@ add_feature_flags_update_callback, update_feature_flags_in_background, ) +from ankihub.gui import menu +from ankihub.gui.config_dialog import setup_config_dialog_manager from ankihub.gui.error_dialog import ErrorDialog from ankihub.gui.errors import ( OUTDATED_CLIENT_RESPONSE_DETAIL, @@ -95,6 +89,7 @@ _try_handle_exception, upload_logs_in_background, ) +from ankihub.gui.exceptions import DeckDownloadAndInstallError from ankihub.gui.media_sync import media_sync from ankihub.gui.menu import AnkiHubLogin, menu_state, refresh_ankihub_menu from ankihub.gui.operations.deck_creation import ( @@ -139,6 +134,8 @@ from ankihub.main.review_data import ( _get_first_and_last_review_datetime_for_ah_deck, _get_review_count_for_ah_deck_since, + get_daily_review_summaries_since_last_sync, + send_daily_review_summaries, send_review_data, ) from ankihub.main.subdecks import ( @@ -148,14 +145,21 @@ ) from ankihub.main.suggestions import ChangeSuggestionResult from ankihub.main.utils import ( + Resource, clear_empty_cards, lowest_level_common_ancestor_deck_name, - mh_tag_to_resource_title_and_slug, + mh_tag_to_resource, mids_of_notes, note_type_with_updated_templates, retain_nids_with_ah_note_type, ) -from ankihub.settings import ANKIWEB_ID, config, log_file_path +from ankihub.settings import ( + ANKIHUB_TEMPLATE_END_COMMENT, + ANKIWEB_ID, + DatadogLogHandler, + config, + log_file_path, +) @pytest.fixture @@ -3055,32 +3059,52 @@ def test_send_daily_review_summaries_without_data(mocker): mock_anki_hub_client.send_daily_card_review_summaries.assert_not_called() +def url_mh_integrations_preview(slug: str) -> str: + # Test-specific replacement for settings.url_mh_integrations_preview using DEFAULT_APP_URL + # We need this because: + # 1. settings.config.app_url is not initialized during test startup + # 2. pytest needs these URLs during test collection for parametrize data + return f"{DEFAULT_APP_URL}/integrations/mcgraw-hill/preview/{slug}" + + @pytest.mark.parametrize( - "tag, expected_title, expected_slug", + "tag, expected_resource", [ # With B&B tag ( "#AK_Step1_v12::#B&B::03_Biochem::03_Amino_Acids::04_Ammonia", - "Ammonia", - "step1-bb-3-3-4", + Resource( + "Ammonia", + url_mh_integrations_preview("step1-bb-3-3-4"), + 1, + ), ), # With First Aid tag ( "#AK_Step2_v12::#FirstAid::14_Pulm::16_Nose_and_Throat::01_Rhinitis", - "Rhinitis", - "step2-fa-14-16-1", + Resource( + "Rhinitis", + url_mh_integrations_preview("step2-fa-14-16-1"), + 2, + ), ), # With lowercase tag ( "#ak_step1_v12::#b&b::03_biochem::03_amino_acids::04_ammonia", - "Ammonia", - "step1-bb-3-3-4", + Resource( + "Ammonia", + url_mh_integrations_preview("step1-bb-3-3-4"), + 1, + ), ), # With trailing Extra tag part that should be ignored ( "#AK_Step1_v12::#B&B::03_Biochem::03_Amino_Acids::04_Ammonia::Extra", - "Ammonia", - "step1-bb-3-3-4", + Resource( + "Ammonia", + url_mh_integrations_preview("step1-bb-3-3-4"), + 1, + ), ), # With tag part group starting with "*" that should be ignored ( @@ -3088,29 +3112,31 @@ def test_send_daily_review_summaries_without_data(mocker): "#AK_Step1_v12::#FirstAid::05_Pharm::02_Autonomic_Drugs::15_beta-blockers::" "*B-Antagonists::Cardioselective_B1_Antagonists" ), - "Beta-blockers", - "step1-fa-5-2-15", + Resource( + "Beta-blockers", + url_mh_integrations_preview("step1-fa-5-2-15"), + 1, + ), ), # With number later in tag part ( "#AK_Step1_v12::#FirstAid::01_Biochem::03_Laboratory_Techniques::02_CRISPR/Cas9", - "Crispr/cas9", - "step1-fa-1-3-2", + Resource( + "Crispr/cas9", + url_mh_integrations_preview("step1-fa-1-3-2"), + 1, + ), ), # With invalid tag - ("invalid_tag", None, None), + ("invalid_tag", None), # With invalid tag (core tag part doesn't start with number) ( "#AK_Step1_v12::#FirstAid::Biochem::03_Laboratory_Techniques::CRISPR/Cas9", None, - None, ), ], ) def test_mh_tag_to_resource_title_and_slug( - tag: str, expected_title: str, expected_slug: str + tag: str, expected_resource: Optional[Resource] ): - if expected_title is None: - assert mh_tag_to_resource_title_and_slug(tag) is None - else: - assert mh_tag_to_resource_title_and_slug(tag) == (expected_title, expected_slug) + assert mh_tag_to_resource(tag) == expected_resource