Skip to content

Commit 82f7cd0

Browse files
cacozecursoragent
andauthored
[NRT-897] Keep personally protected fields when resetting local changes (#1374)
* [NRT-897] Keep personally protected fields when resetting local changes Reset was stripping AnkiHub_Protect tags, so auto-protected edits were discarded. CS confirmed Reset should leave protected fields alone, matching sync. Co-authored-by: Cursor <cursoragent@cursor.com> * [NRT-897] Preserve protect tags on Reset by passing strip_personal_protect_tags=False Keep the strip flag so the old Reset behavior can be restored later without rewriting the reset path. Only the browser call sites change. Co-authored-by: Cursor <cursoragent@cursor.com> * [NRT-897] Pin browser Reset to strip_personal_protect_tags=False The previous tests still exercised the old True path, and nothing locked the two browser call sites this ticket actually changed. Co-authored-by: Cursor <cursoragent@cursor.com> * [NRT-897] Restore True-path coverage and split Reset protect-field cases Keep a test that still calls strip_personal_protect_tags=True, isolate globally protected Back from a personal tag, and cover AnkiHub_Protect::All. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bdc3e69 commit 82f7cd0

2 files changed

Lines changed: 141 additions & 20 deletions

File tree

ankihub/gui/browser/browser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ def on_done(future: Future) -> None:
510510
tooltip("Reset local changes for selected notes.", parent=browser)
511511

512512
aqt.mw.taskman.with_progress(
513-
task=lambda: reset_local_changes_to_notes(filtered_nids, ah_did=ankihub_did, strip_personal_protect_tags=True),
513+
task=lambda: reset_local_changes_to_notes(filtered_nids, ah_did=ankihub_did, strip_personal_protect_tags=False),
514514
on_done=on_done,
515515
label="Resetting local changes...",
516516
parent=browser,
@@ -597,7 +597,7 @@ def on_done(future: Future) -> None:
597597
tooltip(f"Reset local changes to deck <b>{deck_config.name}</b>")
598598

599599
aqt.mw.taskman.with_progress(
600-
lambda: reset_local_changes_to_notes(nids, ah_did=ah_did, strip_personal_protect_tags=True),
600+
lambda: reset_local_changes_to_notes(nids, ah_did=ah_did, strip_personal_protect_tags=False),
601601
on_done=on_done,
602602
label="Resetting local changes...",
603603
parent=browser,

tests/addon/test_integration.py

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@
150150
SuggestionTypeSearchNode,
151151
UpdatedInTheLastXDaysSearchNode,
152152
_on_protect_fields_action,
153+
_on_reset_deck_action,
154+
_on_reset_local_changes_action,
153155
_on_reset_optional_tags_action,
154156
)
155157
from ankihub.gui.browser.custom_search_nodes import (
@@ -6727,6 +6729,7 @@ def test_reset_local_changes_to_notes(
67276729
mock_client_get_note_type: MockClientGetNoteType,
67286730
mocker: MockerFixture,
67296731
):
6732+
"""User-initiated Reset (strip=False): personal tags and globally protected fields survive."""
67306733
with anki_session_with_addon_data.profile_loaded():
67316734
mw = anki_session_with_addon_data.mw
67326735

@@ -6736,13 +6739,12 @@ def test_reset_local_changes_to_notes(
67366739
basic_note_1 = mw.col.get_note(NoteId(1608240029527))
67376740
basic_note_2 = mw.col.get_note(NoteId(1608240057545))
67386741

6739-
# Edit Front + Back, tag both as personally-protected (mirrors the auto-protect
6740-
# hook's behaviour on real edits), and move the note to a different deck. Back is
6741-
# also marked as globally protected for this note type — its protect tag should
6742-
# survive the reset (user-authored intent that should outlast global protection).
6742+
# Edit Front + Back and move the note. Front is personally protected (auto-protect).
6743+
# Back is only globally protected — no personal tag — so a surviving edit proves
6744+
# the importer honours protected_fields, not AnkiHub_Protect::Back.
67436745
basic_note_1["Front"] = "changed front"
67446746
basic_note_1["Back"] = "changed back"
6745-
basic_note_1.tags = ["AnkiHub_Protect::Front", "AnkiHub_Protect::Back"]
6747+
basic_note_1.tags = [f"{TAG_FOR_PROTECTING_FIELDS}::Front"]
67466748
basic_note_1.flush()
67476749
mw.col.set_deck(basic_note_1.card_ids(), 1)
67486750

@@ -6754,19 +6756,17 @@ def test_reset_local_changes_to_notes(
67546756
mocker.patch.object(AnkiHubClient, "get_protected_tags")
67556757
mock_client_get_note_type([note_type for note_type in mw.col.models.all()])
67566758

6757-
# reset local changes
67586759
nids = ankihub_db.anki_nids_for_ankihub_deck(ah_did)
6759-
reset_local_changes_to_notes(nids=nids, ah_did=ah_did, strip_personal_protect_tags=True)
6760+
reset_local_changes_to_notes(nids=nids, ah_did=ah_did, strip_personal_protect_tags=False)
67606761

6761-
# Front: not globally protected → personal-protect tag stripped, field reset.
6762-
# Back: globally protected → field stays edited (importer respects protected_fields)
6763-
# and the personal-protect tag survives.
6762+
# Front: personally protected → edit and protect tag survive (NRT-897).
6763+
# Back: globally protected, no personal tag → field stays edited.
67646764
# Note is still in the deck it was moved to (Reset shouldn't move cards between decks).
67656765
basic_note_1.load()
6766-
assert basic_note_1["Front"] == "This is the front 1"
6766+
assert basic_note_1["Front"] == "changed front"
67676767
assert basic_note_1["Back"] == "changed back"
6768-
assert "AnkiHub_Protect::Front" not in basic_note_1.tags
6769-
assert "AnkiHub_Protect::Back" in basic_note_1.tags
6768+
assert f"{TAG_FOR_PROTECTING_FIELDS}::Front" in basic_note_1.tags
6769+
assert f"{TAG_FOR_PROTECTING_FIELDS}::Back" not in basic_note_1.tags
67706770
assert basic_note_1.cards()
67716771
for card in basic_note_1.cards():
67726772
assert card.did == 1
@@ -6779,15 +6779,45 @@ def test_reset_local_changes_to_notes(
67796779
assert mw.col.decks.name(card.did) == "Testdeck"
67806780

67816781

6782-
def test_reset_local_changes_to_notes_without_stripping_personal_protect_tags(
6782+
def test_reset_local_changes_to_notes_strips_personal_protect_tags(
6783+
anki_session_with_addon_data: AnkiSession,
6784+
install_sample_ah_deck: InstallSampleAHDeck,
6785+
mock_client_get_note_type: MockClientGetNoteType,
6786+
mocker: MockerFixture,
6787+
):
6788+
"""True still strips personal protect tags except those matching globally protected fields."""
6789+
with anki_session_with_addon_data.profile_loaded():
6790+
mw = anki_session_with_addon_data.mw
6791+
6792+
_, ah_did = install_sample_ah_deck()
6793+
6794+
basic_note_1 = mw.col.get_note(NoteId(1608240029527))
6795+
basic_note_1["Front"] = "changed front"
6796+
basic_note_1["Back"] = "changed back"
6797+
basic_note_1.tags = [f"{TAG_FOR_PROTECTING_FIELDS}::Front", f"{TAG_FOR_PROTECTING_FIELDS}::Back"]
6798+
basic_note_1.flush()
6799+
6800+
mocker.patch.object(AnkiHubClient, "get_protected_fields", return_value={basic_note_1.mid: ["Back"]})
6801+
mocker.patch.object(AnkiHubClient, "get_protected_tags")
6802+
mock_client_get_note_type([note_type for note_type in mw.col.models.all()])
6803+
6804+
reset_local_changes_to_notes(nids=[basic_note_1.id], ah_did=ah_did, strip_personal_protect_tags=True)
6805+
6806+
basic_note_1.load()
6807+
assert basic_note_1["Front"] == "This is the front 1"
6808+
assert basic_note_1["Back"] == "changed back"
6809+
assert f"{TAG_FOR_PROTECTING_FIELDS}::Front" not in basic_note_1.tags
6810+
assert f"{TAG_FOR_PROTECTING_FIELDS}::Back" in basic_note_1.tags
6811+
6812+
6813+
def test_reset_local_changes_to_notes_keeps_personally_protected_fields(
67836814
anki_session_with_addon_data: AnkiSession,
67846815
install_ah_deck: InstallAHDeck,
67856816
import_ah_note: ImportAHNote,
67866817
mock_client_get_note_type: MockClientGetNoteType,
67876818
mocker: MockerFixture,
67886819
):
6789-
"""The database check resets decks to repair add-on data, not because the user asked to
6790-
discard edits, so it must leave personally protected content alone."""
6820+
"""Unprotected fields reset; personally protected fields are left alone even if not globally protected."""
67916821
with anki_session_with_addon_data.profile_loaded():
67926822
ah_did = install_ah_deck()
67936823
note_info = import_ah_note(ah_did=ah_did)
@@ -6804,14 +6834,72 @@ def test_reset_local_changes_to_notes_without_stripping_personal_protect_tags(
68046834

68056835
reset_local_changes_to_notes(nids=[note.id], ah_did=ah_did, strip_personal_protect_tags=False)
68066836

6807-
# Back is personally protected: content and tag both survive, even though the field
6808-
# is not globally protected. Front is unprotected and gets reset as usual.
68096837
note.load()
68106838
assert note["Back"] == "personal content"
68116839
assert f"{TAG_FOR_PROTECTING_FIELDS}::Back" in note.tags
68126840
assert note["Front"] == note_info.fields[0].value
68136841

68146842

6843+
def test_reset_local_changes_to_notes_keeps_fields_when_protect_all_tag(
6844+
anki_session_with_addon_data: AnkiSession,
6845+
install_ah_deck: InstallAHDeck,
6846+
import_ah_note: ImportAHNote,
6847+
mock_client_get_note_type: MockClientGetNoteType,
6848+
mocker: MockerFixture,
6849+
):
6850+
"""AnkiHub_Protect::All (Protect Fields → all) blocks Reset of every field when strip=False."""
6851+
with anki_session_with_addon_data.profile_loaded():
6852+
ah_did = install_ah_deck()
6853+
note_info = import_ah_note(ah_did=ah_did)
6854+
6855+
note = aqt.mw.col.get_note(ankihub_db.anki_nid_for_ankihub_nid(note_info.ah_nid))
6856+
note["Front"] = "changed front"
6857+
note["Back"] = "changed back"
6858+
note.tags = [TAG_FOR_PROTECTING_ALL_FIELDS]
6859+
aqt.mw.col.update_note(note)
6860+
6861+
mocker.patch.object(AnkiHubClient, "get_protected_fields", return_value={})
6862+
mocker.patch.object(AnkiHubClient, "get_protected_tags", return_value=[])
6863+
mock_client_get_note_type([note_type for note_type in aqt.mw.col.models.all()])
6864+
6865+
reset_local_changes_to_notes(nids=[note.id], ah_did=ah_did, strip_personal_protect_tags=False)
6866+
6867+
note.load()
6868+
assert note["Front"] == "changed front"
6869+
assert note["Back"] == "changed back"
6870+
assert TAG_FOR_PROTECTING_ALL_FIELDS in note.tags
6871+
6872+
6873+
def test_reset_local_changes_to_notes_strips_protect_all_tag(
6874+
anki_session_with_addon_data: AnkiSession,
6875+
install_ah_deck: InstallAHDeck,
6876+
import_ah_note: ImportAHNote,
6877+
mock_client_get_note_type: MockClientGetNoteType,
6878+
mocker: MockerFixture,
6879+
):
6880+
"""True strips AnkiHub_Protect::All; it is never treated as a globally protected field tag."""
6881+
with anki_session_with_addon_data.profile_loaded():
6882+
ah_did = install_ah_deck()
6883+
note_info = import_ah_note(ah_did=ah_did)
6884+
6885+
note = aqt.mw.col.get_note(ankihub_db.anki_nid_for_ankihub_nid(note_info.ah_nid))
6886+
note["Front"] = "changed front"
6887+
note["Back"] = "changed back"
6888+
note.tags = [TAG_FOR_PROTECTING_ALL_FIELDS]
6889+
aqt.mw.col.update_note(note)
6890+
6891+
mocker.patch.object(AnkiHubClient, "get_protected_fields", return_value={})
6892+
mocker.patch.object(AnkiHubClient, "get_protected_tags", return_value=[])
6893+
mock_client_get_note_type([note_type for note_type in aqt.mw.col.models.all()])
6894+
6895+
reset_local_changes_to_notes(nids=[note.id], ah_did=ah_did, strip_personal_protect_tags=True)
6896+
6897+
note.load()
6898+
assert note["Front"] == note_info.fields[0].value
6899+
assert note["Back"] == note_info.fields[1].value
6900+
assert TAG_FOR_PROTECTING_ALL_FIELDS not in note.tags
6901+
6902+
68156903
def test_db_check_resets_decks_without_stripping_personal_protect_tags(
68166904
anki_session_with_addon_data: AnkiSession,
68176905
install_ah_deck: InstallAHDeck,
@@ -6829,6 +6917,39 @@ def test_db_check_resets_decks_without_stripping_personal_protect_tags(
68296917
assert reset_mock.call_args.kwargs["strip_personal_protect_tags"] is False
68306918

68316919

6920+
def _run_taskman_with_progress(*args, **kwargs):
6921+
task = kwargs.get("task") or args[0]
6922+
kwargs["on_done"](future_with_result(task()))
6923+
6924+
6925+
def test_browser_reset_does_not_strip_personal_protect_tags(
6926+
anki_session_with_addon_data: AnkiSession,
6927+
install_ah_deck: InstallAHDeck,
6928+
import_ah_note: ImportAHNote,
6929+
mocker: MockerFixture,
6930+
):
6931+
"""Pins NRT-897: both browser Reset entry points pass strip_personal_protect_tags=False."""
6932+
with anki_session_with_addon_data.profile_loaded():
6933+
ah_did = install_ah_deck()
6934+
note_info = import_ah_note(ah_did=ah_did)
6935+
nid = ankihub_db.anki_nid_for_ankihub_nid(note_info.ah_nid)
6936+
6937+
reset_mock = mocker.patch("ankihub.gui.browser.browser.reset_local_changes_to_notes")
6938+
mocker.patch.object(aqt.mw.taskman, "with_progress", side_effect=_run_taskman_with_progress)
6939+
mocker.patch("ankihub.gui.browser.browser.tooltip")
6940+
mocker.patch("ankihub.gui.browser.browser.choose_ankihub_deck", return_value=ah_did)
6941+
mocker.patch("ankihub.gui.browser.browser.ask_user", return_value=True)
6942+
mocker.patch.object(aqt.mw, "reset")
6943+
6944+
browser = mocker.Mock()
6945+
_on_reset_local_changes_action(browser, [nid])
6946+
assert reset_mock.call_args.kwargs["strip_personal_protect_tags"] is False
6947+
6948+
reset_mock.reset_mock()
6949+
_on_reset_deck_action(browser)
6950+
assert reset_mock.call_args.kwargs["strip_personal_protect_tags"] is False
6951+
6952+
68326953
def test_migrate_profile_data_from_old_location(
68336954
anki_session_with_addon_before_profile_support: AnkiSession,
68346955
mocker: MockerFixture,

0 commit comments

Comments
 (0)