Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions ankihub/gui/block_exam_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
add_notes_to_block_exam_subdeck,
create_block_exam_subdeck,
)
from ..main.subdecks import is_tag_based_subdeck
from ..settings import BlockExamSubdeckConfigOrigin, config
from .utils import clear_layout

Expand Down Expand Up @@ -368,19 +369,19 @@ def _populate_subdeck_list(self):
"""Populate the subdeck list widget."""
self.subdeck_list.clear()

# Get ALL subdecks under the root deck (including nested ones)
all_subdecks = []

for deck_name, deck_id in aqt.mw.col.decks.children(self.root_deck_id):
subdeck_path = deck_name[len(self.root_deck["name"]) + 2 :]
all_subdecks.append((subdeck_path, deck_id))
# Get ALL subdecks under the root deck (including nested ones), excluding tag-based subdecks
all_subdecks = [
(deck_name, deck_id)
for deck_name, deck_id in aqt.mw.col.decks.children(self.root_deck_id)
if not is_tag_based_subdeck(deck_id) # Skip tag-based subdecks
]

# Sort subdecks alphabetically by name
all_subdecks.sort(key=lambda x: x[0].lower())

# Add all subdecks to the list
for subdeck_name, subdeck_id in all_subdecks:
item = QListWidgetItem(subdeck_name)
item = QListWidgetItem(subdeck_name[len(self.root_deck["name"]) + 2 :])
item.setData(Qt.ItemDataRole.UserRole, subdeck_id)

# Mark block exam subdecks differently (optional visual indication)
Expand Down
39 changes: 24 additions & 15 deletions ankihub/gui/deckbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from aqt import QMenu, gui_hooks, qconnect
from aqt.qt import QDialog, QDialogButtonBox, QFont

from ..main.block_exam_subdecks import get_subdeck_name_without_parent, move_subdeck_to_main_deck
from ..main.block_exam_subdecks import dissolve_block_exam_subdeck, get_subdeck_name_without_parent
from ..main.deck_unsubscribtion import unsubscribe_from_deck_and_uninstall
from ..main.subdecks import is_tag_based_subdeck
from ..settings import config
from .operations.user_details import check_user_feature_access
from .subdeck_due_date_dialog import DatePickerDialog
Expand Down Expand Up @@ -70,7 +71,7 @@ def on_button_clicked(button_index: int) -> None:
if button_index != 1:
return

note_count = move_subdeck_to_main_deck(subdeck_id)
note_count = dissolve_block_exam_subdeck(subdeck_id)
aqt.mw.deckBrowser.refresh()

show_tooltip(f"{note_count} notes merged into the main deck", parent=aqt.mw)
Expand Down Expand Up @@ -98,23 +99,30 @@ def on_button_clicked(button_index: int) -> None:
)


def _setup_update_subdeck_due_date(menu: QMenu, subdeck_did: DeckId) -> None:
def _setup_update_subdeck_due_date(menu: QMenu, subdeck_did: DeckId, is_tag_based: bool) -> None:
action = menu.addAction("Set due date")

initial_due_date = config.get_block_exam_subdeck_due_date(subdeck_did)

action.setToolTip("Set the due date of this subdeck.")
qconnect(
action.triggered,
lambda: _open_date_picker_dialog_for_subdeck(subdeck_did, initial_due_date),
)
if is_tag_based:
action.setEnabled(False)
action.setToolTip("This subdeck is tag-based and cannot be managed as a block exam subdeck.")
else:
initial_due_date = config.get_block_exam_subdeck_due_date(subdeck_did)
action.setToolTip("Set the due date of this subdeck.")
qconnect(
action.triggered,
lambda: _open_date_picker_dialog_for_subdeck(subdeck_did, initial_due_date),
)


def _setup_remove_block_exam_subdeck(menu: QMenu, subdeck_did: DeckId) -> None:
def _setup_remove_block_exam_subdeck(menu: QMenu, subdeck_did: DeckId, is_tag_based: bool) -> None:
action = menu.addAction("Merge into parent deck")

action.setToolTip("Deletes the subdeck and moves all notes back into the main deck.")
qconnect(action.triggered, lambda: _open_remove_block_exam_subdeck_dialog(subdeck_did))
if is_tag_based:
action.setEnabled(False)
action.setToolTip("This subdeck is tag-based and cannot be managed as a block exam subdeck.")
else:
action.setToolTip("Deletes the subdeck and moves all notes back into the main deck.")
qconnect(action.triggered, lambda: _open_remove_block_exam_subdeck_dialog(subdeck_did))


def _initialize_subdeck_context_menu_actions(menu: QMenu, deck_id: int) -> None:
Expand All @@ -137,8 +145,9 @@ def on_access_granted(_: dict) -> None:
font.setPointSize(10)
label_action.setFont(font)

