Skip to content

[NRT-591] Surface duplicate anki_id suggestion error + redesign bulk suggestion summary#1306

Open
RisingOrange wants to merge 12 commits into
mainfrom
worktree-nrt-591-duplicate-anki-id
Open

[NRT-591] Surface duplicate anki_id suggestion error + redesign bulk suggestion summary#1306
RisingOrange wants to merge 12 commits into
mainfrom
worktree-nrt-591-duplicate-anki-id

Conversation

@RisingOrange

@RisingOrange RisingOrange commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Related issues

Proposed changes

When a user submits a new-note suggestion for a note whose anki_id already exists in the deck on AnkiHub (note created + auto-accepted, then edited and re-suggested before a sync), the server rejects it with "A deck can't contain multiple notes with the same anki_id.". Previously this failure was effectively hidden (single → raw showInfo; bulk → an uncategorized blob in the plain-text summary). This PR surfaces it and lets the user recover.

Detection (main/suggestions.py)

  • ANKIHUB_DUPLICATE_ANKI_ID_ERROR constant + parse_duplicate_anki_id_error() — tolerates the backend's DRF-normalized payload (list-wrapped/stringified: reads conflicting_ankihub_id[0], compares conflicting_note_deleted[0] == "True", not truthiness).
  • Resubmit core: _new_note_to_change_suggestion() converts the already-built NewNoteSuggestion into a ChangeNoteSuggestion keyed by the conflicting ah_nid and lets the server diff — no remote fetch, no AnkiHub-DB write. resubmit_new_note_as_change_suggestion() (single) and resubmit_new_notes_as_change_suggestions_in_bulk() (one batched call).
  • suggest_notes_in_bulk now returns already_in_deck_by_nid on the result; soft-deleted conflicts are routed to the existing deleted-on-AnkiHub category instead (a change suggestion would 404 a tombstoned note).

Scenario 1 — single suggestion (gui/suggestion_dialog.py)

  • New NoteAlreadyExistsDialog (info icon, Cancel / Send as change suggestion). The suggestion dialog closes and the modal shows over the editor; on success → toast; soft-deleted conflict → existing "deleted from AnkiHub" dialog; missing conflicting id (older server) → falls back to today's generic error.

Scenario 2 — bulk suggestion summary (gui/bulk_suggestion_summary_dialog.py)

  • Replaces the plain-text showText summary with a real BulkSuggestionSummaryDialog, shown for all outcomes (full redesign): colored counts, bold (N) category titles, a new "Other errors" bucket with a community.ankihub.net/c/support link, and the redundant "All notes with failed suggestions" block removed.
  • New interactive "Notes already in this deck" action box: Resubmit (one batched create_suggestions_in_bulk call) / Ignore; Close/X disabled until resolved; "Updated" badges on changed categories; a hard resubmit failure re-enables Close (no dead-end).

Analytics (structlog → Datadog) — single: duplicate_note_error_dialog_shown, duplicate_note_resubmitted_as_update_suggestion; bulk: bulk_duplicate_note_error_dialog_shown, bulk_duplicate_note_resubmitted_as_update_suggestion.

How to reproduce

As a maintainer on staging:

  1. Create a new note in Anki using one of your deck's AnkiHub note types and Suggest it (auto-accepts so the note is created on AnkiHub).
  2. Don't sync. Edit the same note and suggest it again.
  3. Before: the failure was hidden (single: a raw error; bulk: an uncategorized failure count).
  4. After: single → "Note already exists in this deck" dialog with Send as change suggestion; bulk → the note lands in the "Notes already in this deck" action box with a Resubmit as change suggestions button.

Tests: pytest tests/addon -k "BulkSuggestionSummaryDialog or MaybeHandleNoteAlreadyExists or ParseDuplicate or NewNoteToChange or OnSuggestNotesInBulkDone or already_in_deck".

Screenshots and videos

Redesigned Bulk Suggestion Summary dialog.

Default (dark) — Summary, categorized submission issues, and the full-bleed "Action required" band:
Bulk Suggestion Summary — default (dark)

After resubmitting — the band is removed and the Summary updates with an "Updated" badge (failed count drops accordingly):
Bulk Suggestion Summary — after resubmitting

Ignored — user dismissed the action; the band collapses to a muted note and Close is enabled:
Bulk Suggestion Summary — ignored

Light mode:
Bulk Suggestion Summary — light mode

…summary

Single suggestion: when a new-note suggestion is rejected because the note
already exists in the deck on AnkiHub, show a 'Note already exists in this deck'
dialog offering to resubmit the edits as a change suggestion (converting the
already-built NewNoteSuggestion, no remote fetch / AnkiHub-DB write). Soft-deleted
conflicts route to the existing deleted-on-AnkiHub handling.

Bulk suggestion: replace the plain-text showText summary with a real dialog shown
for all outcomes - categorized results, a new 'Other errors' bucket with a support
link, and an interactive 'Notes already in this deck' action (batched resubmit /
ignore) with Close gating and 'Updated' badges. Degrades gracefully when the
backend doesn't return the conflicting id.

Adds analytics events (error_dialog_shown, resubmit_clicked,
bulk_error_category_shown, bulk_resubmit_clicked) and tests.
- Gate X/Escape (reject()/closeEvent()) on the bulk summary dialog so an
  unresolved action or in-flight resubmit can't be dismissed - previously only
  the Close button was disabled. Adds a regression test.
- Replace the stringly-typed action state + separate hard-error flag with a
  single _ActionState enum (a hard resubmit failure is now FAILED: retry
  buttons stay, Close works).
