Skip to content

Commit

Permalink
Merge branch 'main' into feat/BUILD-975-add-configuration-button-to-s…
Browse files Browse the repository at this point in the history
…idebar-header
  • Loading branch information
RisingOrange committed Jan 16, 2025
2 parents 7243681 + 883a999 commit 1c1ea6c
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 39 deletions.
11 changes: 9 additions & 2 deletions ankihub/ankihub_client/ankihub_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1259,12 +1259,15 @@ def media_upload_finished(self, ah_did: uuid.UUID) -> None:
if response.status_code != 204:
raise AnkiHubHTTPError(response)

def owned_deck_ids(self) -> List[uuid.UUID]:
def get_user_details(self) -> Dict[str, Any]:
response = self._send_request("GET", API.ANKIHUB, "/users/me")
if response.status_code != 200:
raise AnkiHubHTTPError(response)

data = response.json()
return response.json()

def owned_deck_ids(self) -> List[uuid.UUID]:
data = self.get_user_details()
result = [uuid.UUID(deck["id"]) for deck in data["created_decks"]]
return result

Expand All @@ -1290,6 +1293,10 @@ def send_daily_card_review_summaries(
if response.status_code != 201:
raise AnkiHubHTTPError(response)

def is_premium_or_trialing(self) -> bool:
data = self.get_user_details()
return data["is_premium"] or data["is_trialing"]


class ThreadLocalSession:
def __init__(self):
Expand Down
20 changes: 1 addition & 19 deletions ankihub/gui/js_message_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@
from jinja2 import Template

from ..db import ankihub_db
from ..settings import url_plans_page, url_view_note
from ..settings import url_view_note
from .config_dialog import get_config_dialog_manager
from .operations.scheduling import suspend_notes, unsuspend_notes
from .utils import show_dialog

VIEW_NOTE_PYCMD = "ankihub_view_note"
VIEW_NOTE_BUTTON_ID = "ankihub-view-note-button"
Expand All @@ -28,7 +27,6 @@
UNSUSPEND_NOTES_PYCMD = "ankihub_unsuspend_notes"
SUSPEND_NOTES_PYCMD = "ankihub_suspend_notes"
GET_NOTE_SUSPENSION_STATES_PYCMD = "ankihub_get_note_suspension_states"
ANKIHUB_UPSELL = "ankihub_ai_upsell"
COPY_TO_CLIPBOARD_PYCMD = "ankihub_copy_to_clipboard"
OPEN_LINK_PYCMD = "ankihub_open_link"
OPEN_CONFIG_PYCMD = "ankihub_open_config"
Expand Down Expand Up @@ -97,22 +95,6 @@ def _on_js_message(handled: Tuple[bool, Any], message: str, context: Any) -> Any
web=reviewer_sidebar.content_webview,
)
return (True, None)
elif message == ANKIHUB_UPSELL:

def on_button_clicked(button_index: int) -> None:
if button_index == 1:
openLink(url_plans_page())

show_dialog(
text="Upgrade your membership to <b>Premium</b> to access this feature 🌟",
title="Your trial has ended!",
buttons=[
("Cancel", aqt.QDialogButtonBox.ButtonRole.RejectRole),
("Upgrade", aqt.QDialogButtonBox.ButtonRole.ActionRole),
],
default_button_idx=1,
callback=on_button_clicked,
)
elif message.startswith(COPY_TO_CLIPBOARD_PYCMD):
kwargs = parse_js_message_kwargs(message)
content = kwargs.get("content")
Expand Down
58 changes: 51 additions & 7 deletions ankihub/gui/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,29 @@
from concurrent.futures import Future
from functools import partial
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
from uuid import UUID

import aqt
from aqt.gui_hooks import overview_did_refresh, webview_did_receive_js_message
from aqt.utils import tooltip
from aqt.qt import QDialogButtonBox
from aqt.utils import openLink, tooltip
from aqt.webview import AnkiWebView
from jinja2 import Template

from .. import LOGGER
from ..addon_ankihub_client import AddonAnkiHubClient as AnkiHubClient
from ..feature_flags import add_feature_flags_update_callback
from ..settings import config, url_flashcard_selector, url_flashcard_selector_embed
from ..settings import (
config,
url_flashcard_selector,
url_flashcard_selector_embed,
url_plans_page,
)
from .deck_updater import ah_deck_updater
from .js_message_handling import parse_js_message_kwargs
from .menu import AnkiHubLogin
from .utils import get_ah_did_of_deck_or_ancestor_deck
from .utils import get_ah_did_of_deck_or_ancestor_deck, show_dialog
from .webview import AnkiHubWebViewDialog

ADD_FLASHCARD_SELECTOR_BUTTON_JS_PATH = (
Expand Down Expand Up @@ -55,7 +62,6 @@ def _maybe_add_flashcard_selector_button() -> None:

if not aqt.mw.state == "overview":
return

ah_did = get_ah_did_of_deck_or_ancestor_deck(aqt.mw.col.decks.current()["id"])
if (
not config.deck_config(ah_did)
Expand All @@ -81,16 +87,54 @@ def _maybe_add_flashcard_selector_button() -> None:
overview_web.eval(js)


def _show_flashcard_selector_upsell_if_user_has_no_access(
on_done: Callable[[bool], None]
) -> None:
user_details = AnkiHubClient().get_user_details()
has_access = user_details["is_premium"] or user_details["is_trialing"]
if has_access:
on_done(True)
return
show_trial_ended_message = user_details["show_trial_ended_message"]
text = "Let AI do the heavy lifting! Find flashcards perfectly matched to your study materials and elevate your \
learning experience with Premium. 🌟"
if show_trial_ended_message:
title = "Your Trial Has Ended! 🎓✨"
else:
title = "📚 Unlock Your Potential with Premium"

def on_button_clicked(button_index: int) -> None:
if button_index == 1:
openLink(url_plans_page())
on_done(False)

show_dialog(
text,
title,
parent=aqt.mw,
buttons=[
("Not Now", QDialogButtonBox.ButtonRole.RejectRole),
("Learn More", QDialogButtonBox.ButtonRole.HelpRole),
],
default_button_idx=1,
callback=on_button_clicked,
)


def _handle_flashcard_selector_py_commands(
handled: tuple[bool, Any], message: str, context: Any
) -> tuple[bool, Any]:
if message.startswith(FLASHCARD_SELECTOR_OPEN_PYCMD):
kwargs = parse_js_message_kwargs(message)
ah_did = UUID(kwargs.get("deck_id"))

FlashCardSelectorDialog.display_for_ah_did(ah_did=ah_did, parent=aqt.mw)
def on_checked_for_access(has_access: bool) -> None:
if has_access:
FlashCardSelectorDialog.display_for_ah_did(ah_did=ah_did, parent=aqt.mw)
LOGGER.info("Opened flashcard selector dialog.")

_show_flashcard_selector_upsell_if_user_has_no_access(on_checked_for_access)

LOGGER.info("Opened flashcard selector dialog.")
return (True, None)
elif message.startswith(FLASHCARD_SELECTOR_SYNC_NOTES_ACTIONS_PYCMD):
kwargs = parse_js_message_kwargs(message)
Expand Down
9 changes: 1 addition & 8 deletions ankihub/gui/reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@
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 .js_message_handling import (
ANKIHUB_UPSELL,
VIEW_NOTE_PYCMD,
parse_js_message_kwargs,
)
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 (
get_empty_state_html,
Expand Down Expand Up @@ -619,9 +615,6 @@ def _on_js_message(handled: Tuple[bool, Any], message: str, context: Any) -> Any
openLink(url)

return True, None
elif message == ANKIHUB_UPSELL:
reviewer_sidebar.close_sidebar()
return True, None

return handled

Expand Down
14 changes: 14 additions & 0 deletions ankihub/gui/web/media/_chatbot_icon_sleeping.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions ankihub/gui/web/media/_chatbot_icon_sleeping_dark_theme.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion ankihub/gui/web/reviewer_buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class AnkiHubReviewerButtons {
constructor() {
this.theme = "{{ THEME }}";
this.isPremiumOrTrialing = "{{ IS_PREMIUM_OR_TRIALING }}" == "True";
this.isAnKingDeck = null;
this.bbCount = 0;
this.faCount = 0;
Expand Down Expand Up @@ -35,7 +36,8 @@ class AnkiHubReviewerButtons {
},
{
name: "chatbot",
iconPath: "/_chatbot_icon.svg",
iconPath: this.isPremiumOrTrialing ? "/_chatbot_icon.svg" : "/_chatbot_icon_sleeping.svg",
iconPathDarkTheme: this.isPremiumOrTrialing ? null : "/_chatbot_icon_sleeping_dark_theme.svg",
active: false,
tooltip: "AI Chatbot"
},
Expand Down
4 changes: 4 additions & 0 deletions ankihub/gui/web/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from jinja2 import Environment, FileSystemLoader, select_autoescape

from ...addon_ankihub_client import AddonAnkiHubClient as AnkiHubClient

TEMPLATES_PATH = (pathlib.Path(__file__).parent).absolute()

env = Environment(
Expand All @@ -24,10 +26,12 @@ def get_header_webview_html(


def get_reviewer_buttons_js(theme: str, enabled_buttons: List[str]) -> str:
client = AnkiHubClient()
return env.get_template("reviewer_buttons.js").render(
{
"THEME": theme,
"ENABLED_BUTTONS": ",".join(enabled_buttons),
"IS_PREMIUM_OR_TRIALING": str(client.is_premium_or_trialing()),
}
)

Expand Down
88 changes: 86 additions & 2 deletions tests/addon/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
browser_will_show_context_menu,
)
from aqt.importing import AnkiPackageImporter
from aqt.qt import QAction, Qt, QUrl
from aqt.qt import QAction, Qt, QUrl, QWidget
from aqt.theme import theme_manager
from aqt.webview import AnkiWebView
from pytest import fixture
Expand Down Expand Up @@ -5674,6 +5674,16 @@ def test_clicking_button_opens_flashcard_selector_dialog(

mocker.patch.object(AnkiWebView, "load_url")

mocker.patch.object(
AnkiHubClient,
"get_user_details",
return_value={
"is_premium": True,
"is_trialing": False,
"show_trial_ended_message": False,
},
)

overview_web: AnkiWebView = aqt.mw.overview.web
overview_web.eval(
f"document.getElementById('{FLASHCARD_SELECTOR_OPEN_BUTTON_ID}').click()",
Expand Down Expand Up @@ -5711,6 +5721,16 @@ def test_clicking_button_twice_shows_existing_dialog_again(

mocker.patch.object(AnkiWebView, "load_url")

mocker.patch.object(
AnkiHubClient,
"get_user_details",
return_value={
"is_premium": True,
"is_trialing": False,
"show_trial_ended_message": False,
},
)

overview_web: AnkiWebView = aqt.mw.overview.web
overview_web.eval(
f"document.getElementById('{FLASHCARD_SELECTOR_OPEN_BUTTON_ID}').click()",
Expand Down Expand Up @@ -5738,6 +5758,64 @@ def flashcard_selector_opened():

assert FlashCardSelectorDialog.dialog == dialog

@pytest.mark.sequential
@pytest.mark.parametrize(
"show_trial_ended_message",
[False, True],
)
def test_shows_flashcard_selector_upsell_if_no_access(
self,
anki_session_with_addon_data: AnkiSession,
install_ah_deck: InstallAHDeck,
qtbot: QtBot,
set_feature_flag_state: SetFeatureFlagState,
mocker: MockerFixture,
show_trial_ended_message: bool,
):
set_feature_flag_state("show_flashcards_selector_button")

entry_point.run()
with anki_session_with_addon_data.profile_loaded():
mocker.patch.object(config, "token")

anki_did = DeckId(1)
install_ah_deck(
anki_did=anki_did,
has_note_embeddings=True,
)
aqt.mw.deckBrowser.set_current_deck(anki_did)

qtbot.wait(500)

mocker.patch.object(AnkiWebView, "load_url")

mocker.patch.object(
AnkiHubClient,
"get_user_details",
return_value={
"is_premium": False,
"is_trialing": False,
"show_trial_ended_message": show_trial_ended_message,
},
)

overview_web: AnkiWebView = aqt.mw.overview.web
overview_web.eval(
f"document.getElementById('{FLASHCARD_SELECTOR_OPEN_BUTTON_ID}').click()",
)

def upsell_dialog_opened():
dialog: QWidget = aqt.mw.app.activeWindow()
if not isinstance(dialog, utils._Dialog):
return False
return (
"Trial" in dialog.windowTitle()
if show_trial_ended_message
else True
)

qtbot.wait_until(upsell_dialog_opened)

def test_with_no_auth_token(
self,
anki_session_with_addon_data: AnkiSession,
Expand Down Expand Up @@ -6019,9 +6097,15 @@ def mock_using_qt5_to_return_false(mocker: MockerFixture):
mocker.patch("ankihub.gui.reviewer.using_qt5", return_value=False)


@pytest.fixture
def mock_user_details(mocker: MockerFixture):
user_details = {"is_premium": True, "is_trialing": False}
mocker.patch.object(AnkiHubClient, "get_user_details", return_value=user_details)


# The mock_using_qt5_to_return_false fixture is used to test the AnkiHub AI feature on Qt5,
# even though the feature is disabled on Qt5. (In CI we are only running test on Qt5.)
@pytest.mark.usefixtures("mock_using_qt5_to_return_false")
@pytest.mark.usefixtures("mock_using_qt5_to_return_false", "mock_user_details")
class TestAnkiHubAIInReviewer:
@pytest.mark.sequential
@pytest.mark.parametrize(
Expand Down

0 comments on commit 1c1ea6c

Please sign in to comment.