_setup_update_subdeck_due_date(menu, did)
_setup_remove_block_exam_subdeck(menu, did)
is_tag_based = is_tag_based_subdeck(did)
_setup_update_subdeck_due_date(menu, did, is_tag_based)
_setup_remove_block_exam_subdeck(menu, did, is_tag_based)

check_user_feature_access(
feature_key="has_flashcard_selector_access",
Expand Down
4 changes: 2 additions & 2 deletions ankihub/gui/subdeck_due_date_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from .. import LOGGER
from ..main.block_exam_subdecks import (
check_block_exam_subdeck_due_dates,
dissolve_block_exam_subdeck,
get_subdeck_name_without_parent,
move_subdeck_to_main_deck,
set_subdeck_due_date,
)
from ..settings import BlockExamSubdeckConfig, BlockExamSubdeckConfigOrigin, config
Expand Down Expand Up @@ -113,7 +113,7 @@ def _setup_ui(self):

def _on_move_to_main_deck(self):
"""Handle moving subdeck to main deck."""
note_count = move_subdeck_to_main_deck(self.subdeck_config.subdeck_id)
note_count = dissolve_block_exam_subdeck(self.subdeck_config.subdeck_id)
tooltip(f"{note_count} notes merged into the parent deck", parent=aqt.mw)
self.accept()
aqt.mw.deckBrowser.refresh()
Expand Down
23 changes: 18 additions & 5 deletions ankihub/main/block_exam_subdecks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@

from .. import LOGGER
from ..settings import BlockExamSubdeckConfig, BlockExamSubdeckConfigOrigin, config
from .utils import move_notes_to_decks_while_respecting_odid, note_ids_in_deck_hierarchy
from .utils import (
move_notes_to_decks_while_respecting_odid,
note_ids_in_deck_hierarchy,
)


def get_root_deck_id_from_subdeck(subdeck_id: DeckId) -> DeckId:
Expand Down Expand Up @@ -189,15 +192,17 @@ def check_block_exam_subdeck_due_dates() -> List[BlockExamSubdeckConfig]:
return expired_subdecks


def move_subdeck_to_main_deck(subdeck_id: DeckId) -> int:
"""Move all notes from a subdeck back to the root deck, delete the subdeck,
and remove its configuration (if it exists).
def dissolve_block_exam_subdeck(subdeck_id: DeckId) -> int:
"""Remove a block exam subdeck and move its notes back to the root deck.

If the root deck is an AnkiHub deck with subdecks enabled, notes will be
redistributed to tag-based subdecks after being moved to the root deck.

Args:
subdeck_id: The Anki subdeck ID

Returns:
The number of notes moved to the root deck
The number of notes moved from the subdeck

Raises:
ValueError: If the provided deck is a root deck (not a subdeck)
Expand All @@ -224,6 +229,14 @@ def move_subdeck_to_main_deck(subdeck_id: DeckId) -> int:

aqt.mw.col.decks.remove([subdeck_id])

# If the root deck is managed by AnkiHub and has subdecks enabled, move notes to subdecks
# based on subdeck tags
ah_did = config.get_deck_uuid_by_did(root_deck_id)
if ah_did and config.deck_config(ah_did).subdecks_enabled and note_ids:
from .subdecks import build_subdecks_and_move_cards_to_them

build_subdecks_and_move_cards_to_them(ah_did, note_ids)

LOGGER.info("Successfully moved subdeck to root deck", subdeck_name=subdeck["name"])

if subdeck_config:
Expand Down
43 changes: 42 additions & 1 deletion ankihub/main/subdecks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
from ..db.models import AnkiHubNote
from ..settings import config
from .block_exam_subdecks import get_exam_subdecks
from .utils import move_notes_to_decks_while_respecting_odid, nids_in_deck_but_not_in_subdeck, note_ids_in_decks
from .utils import (
move_notes_to_decks_while_respecting_odid,
nids_in_deck_but_not_in_subdeck,
note_ids_in_decks,
)

# root tag for tags that indicate which subdeck a note belongs to
SUBDECK_TAG = "AnkiHub_Subdeck"
Expand Down Expand Up @@ -289,3 +293,40 @@ def _subdeck_name_to_tag(deck_name: str) -> str:
result = re.sub("_+", "_", result)

return result


def is_tag_based_subdeck(subdeck_id: DeckId) -> bool:
"""Check if a subdeck is tag-based.

A subdeck is considered tag-based if there exists at least one note anywhere
in the collection with a subdeck tag matching the subdeck's path.

Args:
subdeck_id: The subdeck ID to check

Returns:
True if the subdeck is tag-based, False otherwise.
"""
# TODO We need to restrict the notes to those belonging to the AnkiHub deck somehow
# or restrict the subdeck tags.
# Otherwise there could be false positives if there are multiple AnkiHub decks
# with subdecks and notes from one deck have subdeck tags matching subdecks
# of another deck.
# We can probably use note_ids_in_deck_hierarchy
# It's probably better to use tag-information from the ankihub_db instead
# Or both?

# Get subdeck path relative to root
full_subdeck_name = aqt.mw.col.decks.name(subdeck_id)

# Check if this is a subdeck (has a parent)
if "::" not in full_subdeck_name:
return False

subdeck_path = full_subdeck_name.split("::", maxsplit=1)[1]

# Check if any notes have subdeck tags matching this path
tag_pattern = f"^{SUBDECK_TAG}::[^:]+::{re.escape(subdeck_path)}$"
matching_nids = aqt.mw.col.find_notes(f'tag:"re:{tag_pattern}"')

return bool(matching_nids)
26 changes: 13 additions & 13 deletions tests/addon/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
add_notes_to_block_exam_subdeck,
check_block_exam_subdeck_due_dates,
create_block_exam_subdeck,
move_subdeck_to_main_deck,
dissolve_block_exam_subdeck,
set_subdeck_due_date,
)

