Skip to content

[NRT-751] Disable the editor Suggest button when there's nothing to suggest#1297

Draft
RisingOrange wants to merge 20 commits into
NRT-749from
NRT-751
Draft

[NRT-751] Disable the editor Suggest button when there's nothing to suggest#1297
RisingOrange wants to merge 20 commits into
NRT-749from
NRT-751

Conversation

@RisingOrange
Copy link
Copy Markdown
Collaborator

@RisingOrange RisingOrange commented May 26, 2026

Related issues

Proposed changes

When a user is in the editor for an AnkiHub note that has nothing to suggest (no field diff vs the AnkiHub-stored note, no tag change), the toolbar's "Suggest a change" button is now disabled with the tooltip "No changes to suggest". Previously the button was always enabled and submitting opened a dialog that couldn't produce a suggestion. Implements the NRT-508 spec line: "If the user didn't edit any field the 'Suggest a change' button should be disable[d]."

Gating lives in ankihub/gui/editor.py, behind the auto_protect_fields_when_edited feature flag (matching NRT-749's dark-merge strategy):

  • Visual gate — in _refresh_buttons, both the change-note (not-deleted) and new-note branches disable SUGGESTION_BTN_ID and set a tooltip explaining why when the note has nothing to suggest: "No changes to suggest", or "The first field can't be empty" (the latter is a new note's only possible gate reason). View / View-history buttons are managed per branch as before.
  • Action gate — in _on_suggestion_button_press, an early-return covers the keyboard-shortcut path (the shortcut fires even when the button is visually disabled): a no-op for non-AnkiHub note types (closing a pre-existing AssertionError from the dialog opener), the gate tooltip when nothing's suggestible (including empty/globally-protected-first-field new notes, so they don't fall through to add_current_note), and the deleted-note tooltip for notes deleted on AnkiHub.
  • Live refresh — registered on editor_did_fire_typing_timer (field edits) and editor_did_update_tags (tag edits) so the button tracks edits within the current note instead of only updating on note reload.

"Suggestible" is computed via any_suggestible_from_diffs — the same check the bulk-suggest dialog uses — so the button and the dialog agree: a field edit outside the globally-protected denylist, or a tag change (and, for a new note, a non-empty first field). Locally/personally-protected fields (AnkiHub_Protect::Field) still count: the protect tag only stops sync from overwriting the local edit; the user can still suggest it. Only globally-protected (deck-owner-managed) fields are excluded.

The gate predicate returns the tooltip reason (or None when suggestible) rather than a bare bool, so each block reason shows the right message. For a new note (no AnkiHub-DB row) the deck is resolved from its note type, so a globally-protected first field gates the button just as it would block the dialog. The button's enabled-state tooltip was also rephrased from "Send your request to AnkiHub" to "Suggest a change to AnkiHub" (or "Suggest the note to AnkiHub" for new notes) — "request" was the odd word out vs the feature's "Suggest" vocabulary. The button label/tooltip JS setters now encode their value with json.dumps — a latent bug surfaced by the apostrophe in the new tooltip (values were interpolated into single-quoted JS literals with no escaping).

Editor-registry, and the removed note-type handler

The live-refresh hooks pass only a Note, not the Editor, so this PR adds a _tracked_editors registry (populated on editor_did_init, pruned lazily, matched by object identity) used by the field-typing and tag-update handlers.

While here, the pre-existing editor module global and its add_cards_did_change_note_type handler were removed: a note-type change in AddCards goes through editor.loadNote, which already fires editor_did_load_note -> _refresh_buttons on the affected editor (verified in aqt at 2.1.50 and 25.x). The explicit handler was redundant and actually fired earlier — synchronously after loadNote() returns, before the webview callback — racing the DOM.

Note: delete-from-editor for unchanged notes

The gate treats change_type as non-DELETE: an unchanged existing AnkiHub note shows "No changes to suggest" and the button is disabled, even though the dialog's change-type dropdown would let the user pick DELETE (suggest_note_update exempts DELETE from its empty/no-changes checks). This matches the NRT-508/751 spec wording — "if the user didn't edit any field the button should be disabled" — and removes one minor pre-existing path: deleting unchanged notes via the editor's Suggest button. The browser's "Suggest to delete" menu remains the supported path for that flow.

How to reproduce

With the auto_protect_fields_when_edited flag on:

  1. Open an AnkiHub note in the editor with no local edits → Suggest button is disabled, tooltip "No changes to suggest".
  2. Edit a field → button enables live, without reloading the note. Revert it → disables again.
  3. Edit only a globally-protected field → stays disabled. A locally-protected field edit keeps it enabled.
  4. Add or remove a tag → button updates live.
  5. Add a new note of an AnkiHub note type with an empty first field → button is disabled; type into the first field → it enables.
  6. With no changes, trigger the suggest hotkey → tooltip fires, dialog does not open. Same for a note deleted on AnkiHub.

With the flag off, the button behaves as before (always enabled for valid notes).

Tests

New tests in TestEditor (tests/addon/test_integration.py), consolidated into three parametrized tests + four standalone ones: the existing-note visual gate (no-change disabled, field edit enabled, globally-protected-only edit excluded, locally-protected edit still enabled, flag-off no-op), the new-note visual gate (empty / filled / globally-protected-first-field), the action-gate blocks (no-changes, deleted, empty-new-note keyboard paths), live refresh on typing-timer + tag-update (existing notes) and on typing into a new note, and a no-op test for the non-AnkiHub hotkey path (logged-in / logged-out). All 26 TestEditor tests pass; mypy + ruff clean.

Disable the editor's "Suggest a change" button (tooltip "No changes to
suggest") when an existing AnkiHub note has nothing to suggest. Reuses
the dialog's compute_note_diffs / any_suggestible_from_diffs so the
button and the dialog's pre-open gate agree on what counts as
suggestible (field edit outside the globally-protected denylist, or a
tag change). Three layers: visual gate in _refresh_buttons, action gate
in _on_suggestion_button_press (covers the keyboard shortcut), and a
typing-timer hook so the state tracks edits live. Behind
AUTO_PROTECT_FEATURE_FLAG.
…dentity

Codex review follow-ups:
- The keyboard-shortcut path now early-returns with the deleted-note
  tooltip for notes deleted on AnkiHub, matching the visual gate (the
  shortcut fires even when the button is disabled).
- _on_editor_typing_timer matches the owning editor by object identity
  instead of note.id, so unsaved Add-Cards notes (all id 0) don't
  refresh every open editor.
…bled

Pins down that a personal AnkiHub_Protect tag does not exclude an edited
field from the gate (only globally-protected fields do) — the user can
still suggest a locally-protected edit.
editor_did_fire_typing_timer only fires for field typing, so adding or
removing a tag never re-ran the gate. Register the same refresh handler
on editor_did_update_tags (same Note-only signature, available since
2.1.20) and rename it _refresh_buttons_for_edited_note to reflect that
it now serves both signals.
The module had two editor-tracking mechanisms: the new `_tracked_editors`
registry, and a pre-existing `editor` global that held only the last
AddCards editor and was read solely by `_on_add_cards_did_change_notetype`
(so a note-type change in an older of two Add windows refreshed the wrong
editor). Drop the global and `_on_add_cards_init`; the note-type-change
handler now refreshes all live tracked editors via the registry. Factored
the prune-and-iterate into `_live_tracked_editors`.
A note-type change in AddCards goes through editor.loadNote, which fires
editor_did_load_note -> _refresh_buttons on the affected editor (verified
in aqt at 2.1.50 and 25.x). The dedicated add_cards_did_change_note_type
handler was therefore redundant — and fired earlier (synchronously after
loadNote() returns, before the webview callback), racing the DOM. Drop it
and its now-unused `editor` global lineage; the live-editor registry now
serves only the field-typing and tag-update hooks. Removes the unused
NoteType import and the brittle note-type-change test (it failed against
both the old and new handler — a webview-timing artifact, not a real
regression; the non-AnkiHub-type disable path stays covered by
test_suggestion_button_is_disabled_for_notes_without_ankihub_note_type).
The gate previously applied only to the change-note branch, so a new note
(AnkiHub note type, not yet on AnkiHub) with an empty first field kept the
button enabled while having nothing to suggest. Extend the gate to the
new-note branch via a shared _apply_suggestion_button_gate helper;
any_suggestible_from_diffs already classifies an empty-first-field new
note as not suggestible. Still behind AUTO_PROTECT_FEATURE_FLAG.
A new note's only gate reason is an empty first field, but it showed the
"No changes to suggest" tooltip, which is wrong for a note that has no
"changes" to begin with. Report it distinctly: _should_block_for_no_changes
becomes _suggestion_gate_tooltip, returning the reason ("The first field
can't be empty" vs "No changes to suggest") or None when suggestible.

The apostrophe in that new string surfaced a latent bug: the button
label/tooltip setters interpolated values into single-quoted JS literals
with no escaping. Encode the value with json.dumps so quotes/apostrophes
can't break the script.
… registry

Codex/review reconciliation:
- _suggestion_gate_tooltip: new notes have no AH-DB row, so resolve the deck
  from the note type (ankihub_did_for_note_type) instead of leaving the
  globally-protected map empty. A new note whose first field is globally
  protected is now gated, matching the dialog.
- Action gate (_on_suggestion_button_press): gate AnkiHub-note-type notes
  including new ones, so an empty new note's hotkey no longer falls through to
  add_current_note (which also avoids leaking the on_did_add_note callback).
- _track_editor prunes sip-deleted editors on append so _tracked_editors can't
  accumulate across a session.
The button is visually disabled for non-AnkiHub notes, but the keyboard
shortcut still fires (disables=False). Before this commit it fell through
to open_suggestion_dialog_for_single_suggestion, which asserts the note's
note type is an AnkiHub one — so the hotkey on a non-AnkiHub note raised
AssertionError. Early-return from _on_suggestion_button_press in that
case (and when editor.note is None) so the action matches the visual
state.
…rade-off

Codex pass-3 follow-ups:
- Move the "note is None or non-AnkiHub note type" no-op above the login
  check in _on_suggestion_button_press, so a logged-out user pressing the
  hotkey on a non-AnkiHub note doesn't get a misleading login prompt.
  Parametrized the no-op test over logged_in.
- Document in _suggestion_gate_tooltip that change_type is treated as
  non-DELETE by design: an unchanged existing AH note gets the "no
  changes" tooltip rather than enabling the button on the chance the
  user might DELETE. DELETE-from-editor for unchanged notes is
  intentionally not supported by NRT-508/751; the browser's "Suggest to
  delete" remains the path for that.
…on_dialog

editor.py was inlining the same dict-comp that already exists as
_globally_protected_fields_by_mid in suggestion_dialog.py. Drop the
underscore and import it from editor.py so the editor button's gate and
the dialog's pre-open gate share the same map-builder.
The helper builds the typed Dict[NotetypeId, Set[str]] shape that
`_is_suggestible_from_diff` / `any_suggestible_from_diffs` and the dialog
widget consume — i.e. it's an adapter for the suggestions API's contract,
not a GUI concern. Move it next to those consumers in main.suggestions
and re-import in suggestion_dialog.py and editor.py.
…refresh test

- Replace the repeated dialogs.open + set_note + wait_suggestion_button_ready
  triple across the 12 new TestEditor cases with a single
  self._open_addcards(anki_note, qtbot, mocker) helper.
- Add test_new_note_button_refreshes_live_as_first_field_is_filled, covering
  the previously-untested new-note path of the typing-timer refresh — the
  case the tracked.note-is-note identity match exists for (unsaved notes
  share id 0).
(a) Drop the standalone flag-off test; fold a flag-off row into the
    consolidated existing-note gate test.
(c) Consolidate the three action-gate "blocks" tests (no-changes,
    deleted note, empty-first-field new note) into one parametrized
    test driven by setup callables.
(d) Consolidate the four visual-gate-existing-note tests into one
    parametrized test driven by named mutator helpers
    (_no_mutation, _set_front, _globally_protect_front_and_set,
    _locally_protect_front_and_set).
(e) Consolidate the two visual-gate-new-note tests into one
    parametrized test sharing the same mutator helpers.

Net: ~125 fewer test lines vs the previous state, same 26
TestEditor cases. The parametrize-id strings preserve diagnostic
clarity on failure.
Switch each consolidated parametrize from `pytest.param(...id=...)` to a
plain tuple per row + a parallel `ids=[...]` list. The shorter tuple
fits well under the line-length limit, so each scenario stays on a
single line — and `# fmt: off` blocks guard against future widening.
Diagnostic clarity is preserved (the `ids` strings are the same).
…kind

The pre-existing tooltip "Send your request to AnkiHub (<hotkey>)" used
"request", which doesn't match the feature's vocabulary anywhere else
(button labels, dialog title, command enum all use "Suggest" /
"Suggestion"). Make the wording match the dynamic button label:

- change-note: "Suggest a change to AnkiHub (<hotkey>)"
- new-note:    "Suggest the note to AnkiHub (<hotkey>)"

`_default_suggestion_button_tooltip` now takes an `is_new_note` flag
threaded through `_apply_suggestion_button_gate`. The initial `tip=`
on the button at creation time uses the change-note variant, matching
the default `Editor.ankihub_command`.
@RisingOrange RisingOrange changed the title [NRT-751] Gate editor Suggest button on edit state [NRT-751] Disable the editor Suggest button when there's nothing to suggest May 27, 2026
AnkiHubNote.get_or_none(anki_note_id=0) already returns None for unsaved
notes, so the `if note.id != 0 else None` guard was dead weight — fetch
unconditionally, matching how _refresh_buttons looks up the AH-DB row.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant