diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 01845a74b..d2375c041 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -533,26 +533,8 @@ select id from notes where id in %s and id not in (select nid from cards)"""
self._remNotes(nids)
def emptyCids(self) -> List[int]:
- """Returns IDs of empty cards."""
- rem: List[int] = []
- for m in self.models.all():
- rem += self.genCards(self.models.nids(m))
- return rem
-
- def emptyCardReport(self, cids) -> str:
- rep = ""
- for ords, cnt, flds in self.db.all(
- """
-select group_concat(ord+1), count(), flds from cards c, notes n
-where c.nid = n.id and c.id in %s group by nid"""
- % ids2str(cids)
- ):
- rep += self.tr(
- TR.EMPTY_CARDS_CARD_LINE,
- **{"card-numbers": ords, "fields": flds.replace("\x1f", " / ")},
- )
- rep += "\n\n"
- return rep
+ print("emptyCids() will go away")
+ return []
# Field checksums and sorting fields
##########################################################################
diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py
index 8acca1811..6b52b3fc3 100644
--- a/pylib/anki/rsbackend.py
+++ b/pylib/anki/rsbackend.py
@@ -700,6 +700,12 @@ class RustBackend:
except NotFoundError:
return None
+ def empty_cards_report(self) -> pb.EmptyCardsReport:
+ return self._run_command(
+ pb.BackendInput(get_empty_cards=pb.Empty()), release_gil=True
+ ).get_empty_cards
+
+
def translate_string_in(
key: TR, **kwargs: Union[str, int, float]
) -> pb.TranslateStringIn:
diff --git a/pylib/tests/test_cards.py b/pylib/tests/test_cards.py
index af1ddae58..82953b8dc 100644
--- a/pylib/tests/test_cards.py
+++ b/pylib/tests/test_cards.py
@@ -72,7 +72,9 @@ def test_genrem():
t = m["tmpls"][1]
t["qfmt"] = "{{Back}}"
mm.save(m, templates=True)
- d.remCards(d.emptyCids())
+ rep = d.backend.empty_cards_report()
+ for note in rep.notes:
+ d.remCards(note.card_ids)
assert len(f.cards()) == 1
# if we add to the note, a card should be automatically generated
f.load()
diff --git a/qt/aqt/emptycards.py b/qt/aqt/emptycards.py
new file mode 100644
index 000000000..f3174130e
--- /dev/null
+++ b/qt/aqt/emptycards.py
@@ -0,0 +1,96 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+import re
+
+import aqt
+from anki.backend_pb2 import EmptyCardsReport, NoteWithEmptyCards
+from aqt.qt import QDialog, QDialogButtonBox, qconnect
+from aqt.utils import TR, restoreGeom, saveGeom, tooltip, tr
+
+
+def show_empty_cards(mw: aqt.main.AnkiQt) -> None:
+ mw.progress.start()
+
+ def on_done(fut):
+ mw.progress.finish()
+ report: EmptyCardsReport = fut.result()
+ if not report.notes:
+ tooltip(tr(TR.EMPTY_CARDS_NOT_FOUND))
+ return
+ diag = EmptyCardsDialog(mw, report)
+ diag.show()
+
+ mw.taskman.run_in_background(mw.col.backend.empty_cards_report, on_done)
+
+
+class EmptyCardsDialog(QDialog):
+ silentlyClose = True
+
+ def __init__(self, mw: aqt.main.AnkiQt, report: EmptyCardsReport) -> None:
+ super().__init__(mw)
+ self.mw = mw.weakref()
+ self.report = report
+ self.form = aqt.forms.emptycards.Ui_Dialog()
+ self.form.setupUi(self)
+ restoreGeom(self, "emptycards")
+ self.setWindowTitle(tr(TR.EMPTY_CARDS_WINDOW_TITLE))
+ self.form.keep_notes.setText(tr(TR.EMPTY_CARDS_PRESERVE_NOTES_CHECKBOX))
+ self.form.webview.title = "empty cards"
+ self.form.webview.set_bridge_command(self._on_note_link_clicked, self)
+
+ # make the note ids clickable
+ html = re.sub(
+ r"\[anki:nid:(\d+)\]",
+ "\\1: ",
+ report.report,
+ )
+ style = ""
+ self.form.webview.stdHtml(style + html, context=self)
+
+ def on_finished(code):
+ saveGeom(self, "emptycards")
+
+ qconnect(self.finished, on_finished)
+
+ self._delete_button = self.form.buttonBox.addButton(
+ tr(TR.EMPTY_CARDS_DELETE_BUTTON), QDialogButtonBox.ActionRole
+ )
+ self._delete_button.setAutoDefault(False)
+ self._delete_button.clicked.connect(self._on_delete)
+
+ def _on_note_link_clicked(self, link):
+ browser = aqt.dialogs.open("Browser", self.mw)
+ browser.form.searchEdit.lineEdit().setText(link)
+ browser.onSearchActivated()
+
+ def _on_delete(self):
+ self.mw.progress.start()
+
+ def delete():
+ return self._delete_cards(self.form.keep_notes.isChecked())
+
+ def on_done(fut):
+ self.mw.progress.finish()
+ try:
+ count = fut.result()
+ finally:
+ self.close()
+ tooltip(tr(TR.EMPTY_CARDS_DELETED_COUNT, cards=count))
+
+ self.mw.taskman.run_in_background(delete, on_done)
+
+ def _delete_cards(self, keep_notes):
+ to_delete = []
+ for note in self.report.notes:
+ note: NoteWithEmptyCards = note
+ if keep_notes and note.will_delete_note:
+ # leave first card
+ to_delete.extend(note.card_ids[1:])
+ else:
+ to_delete.extend(note.card_ids)
+
+ self.mw.col.remCards(to_delete)
+ return len(to_delete)
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index 0743831c7..c8fbd594d 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -10,6 +10,7 @@ import os
import re
import signal
import time
+import weakref
import zipfile
from argparse import Namespace
from concurrent.futures import Future
@@ -35,6 +36,7 @@ from anki.storage import Collection
from anki.utils import devMode, ids2str, intTime, isMac, isWin, splitFields
from aqt import gui_hooks
from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user
+from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer
@@ -53,7 +55,6 @@ from aqt.utils import (
openLink,
restoreGeom,
restoreState,
- saveGeom,
showInfo,
showText,
showWarning,
@@ -161,6 +162,10 @@ class AnkiQt(QMainWindow):
self.setupProfile()
+ def weakref(self) -> AnkiQt:
+ "Shortcut to create a weak reference that doesn't break code completion."
+ return weakref.proxy(self) # type: ignore
+
# Profiles
##########################################################################
@@ -1336,34 +1341,8 @@ will be lost. Continue?"""
self.col.decks.select(self.col.decks.id(ret.name))
self.moveToState("overview")
- def onEmptyCards(self):
- self.progress.start(immediate=True)
- cids = self.col.emptyCids()
- cids = gui_hooks.empty_cards_will_be_deleted(cids)
- if not cids:
- self.progress.finish()
- tooltip(_("No empty cards."))
- return
- report = self.col.emptyCardReport(cids)
- self.progress.finish()
- part1 = ngettext("%d card", "%d cards", len(cids)) % len(cids)
- part1 = _("%s to delete:") % part1
- diag, box = showText(part1 + "\n\n" + report, run=False, geomKey="emptyCards")
- box.addButton(_("Delete Cards"), QDialogButtonBox.AcceptRole)
- box.button(QDialogButtonBox.Close).setDefault(True)
-
- def onDelete():
- saveGeom(diag, "emptyCards")
- QDialog.accept(diag)
- self.checkpoint(_("Delete Empty"))
- self.col.remCards(cids)
- tooltip(
- ngettext("%d card deleted.", "%d cards deleted.", len(cids)) % len(cids)
- )
- self.reset()
-
- qconnect(box.accepted, onDelete)
- diag.show()
+ def onEmptyCards(self) -> None:
+ show_empty_cards(self)
# Debugging
######################################################################
diff --git a/qt/designer/emptycards.ui b/qt/designer/emptycards.ui
new file mode 100644
index 000000000..aee7f7602
--- /dev/null
+++ b/qt/designer/emptycards.ui
@@ -0,0 +1,128 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 531
+ 345
+
+
+
+ EMPTY_CARDS
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+
+ about:blank
+
+
+
+
+ -
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
+ 12
+
+
-
+
+
+ KEEP_NOTES
+
+
+ true
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Close
+
+
+
+
+
+
+
+
+
+ AnkiWebView
+ QWidget
+
+ 1
+
+
+
+ buttonBox
+
+
+
+
+ buttonBox
+ accepted()
+ Dialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ Dialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/rslib/ftl/empty-cards.ftl b/rslib/ftl/empty-cards.ftl
index f4227e72d..7cbbaa5b7 100644
--- a/rslib/ftl/empty-cards.ftl
+++ b/rslib/ftl/empty-cards.ftl
@@ -4,3 +4,8 @@ empty-cards-count-line =
empty-cards-window-title = Empty Cards
empty-cards-preserve-notes-checkbox = Keep notes with no valid cards
empty-cards-delete-button = Delete
+empty-cards-not-found = No empty cards.
+empty-cards-deleted-count = Deleted { $cards ->
+ [one] { $cards } card.
+ *[other] { $cards } cards.
+ }