Expand Down Expand Up @@ -8994,13 +8994,13 @@ def test_set_due_date_on_regular_subdeck(
saved_due_date = subdeck_config.due_date if subdeck_config else None
assert saved_due_date == due_date

def test_move_subdeck_to_main_deck_with_nested_subdecks(
def test_dissolve_block_exam_subdeck_with_nested_subdecks(
self,
anki_session_with_addon_data: AnkiSession,
install_ah_deck: InstallAHDeck,
add_anki_note: AddAnkiNote,
):
"""Test moving a subdeck with nested children moves all notes to main deck."""
"""Test dissolving a subdeck with nested children moves all notes to main deck."""
with anki_session_with_addon_data.profile_loaded():
ah_did = install_ah_deck()
deck_config = config.deck_config(ah_did)
Expand Down Expand Up @@ -9029,8 +9029,8 @@ def test_move_subdeck_to_main_deck_with_nested_subdecks(
assert note_subdeck.cards()[0].did == subdeck_id
assert note_nested.cards()[0].did == nested_subdeck_id

# Move subdeck to main deck
result = move_subdeck_to_main_deck(subdeck_id)
# Dissolve subdeck
result = dissolve_block_exam_subdeck(subdeck_id)

# Verify return value - should be 2 notes (subdeck + nested)
assert result == 2
Expand All @@ -9052,12 +9052,12 @@ def test_move_subdeck_to_main_deck_with_nested_subdecks(
subdeck_config = config.get_block_exam_subdeck_config(subdeck_id)
assert subdeck_config is None

def test_move_subdeck_to_main_deck_without_config(
def test_dissolve_block_exam_subdeck_without_config(
self,
anki_session_with_addon_data: AnkiSession,
add_anki_note: AddAnkiNote,
):
"""Test removing a regular subdeck that doesn't have a BlockExamSubdeckConfig."""
"""Test dissolving a regular subdeck that doesn't have a BlockExamSubdeckConfig."""
with anki_session_with_addon_data.profile_loaded():
main_deck_id = create_anki_deck("Test Deck")
main_deck_name = aqt.mw.col.decks.name_if_exists(main_deck_id)
Expand All @@ -9073,8 +9073,8 @@ def test_move_subdeck_to_main_deck_without_config(
subdeck_config = config.get_block_exam_subdeck_config(subdeck_id)
assert subdeck_config is None

# Move subdeck to main deck (should work without config)
result = move_subdeck_to_main_deck(subdeck_id)
# Dissolve subdeck (should work without config)
result = dissolve_block_exam_subdeck(subdeck_id)

# Verify return value - should be 1 note
assert result == 1
Expand All @@ -9086,17 +9086,17 @@ def test_move_subdeck_to_main_deck_without_config(
# Verify subdeck was deleted
assert aqt.mw.col.decks.id_for_name(subdeck_name) is None

def test_move_subdeck_to_main_deck_with_root_deck(
def test_dissolve_block_exam_subdeck_with_root_deck(
self,
anki_session_with_addon_data: AnkiSession,
):
"""Test calling move_subdeck_to_main_deck on a root deck only removes config."""
"""Test calling dissolve_block_exam_subdeck on a root deck raises ValueError."""
with anki_session_with_addon_data.profile_loaded():
root_deck_id = create_anki_deck("Test Deck")

with pytest.raises(ValueError, match="The provided deck isn't a subdeck."):
# Call move_subdeck_to_main_deck on the root deck
move_subdeck_to_main_deck(root_deck_id)
# Call dissolve_block_exam_subdeck on the root deck
dissolve_block_exam_subdeck(root_deck_id)


class TestBlockExamSubdeckDialog:
Expand Down
Loading
Loading