- Reuse gui.utils.clear_layout instead of a local duplicate; add missing
  type annotations.
…icate-anki-id

# Conflicts:
#	ankihub/gui/suggestion_dialog.py
#	ankihub/main/suggestions.py
- Move the bulk summary dialog, its action-state machine, categories, color
  helpers, and the bulk-submit callback to gui/bulk_suggestion_summary_dialog.py.
- Move the shared panel color helpers to gui/utils.py (avoids a circular
  import between the two dialog modules).
- Drop the change_type plumbing from the bulk callback/dialog - converted
  resubmits are always 'Updated content', matching the single-note path.
- Flatten the nested resubmit task/on_done callbacks in
  _show_note_already_exists_dialog.
- Rename module constants to the no-underscore convention.
Match the finalized design: flat layout with section headers (Summary / Submission
issues / Action required), colored summary bullets, and 'no changes' surfaced as a
'skipped' summary line excluded from the failure count. Each category gets a
'Copy note IDs' button (copies an 'nid:' Browse search) and a one-line guidance.
Renamed categories ('Notes deleted on AnkiHub', 'First field empty'); deleted-on-
AnkiHub links to the docs article. Action-required band has a distinct background;
Resubmit is the default button.
…tion

- _handle_suggestion_error: a 400 response without 'non_field_errors' iterated
  None in the no-change check (latent TypeError). Normalize to [] and guard the
  all() so it degrades to the generic error dialog. Adds a regression test.
- Annotate BulkSuggestionSummaryDialog.closeEvent's event param (QCloseEvent).
…cs rename

- Full-bleed "Action required" band; content inset at 28px, 16px top margin
- Fix resize squashing summary on re-render (defer adjustSize one tick)
- Section spacing: 20px between sections, 8px header→band, 16px band→footer
- Typography: Summary 18 / Submission issues 16 / titles+badge tiers
- macOS: ApplicationModal (no sheet), scope transparent bg so buttons keep
  their border, top-align the "Updated" badge so it stays pill-height
- Narrow min width 520→440
- Rename analytics events to spec: duplicate_note_error_dialog_shown,
  duplicate_note_resubmitted_as_update_suggestion, bulk_* variants
The band description's last line was clipped at the 440px width: adjustSize()
computes height from the label's sizeHint, which under-reports a word-wrap
label's wrapped height. Pin each word-wrap label to its real heightForWidth at
the current width before resizing.
@RisingOrange RisingOrange marked this pull request as ready for review June 15, 2026 16:55
@RisingOrange RisingOrange requested a review from a team June 15, 2026 16:55

@ddevdan ddevdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's this medium bug that claude flagged where if you submit a note with image, then change the image and try to submit again, the image breaks

Single-note resubmit submits stale media filenames

  `resubmit_new_note_as_change_suggestion` rebuilds the suggestion from the **in-memory** `note`, but the failed `suggest_new_note` renamed media to hashed names via raw SQL on the DB only (`_update_media_names_on_notes` → `media_utils.py:9`) — the
  in-memory note is stale. So if new media is added during the edit, the change suggestion references the **original** filename, which was never uploaded under that name → broken image for everyone who syncs. Silent, and auto-accept (the maintainer
  trigger) skips review.

  Bulk path is safe — it reuses the post-rename DTO. Not hit by the canonical NRT-591 repro (text-only edit, no rename), which is why tests stayed green.

  **Fix** (one line in the single path):
  ```python
  # Failed new-note submit renamed media in the DB only, not the in-memory note. Reload
  # so the change suggestion carries the uploaded (hashed) names, not the stale originals.
  note = aqt.mw.col.get_note(note.id)

screen-capture.10.webm

Solution:

++ b/ankihub/main/suggestions.py
     @@ -547,6 +547,11 @@ def resubmit_new_note_as_change_suggestion(
          """Resubmit a single note (whose new-note suggestion hit the duplicate-anki_id
          error) as a change suggestion for the existing note. Media was already renamed and
          uploaded by the failed new-note submit, so it is not re-uploaded here."""
     +    # The failed new-note submit renamed media to hashed names directly in the DB (raw
     +    # SQL, see `_update_media_names_on_notes`) without touching the in-memory note the
     +    # caller is holding. Reload so the change suggestion carries the uploaded (hashed)
     +    # media names rather than the stale originals (which were never uploaded).
     +    note = aqt.mw.col.get_note(note.id)
          new_note_suggestion = _new_note_suggestion(note, ah_did, comment, filters=filters)
          if new_note_suggestion is None:
              return ChangeSuggestionResult.NO_CHANGES

The failed new-note submit renames media to hashed names directly in the DB via
raw SQL (_update_media_names_on_notes), without touching the caller's in-memory
note. The single resubmit path rebuilt the suggestion from that stale note, so a
note whose media changed during the edit would reference the original (never
uploaded) filename — a broken image for everyone who syncs, silent under
auto-accept. Reload the note from the collection before building the suggestion.

The bulk path is unaffected (it reuses the post-rename DTOs). Adds a regression
test (the canonical text-only repro didn't exercise the rename).
@RisingOrange RisingOrange requested a review from ddevdan June 17, 2026 07:18
@RisingOrange

Copy link
Copy Markdown
Collaborator Author

@ddevdan Good catch!

ddevdan
ddevdan previously approved these changes Jun 17, 2026

@ddevdan ddevdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Top-align each category title and its "Copy note IDs" button (the button is
taller) and put 12px between that row and the guidance text below, matching the
Figma spacing. Apply the same to the "Notes already in this deck" band so it's
consistent with the Submission issues blocks.
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.

2 participants