From 42f1c1f9cdacd3c50f5c651e8238830a70b462b2 Mon Sep 17 00:00:00 2001 From: Jakub Fidler <31575114+RisingOrange@users.noreply.github.com> Date: Thu, 19 Oct 2023 11:05:55 +0200 Subject: [PATCH] ref: Move `create_collaborative_deck` to `gui.operations.deck_creation`, Remove `SubscribeDialog` (#778) * ref: Extract create_collaborative_deck operation * Remove unused SubscribeDialog * Add test * Increase coverage --- ankihub/gui/decks_dialog.py | 111 +----------- ankihub/gui/menu.py | 160 +---------------- .../gui/operations/db_check/ah_db_check.py | 2 +- ankihub/gui/operations/deck_creation.py | 167 ++++++++++++++++++ tests/addon/test_integration.py | 3 +- tests/addon/test_unit.py | 91 +++++++++- tests/fixtures.py | 15 ++ 7 files changed, 276 insertions(+), 273 deletions(-) create mode 100644 ankihub/gui/operations/deck_creation.py diff --git a/ankihub/gui/decks_dialog.py b/ankihub/gui/decks_dialog.py index aa523fb26..3bd3d312b 100644 --- a/ankihub/gui/decks_dialog.py +++ b/ankihub/gui/decks_dialog.py @@ -1,6 +1,5 @@ """Dialog for managing subscriptions to AnkiHub decks and deck-specific settings.""" import uuid -from concurrent.futures import Future from typing import Optional from uuid import UUID @@ -11,12 +10,9 @@ QDialog, QDialogButtonBox, QHBoxLayout, - QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPushButton, - QSizePolicy, Qt, QVBoxLayout, qconnect, @@ -27,8 +23,7 @@ from ..addon_ankihub_client import AddonAnkiHubClient as AnkiHubClient from ..main.deck_unsubscribtion import unsubscribe_from_deck_and_uninstall from ..main.subdecks import SUBDECK_TAG -from ..settings import config, url_deck_base, url_decks, url_help -from .operations.deck_installation import download_and_install_decks +from ..settings import config, url_deck_base, url_decks from .operations.subdecks import confirm_and_toggle_subdecks from .utils import ask_user, set_tooltip_icon @@ -114,12 +109,6 @@ def _refresh_anki(self) -> None: op.study_queues = True gui_hooks.operation_did_execute(op, handler=None) - def _on_add(self) -> None: - SubscribeDialog().exec() - - self._refresh_decks_list() - self._refresh_anki() - def _select_deck(self, ah_did: uuid.UUID): deck_item = next( ( @@ -270,101 +259,3 @@ def __init__(self, *args, **kwargs) -> None: self.form.buttonBox.removeButton( self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help) ) - - -class SubscribeDialog(QDialog): - silentlyClose = True - - def __init__(self): - super(SubscribeDialog, self).__init__() - - self.thread = None # type: ignore - self.box_top = QVBoxLayout() - self.box_mid = QHBoxLayout() - self.box_left = QVBoxLayout() - self.box_right = QVBoxLayout() - - self.deck_id_box = QHBoxLayout() - self.deck_id_box_label = QLabel("Deck ID:") - self.deck_id_box_text = QLineEdit("", self) - self.deck_id_box_text.setMinimumWidth(300) - self.deck_id_box.addWidget(self.deck_id_box_label) - self.deck_id_box.addWidget(self.deck_id_box_text) - self.box_left.addLayout(self.deck_id_box) - - self.box_mid.addLayout(self.box_left) - self.box_mid.addSpacing(20) - self.box_mid.addLayout(self.box_right) - - self.buttonbox = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel # type: ignore - ) - self.buttonbox.button(QDialogButtonBox.StandardButton.Ok).setText("Subscribe") - self.browse_btn = self.buttonbox.addButton( - "Browse Decks", QDialogButtonBox.ButtonRole.ActionRole - ) - qconnect(self.browse_btn.clicked, self._on_browse_deck) - qconnect(self.buttonbox.accepted, self._subscribe) - self.buttonbox.rejected.connect(self.close) - - self.instructions_label = QLabel( - "
Copy/Paste a Deck ID from AnkiHub.net/decks to subscribe.
" - ) - # Add all widgets to top layout. - self.box_top.addWidget(self.instructions_label) - self.box_top.addSpacing(10) - self.box_top.addLayout(self.box_mid) - self.box_top.addStretch(1) - self.box_top.addWidget(self.buttonbox) - self.setLayout(self.box_top) - - self.setMinimumWidth(500) - self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - self.setWindowTitle("Subscribe to AnkiHub Deck") - - self.client = AnkiHubClient() - if not config.is_logged_in(): - showText("Oops! Please make sure you are logged into AnkiHub!") - self.close() - else: - self.show() - - def _subscribe(self) -> None: - ah_did_str = self.deck_id_box_text.text().strip() - - try: - ah_did = uuid.UUID(ah_did_str) - except ValueError: - showInfo( - "The format of the Deck ID is invalid. Please make sure you copied the Deck ID correctly." - ) - return - - if ah_did in config.deck_ids(): - showText( - f"You've already subscribed to deck {ah_did}. " - "Syncing with AnkiHub will happen automatically everytime you " - "restart Anki. You can manually sync with AnkiHub from the AnkiHub " - f"menu. See {url_help()} for more details." - ) - self.close() - return - - confirmed = ask_user( - f"Would you like to proceed with downloading and installing the deck? " - f"Your personal collection will be modified.

" - f"See {url_help()} for details.", - title="Please confirm to proceed.", - ) - if not confirmed: - return - - def on_done(future: Future) -> None: - future.result() - - self.accept() - - download_and_install_decks([ah_did], on_done=on_done) - - def _on_browse_deck(self) -> None: - openLink(url_decks()) diff --git a/ankihub/gui/menu.py b/ankihub/gui/menu.py index eaa454380..789f16942 100644 --- a/ankihub/gui/menu.py +++ b/ankihub/gui/menu.py @@ -2,42 +2,35 @@ import re from concurrent.futures import Future from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path from typing import Optional import aqt from aqt import ( AnkiApp, - QCheckBox, QHBoxLayout, QLabel, QLineEdit, - QMessageBox, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) -from aqt.operations import QueryOp from aqt.qt import QAction, QDialog, QKeySequence, QMenu, Qt, qconnect -from aqt.studydeck import StudyDeck from aqt.utils import openLink, showInfo, tooltip from .. import LOGGER from ..addon_ankihub_client import AddonAnkiHubClient as AnkiHubClient -from ..ankihub_client import AnkiHubHTTPError, get_media_names_from_notes_data -from ..ankihub_client.models import UserDeckRelation +from ..ankihub_client import AnkiHubHTTPError from ..db import ankihub_db -from ..main.deck_creation import DeckCreationResult, create_ankihub_deck -from ..main.subdecks import SUBDECK_TAG from ..media_import.ui import open_import_dialog -from ..settings import ADDON_VERSION, config, url_view_deck +from ..settings import ADDON_VERSION, config from .config_dialog import get_config_dialog_manager from .decks_dialog import SubscribedDecksDialog from .errors import upload_logs_and_data_in_background, upload_logs_in_background from .media_sync import media_sync from .operations.ankihub_sync import sync_with_ankihub +from .operations.deck_creation import create_collaborative_deck from .utils import ( ask_user, check_and_prompt_for_updates_on_main_window, @@ -192,154 +185,9 @@ def display_login(cls): return cls._window -class DeckCreationConfirmationDialog(QMessageBox): - def __init__(self): - super().__init__(parent=aqt.mw) - - self.setWindowTitle("Confirm AnkiHub Deck Creation") - self.setIcon(QMessageBox.Icon.Question) - self.setText( - "Are you sure you want to create a new collaborative deck?


" - 'Terms of use: https://www.ankihub.net/terms
' - 'Privacy Policy: https://www.ankihub.net/privacy
', - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel # type: ignore - ) - self.confirmation_cb = QCheckBox( - text=" by checking this checkbox you agree to the terms of use", - parent=self, - ) - self.setCheckBox(self.confirmation_cb) - - def run(self) -> bool: - clicked_ok = self.exec() == QMessageBox.StandardButton.Yes - if not clicked_ok: - return False - - if not self.confirmation_cb.isChecked(): - tooltip("You didn't agree to the terms of use.") - return False - - return True - - -def _create_collaborative_deck_action() -> None: - - confirm = DeckCreationConfirmationDialog().run() - if not confirm: - return - - LOGGER.info("Asking user to choose a deck to upload...") - deck_chooser = StudyDeck( - aqt.mw, - title="AnkiHub", - accept="Upload", - # Removes the "Add" button - buttons=[], - names=lambda: [ - d.name - for d in aqt.mw.col.decks.all_names_and_ids(include_filtered=False) - if "::" not in d.name and d.id != 1 - ], - parent=aqt.mw, - ) - LOGGER.info(f"Closed deck chooser dialog: {deck_chooser}") - LOGGER.info(f"Chosen deck name: {deck_chooser.name}") - deck_name = deck_chooser.name - if not deck_name: - return - - if len(aqt.mw.col.find_cards(f'deck:"{deck_name}"')) == 0: - showInfo("You can't upload an empty deck.") - return - - public = ask_user( - "Would you like to make this deck public?

" - 'If you chose "No" it will be private and only people with a link ' - "will be able to see it on the AnkiHub website." - ) - if public is None: - return - - private = public is False - - add_subdeck_tags = False - if aqt.mw.col.decks.children(aqt.mw.col.decks.id_for_name(deck_name)): - add_subdeck_tags = ask_user( - "Would you like to add a tag to each note in the deck that indicates which subdeck it belongs to?

" - "For example, if you have a deck named My Deck with a subdeck named My Deck::Subdeck, " - "each note in My Deck::Subdeck will have a tag " - f"{SUBDECK_TAG}::Subdeck added to it.

" - "This allows subscribers to have the same subdeck structure as you have." - ) - if add_subdeck_tags is None: - return - - confirm = ask_user( - "Uploading the deck to AnkiHub requires modifying notes and note types in " - f"{deck_name} and will require a full sync afterwards. Would you like to " - "continue?", - ) - if not confirm: - return - - should_upload_media = ask_user( - "Do you want to upload media for this deck as well? " - "This will take some extra time but it is required to display images " - "on AnkiHub and this way subscribers will be able to download media files " - "when installing the deck. " - ) - - def on_success(deck_creation_result: DeckCreationResult) -> None: - - # Upload all existing local media for this deck - # (media files that are referenced on Deck's notes) - if should_upload_media: - media_names = get_media_names_from_notes_data( - deck_creation_result.notes_data - ) - media_sync.start_media_upload(media_names, deck_creation_result.ankihub_did) - - # Add the deck to the list of decks the user owns - anki_did = aqt.mw.col.decks.id_for_name(deck_name) - creation_time = datetime.now(tz=timezone.utc) - config.add_deck( - deck_name, - deck_creation_result.ankihub_did, - anki_did, - user_relation=UserDeckRelation.OWNER, - latest_udpate=creation_time, - ) - - # Show a message to the user with a link to the deck on AnkiHub - deck_url = f"{url_view_deck()}{deck_creation_result.ankihub_did}" - showInfo( - "🎉 Deck upload successful!

" - "Link to the deck on AnkiHub:
" - f"{deck_url}" - ) - - def on_failure(exc: Exception): - aqt.mw.progress.finish() - raise exc - - op = QueryOp( - parent=aqt.mw, - op=lambda col: create_ankihub_deck( - deck_name, - private=private, - add_subdeck_tags=add_subdeck_tags, - ), - success=on_success, - ).failure(on_failure) - LOGGER.info("Instantiated QueryOp for creating collaborative deck") - op.with_progress(label="Creating collaborative deck").run_in_background() - - def _create_collaborative_deck_setup(parent: QMenu): q_action = QAction("🛠️ Create Collaborative Deck", parent=parent) - qconnect(q_action.triggered, _create_collaborative_deck_action) + qconnect(q_action.triggered, create_collaborative_deck) parent.addAction(q_action) diff --git a/ankihub/gui/operations/db_check/ah_db_check.py b/ankihub/gui/operations/db_check/ah_db_check.py index 53976298e..91740a7a9 100644 --- a/ankihub/gui/operations/db_check/ah_db_check.py +++ b/ankihub/gui/operations/db_check/ah_db_check.py @@ -7,8 +7,8 @@ from ....db import ankihub_db from ....main.deck_unsubscribtion import uninstall_deck from ....settings import config -from ...decks_dialog import download_and_install_decks from ...exceptions import DeckDownloadAndInstallError, RemoteDeckNotFoundError +from ...operations.deck_installation import download_and_install_decks from ...utils import ask_user diff --git a/ankihub/gui/operations/deck_creation.py b/ankihub/gui/operations/deck_creation.py new file mode 100644 index 000000000..03585e927 --- /dev/null +++ b/ankihub/gui/operations/deck_creation.py @@ -0,0 +1,167 @@ +from datetime import datetime, timezone + +import aqt +from aqt import QCheckBox, QMessageBox +from aqt.operations import QueryOp +from aqt.studydeck import StudyDeck +from aqt.utils import showInfo, tooltip + +from ... import LOGGER +from ...ankihub_client import get_media_names_from_notes_data +from ...ankihub_client.models import UserDeckRelation +from ...main.deck_creation import DeckCreationResult, create_ankihub_deck +from ...main.subdecks import SUBDECK_TAG +from ...settings import config, url_view_deck +from ..media_sync import media_sync +from ..utils import ask_user + + +def create_collaborative_deck() -> None: + """Creates a new collaborative deck and uploads it to AnkiHub. + + Asks the user to confirm, choose a deck to upload and for some additional options, + and then uploads the deck to AnkiHub. + When the upload is complete, shows a message to the user with a link to the deck on AnkiHub. + """ + + confirm = DeckCreationConfirmationDialog().run() + if not confirm: + return + + LOGGER.info("Asking user to choose a deck to upload...") + deck_chooser = StudyDeck( + aqt.mw, + title="AnkiHub", + accept="Upload", + # Removes the "Add" button + buttons=[], + names=lambda: [ + d.name + for d in aqt.mw.col.decks.all_names_and_ids(include_filtered=False) + if "::" not in d.name and d.id != 1 + ], + parent=aqt.mw, + ) + LOGGER.info(f"Closed deck chooser dialog: {deck_chooser}") + LOGGER.info(f"Chosen deck name: {deck_chooser.name}") + deck_name = deck_chooser.name + if not deck_name: + return + + if len(aqt.mw.col.find_cards(f'deck:"{deck_name}"')) == 0: + showInfo("You can't upload an empty deck.") + return + + public = ask_user( + "Would you like to make this deck public?

" + 'If you chose "No" it will be private and only people with a link ' + "will be able to see it on the AnkiHub website." + ) + if public is None: + return + + private = public is False + + add_subdeck_tags = False + if aqt.mw.col.decks.children(aqt.mw.col.decks.id_for_name(deck_name)): + add_subdeck_tags = ask_user( + "Would you like to add a tag to each note in the deck that indicates which subdeck it belongs to?

" + "For example, if you have a deck named My Deck with a subdeck named My Deck::Subdeck, " + "each note in My Deck::Subdeck will have a tag " + f"{SUBDECK_TAG}::Subdeck added to it.

" + "This allows subscribers to have the same subdeck structure as you have." + ) + if add_subdeck_tags is None: + return + + confirm = ask_user( + "Uploading the deck to AnkiHub requires modifying notes and note types in " + f"{deck_name} and will require a full sync afterwards. Would you like to " + "continue?", + ) + if not confirm: + return + + should_upload_media = ask_user( + "Do you want to upload media for this deck as well? " + "This will take some extra time but it is required to display images " + "on AnkiHub and this way subscribers will be able to download media files " + "when installing the deck. " + ) + + def on_success(deck_creation_result: DeckCreationResult) -> None: + + # Upload all existing local media for this deck + # (media files that are referenced on Deck's notes) + if should_upload_media: + media_names = get_media_names_from_notes_data( + deck_creation_result.notes_data + ) + media_sync.start_media_upload(media_names, deck_creation_result.ankihub_did) + + # Add the deck to the list of decks the user owns + anki_did = aqt.mw.col.decks.id_for_name(deck_name) + creation_time = datetime.now(tz=timezone.utc) + config.add_deck( + deck_name, + deck_creation_result.ankihub_did, + anki_did, + user_relation=UserDeckRelation.OWNER, + latest_udpate=creation_time, + ) + + # Show a message to the user with a link to the deck on AnkiHub + deck_url = f"{url_view_deck()}{deck_creation_result.ankihub_did}" + showInfo( + "🎉 Deck upload successful!

" + "Link to the deck on AnkiHub:
" + f"{deck_url}" + ) + + def on_failure(exc: Exception): + aqt.mw.progress.finish() + raise exc + + op = QueryOp( + parent=aqt.mw, + op=lambda col: create_ankihub_deck( + deck_name, + private=private, + add_subdeck_tags=add_subdeck_tags, + ), + success=on_success, + ).failure(on_failure) + LOGGER.info("Instantiated QueryOp for creating collaborative deck") + op.with_progress(label="Creating collaborative deck").run_in_background() + + +class DeckCreationConfirmationDialog(QMessageBox): + def __init__(self): + super().__init__(parent=aqt.mw) + + self.setWindowTitle("Confirm AnkiHub Deck Creation") + self.setIcon(QMessageBox.Icon.Question) + self.setText( + "Are you sure you want to create a new collaborative deck?


" + 'Terms of use: https://www.ankihub.net/terms
' + 'Privacy Policy: https://www.ankihub.net/privacy
', + ) + self.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel # type: ignore + ) + self.confirmation_cb = QCheckBox( + text=" by checking this checkbox you agree to the terms of use", + parent=self, + ) + self.setCheckBox(self.confirmation_cb) + + def run(self) -> bool: + clicked_ok = self.exec() == QMessageBox.StandardButton.Yes + if not clicked_ok: + return False + + if not self.confirmation_cb.isChecked(): + tooltip("You didn't agree to the terms of use.") + return False + + return True diff --git a/tests/addon/test_integration.py b/tests/addon/test_integration.py index c68ec0a21..d0d7e4b09 100644 --- a/tests/addon/test_integration.py +++ b/tests/addon/test_integration.py @@ -115,12 +115,13 @@ setup_config_dialog_manager, ) from ankihub.gui.deck_updater import _AnkiHubDeckUpdater, ah_deck_updater -from ankihub.gui.decks_dialog import SubscribedDecksDialog, download_and_install_decks +from ankihub.gui.decks_dialog import SubscribedDecksDialog from ankihub.gui.editor import _on_suggestion_button_press, _refresh_buttons from ankihub.gui.errors import upload_logs_and_data_in_background from ankihub.gui.media_sync import media_sync from ankihub.gui.menu import menu_state from ankihub.gui.operations import ankihub_sync +from ankihub.gui.operations.deck_installation import download_and_install_decks from ankihub.gui.operations.new_deck_subscriptions import ( check_and_install_new_deck_subscriptions, ) diff --git a/tests/addon/test_unit.py b/tests/addon/test_unit.py index 4f50a0790..c9413c12b 100644 --- a/tests/addon/test_unit.py +++ b/tests/addon/test_unit.py @@ -21,15 +21,14 @@ from pytestqt.qtbot import QtBot # type: ignore from requests import Response # type: ignore -from ankihub.gui import errors -from ankihub.gui.operations.utils import future_with_exception, future_with_result - from ..factories import DeckMediaFactory, NoteInfoFactory from ..fixtures import ( # type: ignore ImportAHNoteType, MockFunction, NewNoteWithNoteType, SetFeatureFlagState, + add_basic_anki_note_to_deck, + create_anki_deck, ) from .test_integration import ImportAHNote @@ -41,7 +40,7 @@ from ankihub.db.db import _AnkiHubDB from ankihub.db.exceptions import IntegrityError from ankihub.feature_flags import _FeatureFlags, feature_flags -from ankihub.gui import suggestion_dialog +from ankihub.gui import errors, suggestion_dialog from ankihub.gui.error_dialog import ErrorDialog from ankihub.gui.errors import ( OUTDATED_CLIENT_ERROR_REASON, @@ -50,6 +49,12 @@ _try_handle_exception, ) from ankihub.gui.menu import AnkiHubLogin +from ankihub.gui.operations import deck_creation +from ankihub.gui.operations.deck_creation import ( + DeckCreationConfirmationDialog, + create_collaborative_deck, +) +from ankihub.gui.operations.utils import future_with_exception, future_with_result from ankihub.gui.suggestion_dialog import ( SourceType, SuggestionDialog, @@ -62,7 +67,10 @@ ) from ankihub.gui.threading_utils import rate_limited from ankihub.main import suggestions -from ankihub.main.deck_creation import _note_type_name_without_ankihub_modifications +from ankihub.main.deck_creation import ( + DeckCreationResult, + _note_type_name_without_ankihub_modifications, +) from ankihub.main.exporting import _prepared_field_html from ankihub.main.importing import _updated_tags from ankihub.main.note_conversion import ( @@ -1493,3 +1501,76 @@ def test_combined( nids = [nid_1, nid_2, nid_3] assert retain_nids_with_ah_note_type(nids) == [nid_1, nid_2] + + +@pytest.mark.parametrize( + "creating_deck_fails", + [True, False], +) +class TestCreateCollaborativeDeck: + @pytest.mark.qt_no_exception_capture + def test_basic( + self, + anki_session_with_addon_data: AnkiSession, + mock_function: MockFunction, + next_deterministic_uuid: Callable[[], uuid.UUID], + qtbot: QtBot, + creating_deck_fails: bool, + ) -> None: + with anki_session_with_addon_data.profile_loaded(): + # Setup Anki deck with a note. + deck_name = "test" + anki_did = create_anki_deck(deck_name=deck_name) + add_basic_anki_note_to_deck(anki_did) + + # Mock all the UI interactions. + mock_function(DeckCreationConfirmationDialog, "run", return_value=True) + + study_deck_mock = Mock + study_deck_mock.name = deck_name + mock_function(deck_creation, "StudyDeck", return_value=study_deck_mock) + + mock_function(deck_creation, "ask_user", return_value=True) + + def raise_exception(*args, **kwargs) -> None: + raise Exception("test") + + ah_did = next_deterministic_uuid() + notes_data = [NoteInfoFactory.create()] + create_ankihub_deck_mock = mock_function( + deck_creation, + "create_ankihub_deck", + return_value=DeckCreationResult( + ankihub_did=ah_did, + notes_data=notes_data, + ), + side_effect=raise_exception if creating_deck_fails else None, + ) + + get_media_names_from_notes_data_mock = mock_function( + deck_creation, + "get_media_names_from_notes_data", + return_value=[], + ) + start_media_upload_mock = mock_function( + deck_creation.media_sync, "start_media_upload" + ) + showInfo_mock = mock_function(deck_creation, "showInfo") + + # Create the collaborative deck. + if creating_deck_fails: + create_collaborative_deck() + qtbot.wait(500) + showInfo_mock.assert_not_called() + else: + create_collaborative_deck() + + qtbot.wait_until(lambda: showInfo_mock.called) + + # Assert that the correct functions were called. + create_ankihub_deck_mock.assert_called_once_with( + deck_name, private=False, add_subdeck_tags=False + ) + + get_media_names_from_notes_data_mock.assert_called_once_with(notes_data) + start_media_upload_mock.assert_called_once() diff --git a/tests/fixtures.py b/tests/fixtures.py index d524ba8fe..9a52f7062 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -374,3 +374,18 @@ def add_mock(object, func_name: str, return_value: Any = None): return mocks return mock_install_deck_dependencies + + +def create_anki_deck(deck_name: str) -> DeckId: + """Creates an Anki deck with the given name and returns the id.""" + deck = aqt.mw.col.decks.new_deck() + deck.name = deck_name + changes = aqt.mw.col.decks.add_deck(deck) + return DeckId(changes.id) + + +def add_basic_anki_note_to_deck(anki_did: DeckId) -> None: + """Adds a basic Anki note to the given deck.""" + note = aqt.mw.col.new_note(aqt.mw.col.models.by_name("Basic")) + note["Front"] = "some text" + aqt.mw.col.add_note(note, anki_did)