Merge branch 'master' into backend-columns

This commit is contained in:
RumovZ 2021-04-11 11:18:15 +02:00
commit fadec3dc5b
70 changed files with 1022 additions and 778 deletions

View File

@ -28,15 +28,11 @@ build --incompatible_default_to_explicit_init_py
build:ci --show_timestamps --isatty=0 --color=yes --show_progress_rate_limit=5 build:ci --show_timestamps --isatty=0 --color=yes --show_progress_rate_limit=5
# incrementally compile Anki crates in fastbuild mode, but not release mode. # disable incremental compilation in release mode
# The worker must be separately enabled; see docs/development.md build:opt -c opt --@rules_rust//:experimental_incremental_prefixes=
build -c fastbuild --@rules_rust//worker:include_regex=anki.*|rsbridge
build:opt -c opt --@rules_rust//worker:include_regex=no-crates-please
build --worker_max_instances=Rustc=HOST_CPUS*0.5
# the TypeScript workers on Windows choke when deps are changed while they're # the TypeScript workers on Windows choke when deps are changed while they're
# still running, so shut them down at the end of the build. Also fixes issues # still running, so shut them down at the end of the build.
# with the optional Rust worker.
build:windows --worker_quit_after_build build:windows --worker_quit_after_build
try-import %workspace%/user.bazelrc try-import %workspace%/user.bazelrc

View File

@ -9,6 +9,7 @@ load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install
load("@io_bazel_rules_sass//:defs.bzl", "sass_repositories") load("@io_bazel_rules_sass//:defs.bzl", "sass_repositories")
load("@com_github_ali5h_rules_pip//:defs.bzl", "pip_import") load("@com_github_ali5h_rules_pip//:defs.bzl", "pip_import")
load("//pip/pyqt5:defs.bzl", "install_pyqt5") load("//pip/pyqt5:defs.bzl", "install_pyqt5")
load("//ts/esbuild:esbuild_repo.bzl", "esbuild_dependencies")
anki_version = "2.1.44" anki_version = "2.1.44"
@ -52,3 +53,5 @@ def setup_deps():
) )
sass_repositories() sass_repositories()
esbuild_dependencies()

View File

@ -153,7 +153,7 @@ following in your user.bazelrc file to enable incremental compilation
when using ./run. when using ./run.
``` ```
build --@rules_rust//worker:cache_root=/path/to/folder/to/store/temp/files build --@rules_rust//:experimental_incremental_base=/home/myuser/bazel/incremental
``` ```
The worker support is experimental, so you may need to remove it in future The worker support is experimental, so you may need to remove it in future

View File

@ -40,3 +40,4 @@ errors-unable-open-collection =
Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager. Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager.
Debug info: Debug info:
errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, and try using a different voice.

View File

@ -385,7 +385,7 @@ class Collection:
note.id = NoteId(out.note_id) note.id = NoteId(out.note_id)
return out.changes return out.changes
def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChanges: def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount:
hooks.notes_will_be_deleted(self, note_ids) hooks.notes_will_be_deleted(self, note_ids)
return self._backend.remove_notes(note_ids=note_ids, card_ids=[]) return self._backend.remove_notes(note_ids=note_ids, card_ids=[])
@ -917,11 +917,6 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo) assert_exhaustive(self._undo)
assert False assert False
def op_affects_study_queue(self, changes: OpChanges) -> bool:
if changes.kind == changes.SET_CARD_FLAG:
return False
return changes.card or changes.deck or changes.preference
def op_made_changes(self, changes: OpChanges) -> bool: def op_made_changes(self, changes: OpChanges) -> bool:
for field in changes.DESCRIPTOR.fields: for field in changes.DESCRIPTOR.fields:
if field.name != "kind": if field.name != "kind":

View File

@ -447,30 +447,31 @@ class DeckManager:
# Deck selection # Deck selection
############################################################# #############################################################
def active(self) -> List[DeckId]: def get_current(self) -> Deck:
"The currrently active dids." return self.col._backend.get_current_deck()
return self.col.get_config("activeDecks", [1])
def selected(self) -> DeckId: def set_current(self, deck: DeckId) -> OpChanges:
"The currently selected did." return self.col._backend.set_current_deck(deck)
return DeckId(int(self.col.conf["curDeck"]))
def get_current_id(self) -> DeckId:
"The currently selected deck ID."
return DeckId(self.get_current().id)
# legacy
def current(self) -> DeckDict: def current(self) -> DeckDict:
return self.get(self.selected()) return self.get(self.selected())
def select(self, did: DeckId) -> None: def select(self, did: DeckId) -> None:
"Select a new branch."
# make sure arg is an int; legacy callers may be passing in a string # make sure arg is an int; legacy callers may be passing in a string
did = DeckId(did) did = DeckId(did)
current = self.selected() self.set_current(did)
active = self.deck_and_child_ids(did) self.col.reset()
if current != did or active != self.active():
self.col.conf["curDeck"] = did
self.col.conf["activeDecks"] = active
# don't use this, it will likely go away def active(self) -> List[DeckId]:
def update_active(self) -> None: return self.col.sched.active_decks
self.select(self.current()["id"])
selected = get_current_id
# Parents/children # Parents/children
############################################################# #############################################################
@ -518,7 +519,7 @@ class DeckManager:
) )
def deck_and_child_ids(self, deck_id: DeckId) -> List[DeckId]: def deck_and_child_ids(self, deck_id: DeckId) -> List[DeckId]:
parent_name = self.get_legacy(deck_id)["name"] parent_name = self.col.get_deck(deck_id).name
out = [deck_id] out = [deck_id]
out.extend(self.child_ids(parent_name)) out.extend(self.child_ids(parent_name))
return out return out

View File

@ -16,7 +16,7 @@ from typing import List, Optional, Sequence
from anki.cards import CardId from anki.cards import CardId
from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV
from anki.decks import DeckConfigDict, DeckId, DeckTreeNode from anki.decks import DeckConfigDict, DeckId, DeckTreeNode
from anki.notes import Note from anki.notes import NoteId
from anki.utils import ids2str, intTime from anki.utils import ids2str, intTime
CongratsInfo = _pb.CongratsInfoOut CongratsInfo = _pb.CongratsInfoOut
@ -85,8 +85,9 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# fixme: only used by totalRevForCurrentDeck and old deck stats; # fixme: only used by totalRevForCurrentDeck and old deck stats;
# schedv2 defines separate version # schedv2 defines separate version
def _deckLimit(self) -> str: def _deckLimit(self) -> str:
self.col.decks.update_active() return ids2str(
return ids2str(self.col.decks.active()) self.col.decks.deck_and_child_ids(self.col.decks.get_current_id())
)
# Filtered deck handling # Filtered deck handling
########################################################################## ##########################################################################
@ -123,20 +124,31 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
) -> None: ) -> None:
self.col._backend.unbury_cards_in_current_deck(mode) self.col._backend.unbury_cards_in_current_deck(mode)
def suspend_cards(self, ids: Sequence[CardId]) -> OpChanges: def suspend_cards(self, ids: Sequence[CardId]) -> OpChangesWithCount:
return self.col._backend.bury_or_suspend_cards( return self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND card_ids=ids, note_ids=[], mode=BuryOrSuspend.SUSPEND
) )
def bury_cards(self, ids: Sequence[CardId], manual: bool = True) -> OpChanges: def suspend_notes(self, ids: Sequence[NoteId]) -> OpChangesWithCount:
return self.col._backend.bury_or_suspend_cards(
card_ids=[], note_ids=ids, mode=BuryOrSuspend.SUSPEND
)
def bury_cards(
self, ids: Sequence[CardId], manual: bool = True
) -> OpChangesWithCount:
if manual: if manual:
mode = BuryOrSuspend.BURY_USER mode = BuryOrSuspend.BURY_USER
else: else:
mode = BuryOrSuspend.BURY_SCHED mode = BuryOrSuspend.BURY_SCHED
return self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode) return self.col._backend.bury_or_suspend_cards(
card_ids=ids, note_ids=[], mode=mode
)
def bury_note(self, note: Note) -> None: def bury_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount:
self.bury_cards(note.card_ids()) return self.col._backend.bury_or_suspend_cards(
card_ids=[], note_ids=note_ids, mode=BuryOrSuspend.BURY_USER
)
# Resetting/rescheduling # Resetting/rescheduling
########################################################################## ##########################################################################

View File

@ -33,7 +33,7 @@ class Scheduler(V2):
def __init__( # pylint: disable=super-init-not-called def __init__( # pylint: disable=super-init-not-called
self, col: anki.collection.Collection self, col: anki.collection.Collection
) -> None: ) -> None:
self.col = col.weakref() super().__init__(col)
self.queueLimit = 50 self.queueLimit = 50
self.reportLimit = 1000 self.reportLimit = 1000
self.dynReportLimit = 99999 self.dynReportLimit = 99999
@ -42,7 +42,6 @@ class Scheduler(V2):
self.revCount = 0 self.revCount = 0
self.newCount = 0 self.newCount = 0
self._haveQueues = False self._haveQueues = False
self._updateCutoff()
def answerCard(self, card: Card, ease: int) -> None: def answerCard(self, card: Card, ease: int) -> None:
self.col.log() self.col.log()

View File

@ -48,7 +48,16 @@ class Scheduler(SchedulerBaseWithLegacy):
self.reps = 0 self.reps = 0
self._haveQueues = False self._haveQueues = False
self._lrnCutoff = 0 self._lrnCutoff = 0
self._updateCutoff() self._active_decks: List[DeckId] = []
self._current_deck_id = DeckId(1)
@property
def active_decks(self) -> List[DeckId]:
"Caller must make sure to make a copy."
return self._active_decks
def _update_active_decks(self) -> None:
self._active_decks = self.col.decks.deck_and_child_ids(self._current_deck_id)
# Daily cutoff # Daily cutoff
########################################################################## ##########################################################################
@ -65,8 +74,8 @@ class Scheduler(SchedulerBaseWithLegacy):
########################################################################## ##########################################################################
def reset(self) -> None: def reset(self) -> None:
self.col.decks.update_active() self._current_deck_id = self.col.decks.selected()
self._updateCutoff() self._update_active_decks()
self._reset_counts() self._reset_counts()
self._resetLrn() self._resetLrn()
self._resetRev() self._resetRev()
@ -74,10 +83,8 @@ class Scheduler(SchedulerBaseWithLegacy):
self._haveQueues = True self._haveQueues = True
def _reset_counts(self) -> None: def _reset_counts(self) -> None:
tree = self.deck_due_tree(self.col.decks.selected()) tree = self.deck_due_tree(self._current_deck_id)
node = self.col.decks.find_deck_in_tree( node = self.col.decks.find_deck_in_tree(tree, self._current_deck_id)
tree, DeckId(int(self.col.conf["curDeck"]))
)
if not node: if not node:
# current deck points to a missing deck # current deck points to a missing deck
self.newCount = 0 self.newCount = 0

View File

@ -18,6 +18,7 @@ def test_basic():
assert col.decks.id("new deck") == parentId assert col.decks.id("new deck") == parentId
# we start with the default col selected # we start with the default col selected
assert col.decks.selected() == 1 assert col.decks.selected() == 1
col.reset()
assert col.decks.active() == [1] assert col.decks.active() == [1]
# we can select a different col # we can select a different col
col.decks.select(parentId) col.decks.select(parentId)

View File

@ -501,7 +501,7 @@ def test_misc():
col.addNote(note) col.addNote(note)
c = note.cards()[0] c = note.cards()[0]
# burying # burying
col.sched.bury_note(note) col.sched.bury_notes([note.id])
col.reset() col.reset()
assert not col.sched.getCard() assert not col.sched.getCard()
col.sched.unbury_cards_in_current_deck() col.sched.unbury_cards_in_current_deck()

View File

@ -208,9 +208,9 @@ class AddCards(QDialog):
self._load_new_note(sticky_fields_from=note) self._load_new_note(sticky_fields_from=note)
gui_hooks.add_cards_did_add_note(note) gui_hooks.add_cards_did_add_note(note)
add_note( add_note(parent=self, note=note, target_deck_id=target_deck_id).success(
mw=self.mw, note=note, target_deck_id=target_deck_id, success=on_success on_success
) ).run_in_background()
def _note_can_be_added(self, note: Note) -> bool: def _note_can_be_added(self, note: Note) -> bool:
result = note.duplicate_or_empty() result = note.duplicate_or_empty()

View File

@ -24,7 +24,6 @@ from aqt.editor import Editor
from aqt.exporting import ExportDialog from aqt.exporting import ExportDialog
from aqt.find_and_replace import FindAndReplaceDialog from aqt.find_and_replace import FindAndReplaceDialog
from aqt.main import ResetReason from aqt.main import ResetReason
from aqt.operations import OpMeta
from aqt.operations.card import set_card_deck, set_card_flag from aqt.operations.card import set_card_deck, set_card_flag
from aqt.operations.collection import undo from aqt.operations.collection import undo
from aqt.operations.note import remove_notes from aqt.operations.note import remove_notes
@ -128,12 +127,14 @@ class Browser(QMainWindow):
gui_hooks.browser_will_show(self) gui_hooks.browser_will_show(self)
self.show() self.show()
def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: def on_operation_did_execute(
self, changes: OpChanges, handler: Optional[object]
) -> None:
focused = current_top_level_widget() == self focused = current_top_level_widget() == self
self.table.op_executed(changes, meta, focused) self.table.op_executed(changes, handler, focused)
self.sidebar.op_executed(changes, meta, focused) self.sidebar.op_executed(changes, handler, focused)
if changes.note or changes.notetype: if changes.note or changes.notetype:
if meta.handler is not self.editor: if handler is not self.editor:
# fixme: this will leave the splitter shown, but with no current # fixme: this will leave the splitter shown, but with no current
# note being edited # note being edited
note = self.editor.note note = self.editor.note
@ -641,11 +642,7 @@ where id in %s"""
self.focusTo = self.editor.currentField self.focusTo = self.editor.currentField
self.table.to_next_row() self.table.to_next_row()
remove_notes( remove_notes(parent=self, note_ids=nids).run_in_background()
mw=self.mw,
note_ids=nids,
success=lambda _: tooltip(tr.browsing_note_deleted(count=len(nids))),
)
# legacy # legacy
@ -676,7 +673,7 @@ where id in %s"""
return return
did = self.col.decks.id(ret.name) did = self.col.decks.id(ret.name)
set_card_deck(mw=self.mw, card_ids=cids, deck_id=did) set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background()
# legacy # legacy
@ -694,13 +691,8 @@ where id in %s"""
if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())): if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())):
return return
add_tags_to_notes( add_tags_to_notes(
mw=self.mw, parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
note_ids=self.selected_notes(), ).run_in_background(initiator=self)
space_separated_tags=tags,
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=self
),
)
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
@ -709,14 +701,10 @@ where id in %s"""
tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete()) tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete())
): ):
return return
remove_tags_from_notes( remove_tags_from_notes(
mw=self.mw, parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
note_ids=self.selected_notes(), ).run_in_background(initiator=self)
space_separated_tags=tags,
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=self
),
)
def _prompt_for_tags(self, prompt: str) -> Optional[str]: def _prompt_for_tags(self, prompt: str) -> Optional[str]:
(tags, ok) = getTag(self, self.col, prompt) (tags, ok) = getTag(self, self.col, prompt)
@ -727,7 +715,7 @@ where id in %s"""
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def clear_unused_tags(self) -> None: def clear_unused_tags(self) -> None:
clear_unused_tags(mw=self.mw, parent=self) clear_unused_tags(parent=self).run_in_background()
addTags = add_tags_to_selected_notes addTags = add_tags_to_selected_notes
deleteTags = remove_tags_from_selected_notes deleteTags = remove_tags_from_selected_notes
@ -744,9 +732,9 @@ where id in %s"""
def suspend_selected_cards(self, checked: bool) -> None: def suspend_selected_cards(self, checked: bool) -> None:
cids = self.selected_cards() cids = self.selected_cards()
if checked: if checked:
suspend_cards(mw=self.mw, card_ids=cids) suspend_cards(parent=self, card_ids=cids).run_in_background()
else: else:
unsuspend_cards(mw=self.mw, card_ids=cids) unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background()
# Exporting # Exporting
###################################################################### ######################################################################
@ -768,7 +756,9 @@ where id in %s"""
if flag == self.card.user_flag(): if flag == self.card.user_flag():
flag = 0 flag = 0
set_card_flag(mw=self.mw, card_ids=self.selected_cards(), flag=flag) set_card_flag(
parent=self, card_ids=self.selected_cards(), flag=flag
).run_in_background()
def _update_flags_menu(self) -> None: def _update_flags_menu(self) -> None:
flag = self.card and self.card.user_flag() flag = self.card and self.card.user_flag()
@ -806,25 +796,23 @@ where id in %s"""
return return
reposition_new_cards_dialog( reposition_new_cards_dialog(
mw=self.mw, parent=self, card_ids=self.selected_cards() parent=self, card_ids=self.selected_cards()
) ).run_in_background()
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def set_due_date(self) -> None: def set_due_date(self) -> None:
set_due_date_dialog( set_due_date_dialog(
mw=self.mw,
parent=self, parent=self,
card_ids=self.selected_cards(), card_ids=self.selected_cards(),
config_key=Config.String.SET_DUE_BROWSER, config_key=Config.String.SET_DUE_BROWSER,
) ).run_in_background()
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def forget_cards(self) -> None: def forget_cards(self) -> None:
forget_cards( forget_cards(
mw=self.mw,
parent=self, parent=self,
card_ids=self.selected_cards(), card_ids=self.selected_cards(),
) ).run_in_background()
# Edit: selection # Edit: selection
###################################################################### ######################################################################
@ -867,7 +855,7 @@ where id in %s"""
###################################################################### ######################################################################
def undo(self) -> None: def undo(self) -> None:
undo(mw=self.mw, parent=self) undo(parent=self)
def onUndoState(self, on: bool) -> None: def onUndoState(self, on: bool) -> None:
self.form.actionUndo.setEnabled(on) self.form.actionUndo.setEnabled(on)

View File

@ -3,6 +3,7 @@ window.MathJax = {
displayMath: [["\\[", "\\]"]], displayMath: [["\\[", "\\]"]],
processRefs: false, processRefs: false,
processEnvironments: false, processEnvironments: false,
processEscapes: false,
packages: { packages: {
"[+]": ["noerrors", "mhchem"], "[+]": ["noerrors", "mhchem"],
}, },

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, Optional
import aqt import aqt
from anki.collection import OpChanges from anki.collection import OpChanges
@ -17,6 +17,7 @@ from aqt.operations.deck import (
remove_decks, remove_decks,
rename_deck, rename_deck,
reparent_decks, reparent_decks,
set_current_deck,
set_deck_collapsed, set_deck_collapsed,
) )
from aqt.qt import * from aqt.qt import *
@ -76,8 +77,10 @@ class DeckBrowser:
if self._refresh_needed: if self._refresh_needed:
self.refresh() self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool: def op_executed(
if self.mw.col.op_affects_study_queue(changes): self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if changes.study_queues and handler is not self:
self._refresh_needed = True self._refresh_needed = True
if focused: if focused:
@ -94,7 +97,7 @@ class DeckBrowser:
else: else:
cmd = url cmd = url
if cmd == "open": if cmd == "open":
self._selDeck(arg) self.set_current_deck(DeckId(int(arg)))
elif cmd == "opts": elif cmd == "opts":
self._showOptions(arg) self._showOptions(arg)
elif cmd == "shared": elif cmd == "shared":
@ -117,9 +120,10 @@ class DeckBrowser:
self.refresh() self.refresh()
return False return False
def _selDeck(self, did: str) -> None: def set_current_deck(self, deck_id: DeckId) -> None:
self.mw.col.decks.select(DeckId(int(did))) set_current_deck(parent=self.mw, deck_id=deck_id).success(
self.mw.onOverview() lambda _: self.mw.onOverview()
).run_in_background(initiator=self)
# HTML generation # HTML generation
########################################################################## ##########################################################################
@ -276,7 +280,9 @@ class DeckBrowser:
if not new_name or new_name == deck.name: if not new_name or new_name == deck.name:
return return
else: else:
rename_deck(mw=self.mw, deck_id=did, new_name=new_name) rename_deck(
parent=self.mw, deck_id=did, new_name=new_name
).run_in_background()
self.mw.query_op(lambda: self.mw.col.get_deck(did), success=prompt) self.mw.query_op(lambda: self.mw.col.get_deck(did), success=prompt)
@ -291,18 +297,20 @@ class DeckBrowser:
if node: if node:
node.collapsed = not node.collapsed node.collapsed = not node.collapsed
set_deck_collapsed( set_deck_collapsed(
mw=self.mw, parent=self.mw,
deck_id=did, deck_id=did,
collapsed=node.collapsed, collapsed=node.collapsed,
scope=DeckCollapseScope.REVIEWER, scope=DeckCollapseScope.REVIEWER,
) ).run_in_background()
self._renderPage(reuse=True) self._renderPage(reuse=True)
def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None: def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None:
reparent_decks(mw=self.mw, parent=self.mw, deck_ids=[source], new_parent=target) reparent_decks(
parent=self.mw, deck_ids=[source], new_parent=target
).run_in_background()
def _delete(self, did: DeckId) -> None: def _delete(self, did: DeckId) -> None:
remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did]) remove_decks(parent=self.mw, deck_ids=[did]).run_in_background()
# Top buttons # Top buttons
###################################################################### ######################################################################
@ -333,7 +341,8 @@ class DeckBrowser:
openLink(f"{aqt.appShared}decks/") openLink(f"{aqt.appShared}decks/")
def _on_create(self) -> None: def _on_create(self) -> None:
add_deck_dialog(mw=self.mw, parent=self.mw) if op := add_deck_dialog(parent=self.mw):
op.run_in_background()
###################################################################### ######################################################################

View File

@ -1,11 +1,11 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import Optional
import aqt.editor import aqt.editor
from anki.collection import OpChanges from anki.collection import OpChanges
from anki.errors import NotFoundError from anki.errors import NotFoundError
from aqt import gui_hooks from aqt import gui_hooks
from aqt.operations import OpMeta
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tr from aqt.utils import disable_help_button, restoreGeom, saveGeom, tr
@ -31,8 +31,10 @@ class EditCurrent(QDialog):
gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show() self.show()
def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: def on_operation_did_execute(
if changes.editor and meta.handler is not self.editor: self, changes: OpChanges, handler: Optional[object]
) -> None:
if changes.editor and handler is not self.editor:
# reload note # reload note
note = self.editor.note note = self.editor.note
try: try:

View File

@ -100,7 +100,7 @@ class Editor:
redrawing. redrawing.
The editor will cause that hook to be fired when it saves changes. To avoid The editor will cause that hook to be fired when it saves changes. To avoid
an unwanted refresh, the parent widget should check if meta.handler an unwanted refresh, the parent widget should check if handler
corresponds to this editor instance, and ignore the change if it does. corresponds to this editor instance, and ignore the change if it does.
""" """
@ -558,7 +558,9 @@ class Editor:
def _save_current_note(self) -> None: def _save_current_note(self) -> None:
"Call after note is updated with data from webview." "Call after note is updated with data from webview."
update_note(mw=self.mw, note=self.note, handler=self) update_note(parent=self.widget, note=self.note).run_in_background(
initiator=self
)
def fonts(self) -> List[Tuple[str, int, bool]]: def fonts(self) -> List[Tuple[str, int, bool]]:
return [ return [

View File

@ -310,7 +310,9 @@ class FilteredDeckConfigDialog(QDialog):
gui_hooks.filtered_deck_dialog_will_add_or_update_deck(self, self.deck) gui_hooks.filtered_deck_dialog_will_add_or_update_deck(self, self.deck)
add_or_update_filtered_deck(mw=self.mw, deck=self.deck, success=success) add_or_update_filtered_deck(parent=self, deck=self.deck).success(
success
).run_in_background()
# Step load/save # Step load/save
######################################################## ########################################################

View File

@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Sequence from typing import List, Sequence
import aqt import aqt
from anki.notes import NoteId from anki.notes import NoteId
from aqt import AnkiQt, QWidget from aqt import AnkiQt, QWidget
from aqt.operations.note import find_and_replace
from aqt.operations.tag import find_and_replace_tag
from aqt.qt import QDialog, Qt from aqt.qt import QDialog, Qt
from aqt.utils import ( from aqt.utils import (
HelpPage, HelpPage,
@ -22,63 +24,10 @@ from aqt.utils import (
save_combo_index_for_session, save_combo_index_for_session,
save_is_checked, save_is_checked,
saveGeom, saveGeom,
tooltip,
tr, tr,
) )
def find_and_replace(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[NoteId],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
success=lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)
def find_and_replace_tag(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.tags.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
),
success=lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)
class FindAndReplaceDialog(QDialog): class FindAndReplaceDialog(QDialog):
COMBO_NAME = "BrowserFindAndReplace" COMBO_NAME = "BrowserFindAndReplace"
@ -146,10 +95,9 @@ class FindAndReplaceDialog(QDialog):
save_is_checked(self.form.re, self.COMBO_NAME + "Regex") save_is_checked(self.form.re, self.COMBO_NAME + "Regex")
save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
# tags?
if self.form.field.currentIndex() == 1: if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag( find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(), parent=self.parentWidget(),
note_ids=self.note_ids, note_ids=self.note_ids,
search=search, search=search,
@ -157,15 +105,14 @@ class FindAndReplaceDialog(QDialog):
regex=regex, regex=regex,
match_case=match_case, match_case=match_case,
) )
return else:
# fields
if self.form.field.currentIndex() == 0: if self.form.field.currentIndex() == 0:
field = None field = None
else: else:
field = self.field_names[self.form.field.currentIndex() - 2] field = self.field_names[self.form.field.currentIndex() - 2]
find_and_replace( find_and_replace(
mw=self.mw,
parent=self.parentWidget(), parent=self.parentWidget(),
note_ids=self.note_ids, note_ids=self.note_ids,
search=search, search=search,

View File

@ -21,7 +21,6 @@ from typing import (
List, List,
Literal, Literal,
Optional, Optional,
Protocol,
Sequence, Sequence,
TextIO, TextIO,
Tuple, Tuple,
@ -40,15 +39,7 @@ import aqt.toolbar
import aqt.webview import aqt.webview
from anki import hooks from anki import hooks
from anki._backend import RustBackend as _RustBackend from anki._backend import RustBackend as _RustBackend
from anki.collection import ( from anki.collection import Collection, Config, OpChanges, UndoStatus
Collection,
Config,
OpChanges,
OpChangesAfterUndo,
OpChangesWithCount,
OpChangesWithId,
UndoStatus,
)
from anki.decks import DeckDict, DeckId from anki.decks import DeckDict, DeckId
from anki.hooks import runHook from anki.hooks import runHook
from anki.notes import NoteId from anki.notes import NoteId
@ -61,8 +52,8 @@ from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer from aqt.mediasync import MediaSyncer
from aqt.operations import OpMeta
from aqt.operations.collection import undo from aqt.operations.collection import undo
from aqt.operations.deck import set_current_deck
from aqt.profiles import ProfileManager as ProfileManagerType from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import * from aqt.qt import *
from aqt.qt import sip from aqt.qt import sip
@ -92,30 +83,6 @@ from aqt.utils import (
tr, tr,
) )
class HasChangesProperty(Protocol):
changes: OpChanges
# either an OpChanges object, or an object with .changes on it. This bound
# doesn't actually work for protobuf objects, so new protobuf objects will
# either need to be added here, or cast at call time
ResultWithChanges = TypeVar(
"ResultWithChanges",
bound=Union[
OpChanges,
OpChangesWithCount,
OpChangesWithId,
OpChangesAfterUndo,
HasChangesProperty,
],
)
T = TypeVar("T")
PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]]
PerformOpOptionalFailureCallback = Optional[Callable[[Exception], Any]]
install_pylib_legacy() install_pylib_legacy()
MainWindowState = Literal[ MainWindowState = Literal[
@ -123,6 +90,9 @@ MainWindowState = Literal[
] ]
T = TypeVar("T")
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
col: Collection col: Collection
pm: ProfileManagerType pm: ProfileManagerType
@ -733,10 +703,9 @@ class AnkiQt(QMainWindow):
) -> None: ) -> None:
"""Run an operation that queries the DB on a background thread. """Run an operation that queries the DB on a background thread.
Similar interface to perform_op(), but intended to be used for operations Intended to be used for operations that do not change collection
that do not change collection state. Undo status will not be changed, state. Undo status will not be changed, and `operation_did_execute`
and `operation_did_execute` will not fire. No progress window will will not fire. No progress window will be shown either.
be shown either.
`operations_will|did_execute` will still fire, so the UI can defer `operations_will|did_execute` will still fire, so the UI can defer
updates during a background task. updates during a background task.
@ -766,71 +735,6 @@ class AnkiQt(QMainWindow):
# Resetting state # Resetting state
########################################################################## ##########################################################################
def perform_op(
self,
op: Callable[[], ResultWithChanges],
*,
success: PerformOpOptionalSuccessCallback = None,
failure: PerformOpOptionalFailureCallback = None,
after_hooks: Optional[Callable[[], None]] = None,
meta: OpMeta = OpMeta(),
) -> None:
"""Run the provided operation on a background thread.
op() should either return OpChanges, or an object with a 'changes'
property. The changes will be passed to `operation_did_execute` so that
the UI can decide whether it needs to update itself.
- Shows progress popup for the duration of the op.
- Ensures the browser doesn't try to redraw during the operation, which can lead
to a frozen UI
- Updates undo state at the end of the operation
- Commits changes
- Fires the `operation_(will|did)_reset` hooks
- Fires the legacy `state_did_reset` hook
Be careful not to call any UI routines in `op`, as that may crash Qt.
This includes things select .selectedCards() in the browse screen.
success() will be called with the return value of op().
If op() throws an exception, it will be shown in a popup, or
passed to failure() if it is provided.
after_hooks() will be called after hooks are fired, if it is provided.
Components can use this to ignore change notices generated by operations
they invoke themselves, or perform some subsequent action.
"""
self._increase_background_ops()
def wrapped_done(future: Future) -> None:
self._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if failure:
failure(exception)
else:
showWarning(str(exception))
return
else:
# BaseException like SystemExit; rethrow it
future.result()
result = future.result()
try:
if success:
success(result)
finally:
# update undo status
status = self.col.undo_status()
self._update_undo_actions_for_status_and_save(status)
# fire change hooks
self._fire_change_hooks_after_op_performed(result, after_hooks, meta)
self.taskman.with_progress(op, wrapped_done)
def _increase_background_ops(self) -> None: def _increase_background_ops(self) -> None:
if not self._background_op_count: if not self._background_op_count:
gui_hooks.backend_will_block() gui_hooks.backend_will_block()
@ -842,27 +746,6 @@ class AnkiQt(QMainWindow):
gui_hooks.backend_did_block() gui_hooks.backend_did_block()
assert self._background_op_count >= 0 assert self._background_op_count >= 0
def _fire_change_hooks_after_op_performed(
self,
result: ResultWithChanges,
after_hooks: Optional[Callable[[], None]],
meta: OpMeta,
) -> None:
if isinstance(result, OpChanges):
changes = result
else:
changes = result.changes
# fire new hook
print("op changes:")
print(changes)
gui_hooks.operation_did_execute(changes, meta)
# fire legacy hook so old code notices changes
if self.col.op_made_changes(changes):
gui_hooks.state_did_reset()
if after_hooks:
after_hooks()
def _synthesize_op_did_execute_from_reset(self) -> None: def _synthesize_op_did_execute_from_reset(self) -> None:
"""Fire the `operation_did_execute` hook with everything marked as changed, """Fire the `operation_did_execute` hook with everything marked as changed,
after legacy code has called .reset()""" after legacy code has called .reset()"""
@ -872,15 +755,17 @@ class AnkiQt(QMainWindow):
setattr(op, field.name, True) setattr(op, field.name, True)
gui_hooks.operation_did_execute(op, None) gui_hooks.operation_did_execute(op, None)
def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: def on_operation_did_execute(
self, changes: OpChanges, handler: Optional[object]
) -> None:
"Notify current screen of changes." "Notify current screen of changes."
focused = current_top_level_widget() == self focused = current_top_level_widget() == self
if self.state == "review": if self.state == "review":
dirty = self.reviewer.op_executed(changes, focused) dirty = self.reviewer.op_executed(changes, handler, focused)
elif self.state == "overview": elif self.state == "overview":
dirty = self.overview.op_executed(changes, focused) dirty = self.overview.op_executed(changes, handler, focused)
elif self.state == "deckBrowser": elif self.state == "deckBrowser":
dirty = self.deckBrowser.op_executed(changes, focused) dirty = self.deckBrowser.op_executed(changes, handler, focused)
else: else:
dirty = False dirty = False
@ -908,7 +793,7 @@ class AnkiQt(QMainWindow):
def reset(self, unused_arg: bool = False) -> None: def reset(self, unused_arg: bool = False) -> None:
"""Legacy method of telling UI to refresh after changes made to DB. """Legacy method of telling UI to refresh after changes made to DB.
New code should use mw.perform_op() instead.""" New code should use CollectionOp() instead."""
if self.col: if self.col:
# fire new `operation_did_execute` hook first. If the overview # fire new `operation_did_execute` hook first. If the overview
# or review screen are currently open, they will rebuild the study # or review screen are currently open, they will rebuild the study
@ -1218,7 +1103,7 @@ title="%s" %s>%s</button>""" % (
def undo(self) -> None: def undo(self) -> None:
"Call collection_ops.py:undo() directly instead." "Call collection_ops.py:undo() directly instead."
undo(mw=self, parent=self) undo(parent=self)
def update_undo_actions(self, status: Optional[UndoStatus] = None) -> None: def update_undo_actions(self, status: Optional[UndoStatus] = None) -> None:
"""Update menu text and enable/disable menu item as appropriate. """Update menu text and enable/disable menu item as appropriate.
@ -1541,8 +1426,11 @@ title="%s" %s>%s</button>""" % (
ret = StudyDeck(self, dyn=True, current=self.col.decks.current()["name"]) ret = StudyDeck(self, dyn=True, current=self.col.decks.current()["name"])
if ret.name: if ret.name:
self.col.decks.select(self.col.decks.id(ret.name)) # fixme: this is silly, it should be returning an ID
self.moveToState("overview") deck_id = self.col.decks.id(ret.name)
set_current_deck(parent=self, deck_id=deck_id).success(
lambda out: self.moveToState("overview")
).run_in_background()
def onEmptyCards(self) -> None: def onEmptyCards(self) -> None:
show_empty_cards(self) show_empty_cards(self)

View File

@ -1,16 +1,142 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from dataclasses import dataclass from __future__ import annotations
from typing import Optional
from concurrent.futures._base import Future
from typing import Any, Callable, Generic, Optional, Protocol, TypeVar, Union
import aqt
from anki.collection import (
Collection,
OpChanges,
OpChangesAfterUndo,
OpChangesWithCount,
OpChangesWithId,
)
from aqt.qt import QWidget
from aqt.utils import showWarning
@dataclass class HasChangesProperty(Protocol):
class OpMeta: changes: OpChanges
"""Metadata associated with an operation.
The `handler` field can be used by screens to ignore change
events they initiated themselves, if they have already made
the required changes."""
handler: Optional[object] = None # either an OpChanges object, or an object with .changes on it. This bound
# doesn't actually work for protobuf objects, so new protobuf objects will
# either need to be added here, or cast at call time
ResultWithChanges = TypeVar(
"ResultWithChanges",
bound=Union[
OpChanges,
OpChangesWithCount,
OpChangesWithId,
OpChangesAfterUndo,
HasChangesProperty,
],
)
class CollectionOp(Generic[ResultWithChanges]):
"""Helper to perform a mutating DB operation on a background thread, and update UI.
`op` should either return OpChanges, or an object with a 'changes'
property. The changes will be passed to `operation_did_execute` so that
the UI can decide whether it needs to update itself.
- Shows progress popup for the duration of the op.
- Ensures the browser doesn't try to redraw during the operation, which can lead
to a frozen UI
- Updates undo state at the end of the operation
- Commits changes
- Fires the `operation_(will|did)_reset` hooks
- Fires the legacy `state_did_reset` hook
Be careful not to call any UI routines in `op`, as that may crash Qt.
This includes things select .selectedCards() in the browse screen.
`success` will be called with the return value of op().
If op() throws an exception, it will be shown in a popup, or
passed to `failure` if it is provided.
"""
_success: Optional[Callable[[ResultWithChanges], Any]] = None
_failure: Optional[Optional[Callable[[Exception], Any]]] = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent
self._op = op
def success(
self, success: Optional[Callable[[ResultWithChanges], Any]]
) -> CollectionOp[ResultWithChanges]:
self._success = success
return self
def failure(
self, failure: Optional[Optional[Callable[[Exception], Any]]]
) -> CollectionOp[ResultWithChanges]:
self._failure = failure
return self
def run_in_background(self, *, initiator: Optional[object] = None) -> None:
from aqt import mw
assert mw
mw._increase_background_ops()
def wrapped_op() -> ResultWithChanges:
assert mw
return self._op(mw.col)
def wrapped_done(future: Future) -> None:
assert mw
mw._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if self._failure:
self._failure(exception)
else:
showWarning(str(exception), self._parent)
return
else:
# BaseException like SystemExit; rethrow it
future.result()
result = future.result()
try:
if self._success:
self._success(result)
finally:
# update undo status
status = mw.col.undo_status()
mw._update_undo_actions_for_status_and_save(status)
# fire change hooks
self._fire_change_hooks_after_op_performed(result, initiator)
mw.taskman.with_progress(wrapped_op, wrapped_done)
def _fire_change_hooks_after_op_performed(
self,
result: ResultWithChanges,
handler: Optional[object],
) -> None:
from aqt import mw
assert mw
if isinstance(result, OpChanges):
changes = result
else:
changes = result.changes
# fire new hook
print("op changes:")
print(changes)
aqt.gui_hooks.operation_did_execute(changes, handler)
# fire legacy hook so old code notices changes
if mw.col.op_made_changes(changes):
aqt.gui_hooks.state_did_reset()

View File

@ -6,13 +6,22 @@ from __future__ import annotations
from typing import Sequence from typing import Sequence
from anki.cards import CardId from anki.cards import CardId
from anki.collection import OpChanges
from anki.decks import DeckId from anki.decks import DeckId
from aqt import AnkiQt from aqt.operations import CollectionOp
from aqt.qt import QWidget
def set_card_deck(*, mw: AnkiQt, card_ids: Sequence[CardId], deck_id: DeckId) -> None: def set_card_deck(
mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id)) *, parent: QWidget, card_ids: Sequence[CardId], deck_id: DeckId
) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.set_deck(card_ids, deck_id))
def set_card_flag(*, mw: AnkiQt, card_ids: Sequence[CardId], flag: int) -> None: def set_card_flag(
mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids)) *,
parent: QWidget,
card_ids: Sequence[CardId],
flag: int,
) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.set_user_flag_for_cards(flag, card_ids))

View File

@ -3,34 +3,37 @@
from __future__ import annotations from __future__ import annotations
import aqt from anki.collection import LegacyCheckpoint, LegacyReviewUndo
from anki.collection import LegacyCheckpoint, LegacyReviewUndo, OpChangesAfterUndo
from anki.errors import UndoEmpty from anki.errors import UndoEmpty
from anki.types import assert_exhaustive from anki.types import assert_exhaustive
from aqt import gui_hooks from aqt import gui_hooks
from aqt.operations import CollectionOp
from aqt.qt import QWidget from aqt.qt import QWidget
from aqt.utils import showInfo, showWarning, tooltip, tr from aqt.utils import showInfo, showWarning, tooltip, tr
def undo(*, mw: aqt.AnkiQt, parent: QWidget) -> None: def undo(*, parent: QWidget) -> None:
"Undo the last operation, and refresh the UI." "Undo the last operation, and refresh the UI."
def on_success(out: OpChangesAfterUndo) -> None:
mw.update_undo_actions(out.new_status)
tooltip(tr.undo_action_undone(action=out.operation), parent=parent)
def on_failure(exc: Exception) -> None: def on_failure(exc: Exception) -> None:
if isinstance(exc, UndoEmpty): if isinstance(exc, UndoEmpty):
# backend has no undo, but there may be a checkpoint # backend has no undo, but there may be a checkpoint
# or v1/v2 review waiting # or v1/v2 review waiting
_legacy_undo(mw=mw, parent=parent) _legacy_undo(parent=parent)
else: else:
showWarning(str(exc), parent=parent) showWarning(str(exc), parent=parent)
mw.perform_op(mw.col.undo, success=on_success, failure=on_failure) CollectionOp(parent, lambda col: col.undo()).success(
lambda out: tooltip(tr.undo_action_undone(action=out.operation), parent=parent)
).failure(on_failure).run_in_background()
def _legacy_undo(*, mw: aqt.AnkiQt, parent: QWidget) -> None: def _legacy_undo(*, parent: QWidget) -> None:
from aqt import mw
assert mw
assert mw.col
reviewing = mw.state == "review" reviewing = mw.state == "review"
just_refresh_reviewer = False just_refresh_reviewer = False

View File

@ -3,85 +3,80 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Optional, Sequence from typing import Optional, Sequence
from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId
from anki.decks import DeckCollapseScope, DeckId from anki.decks import DeckCollapseScope, DeckId
from aqt import AnkiQt, QWidget from aqt import QWidget
from aqt.main import PerformOpOptionalSuccessCallback from aqt.operations import CollectionOp
from aqt.operations import OpMeta
from aqt.utils import getOnlyText, tooltip, tr from aqt.utils import getOnlyText, tooltip, tr
def remove_decks( def remove_decks(
*, *,
mw: AnkiQt,
parent: QWidget, parent: QWidget,
deck_ids: Sequence[DeckId], deck_ids: Sequence[DeckId],
) -> None: ) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(parent, lambda col: col.decks.remove(deck_ids)).success(
lambda: mw.col.decks.remove(deck_ids), lambda out: tooltip(tr.browsing_cards_deleted(count=out.count), parent=parent)
success=lambda out: tooltip(
tr.browsing_cards_deleted(count=out.count), parent=parent
),
) )
def reparent_decks( def reparent_decks(
*, mw: AnkiQt, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId *, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId
) -> None: ) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(
lambda: mw.col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent), parent, lambda col: col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent)
success=lambda out: tooltip( ).success(
lambda out: tooltip(
tr.browsing_reparented_decks(count=out.count), parent=parent tr.browsing_reparented_decks(count=out.count), parent=parent
), )
) )
def rename_deck( def rename_deck(
*, *,
mw: AnkiQt, parent: QWidget,
deck_id: DeckId, deck_id: DeckId,
new_name: str, new_name: str,
after_rename: Callable[[], None] = None, ) -> CollectionOp[OpChanges]:
) -> None: return CollectionOp(
mw.perform_op( parent,
lambda: mw.col.decks.rename(deck_id, new_name), after_hooks=after_rename lambda col: col.decks.rename(deck_id, new_name),
) )
def add_deck_dialog( def add_deck_dialog(
*, *,
mw: AnkiQt,
parent: QWidget, parent: QWidget,
default_text: str = "", default_text: str = "",
success: PerformOpOptionalSuccessCallback = None, ) -> Optional[CollectionOp[OpChangesWithId]]:
) -> None:
if name := getOnlyText( if name := getOnlyText(
tr.decks_new_deck_name(), default=default_text, parent=parent tr.decks_new_deck_name(), default=default_text, parent=parent
).strip(): ).strip():
add_deck(mw=mw, name=name, success=success) return add_deck(parent=parent, name=name)
else:
return None
def add_deck( def add_deck(*, parent: QWidget, name: str) -> CollectionOp[OpChangesWithId]:
*, mw: AnkiQt, name: str, success: PerformOpOptionalSuccessCallback = None return CollectionOp(parent, lambda col: col.decks.add_normal_deck_with_name(name))
) -> None:
mw.perform_op(
lambda: mw.col.decks.add_normal_deck_with_name(name),
success=success,
)
def set_deck_collapsed( def set_deck_collapsed(
*, *,
mw: AnkiQt, parent: QWidget,
deck_id: DeckId, deck_id: DeckId,
collapsed: bool, collapsed: bool,
scope: DeckCollapseScope.V, scope: DeckCollapseScope.V,
handler: Optional[object] = None, ) -> CollectionOp[OpChanges]:
) -> None: return CollectionOp(
mw.perform_op( parent,
lambda: mw.col.decks.set_collapsed( lambda col: col.decks.set_collapsed(
deck_id=deck_id, collapsed=collapsed, scope=scope deck_id=deck_id, collapsed=collapsed, scope=scope
), ),
meta=OpMeta(handler=handler),
) )
def set_current_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.decks.set_current(deck_id))

View File

@ -5,34 +5,60 @@ from __future__ import annotations
from typing import Optional, Sequence from typing import Optional, Sequence
from anki.collection import OpChanges, OpChangesWithCount
from anki.decks import DeckId from anki.decks import DeckId
from anki.notes import Note, NoteId from anki.notes import Note, NoteId
from aqt import AnkiQt from aqt.operations import CollectionOp
from aqt.main import PerformOpOptionalSuccessCallback from aqt.qt import QWidget
from aqt.operations import OpMeta from aqt.utils import tooltip, tr
def add_note( def add_note(
*, *,
mw: AnkiQt, parent: QWidget,
note: Note, note: Note,
target_deck_id: DeckId, target_deck_id: DeckId,
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChanges]:
) -> None: return CollectionOp(parent, lambda col: col.add_note(note, target_deck_id))
mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success)
def update_note(*, mw: AnkiQt, note: Note, handler: Optional[object]) -> None: def update_note(*, parent: QWidget, note: Note) -> CollectionOp[OpChanges]:
mw.perform_op( return CollectionOp(parent, lambda col: col.update_note(note))
lambda: mw.col.update_note(note),
meta=OpMeta(handler=handler),
)
def remove_notes( def remove_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_ids: Sequence[NoteId], note_ids: Sequence[NoteId],
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success(
mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)),
)
def find_and_replace(
*,
parent: QWidget,
note_ids: Sequence[NoteId],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent,
lambda col: col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
).success(
lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
)
)

View File

@ -7,28 +7,33 @@ from typing import Optional, Sequence
import aqt import aqt
from anki.cards import CardId from anki.cards import CardId
from anki.collection import CARD_TYPE_NEW, Config from anki.collection import (
CARD_TYPE_NEW,
Config,
OpChanges,
OpChangesWithCount,
OpChangesWithId,
)
from anki.decks import DeckId from anki.decks import DeckId
from anki.notes import NoteId from anki.notes import NoteId
from anki.scheduler import FilteredDeckForUpdate from anki.scheduler import FilteredDeckForUpdate
from aqt import AnkiQt from aqt.operations import CollectionOp
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, getText, tooltip, tr from aqt.utils import disable_help_button, getText, tooltip, tr
def set_due_date_dialog( def set_due_date_dialog(
*, *,
mw: aqt.AnkiQt,
parent: QWidget, parent: QWidget,
card_ids: Sequence[CardId], card_ids: Sequence[CardId],
config_key: Optional[Config.String.Key.V], config_key: Optional[Config.String.Key.V],
) -> None: ) -> Optional[CollectionOp[OpChanges]]:
assert aqt.mw
if not card_ids: if not card_ids:
return return None
default_text = ( default_text = (
mw.col.get_config_string(config_key) if config_key is not None else "" aqt.mw.col.get_config_string(config_key) if config_key is not None else ""
) )
prompt = "\n".join( prompt = "\n".join(
[ [
@ -43,35 +48,38 @@ def set_due_date_dialog(
title=tr.actions_set_due_date(), title=tr.actions_set_due_date(),
) )
if not success or not days.strip(): if not success or not days.strip():
return return None
else:
mw.perform_op( return CollectionOp(
lambda: mw.col.sched.set_due_date(card_ids, days, config_key), parent, lambda col: col.sched.set_due_date(card_ids, days, config_key)
success=lambda _: tooltip( ).success(
lambda _: tooltip(
tr.scheduling_set_due_date_done(cards=len(card_ids)), tr.scheduling_set_due_date_done(cards=len(card_ids)),
parent=parent, parent=parent,
), )
) )
def forget_cards( def forget_cards(
*, mw: aqt.AnkiQt, parent: QWidget, card_ids: Sequence[CardId] *, parent: QWidget, card_ids: Sequence[CardId]
) -> None: ) -> CollectionOp[OpChanges]:
if not card_ids: return CollectionOp(
return parent, lambda col: col.sched.schedule_cards_as_new(card_ids)
).success(
mw.perform_op( lambda _: tooltip(
lambda: mw.col.sched.schedule_cards_as_new(card_ids),
success=lambda _: tooltip(
tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent
), )
) )
def reposition_new_cards_dialog( def reposition_new_cards_dialog(
*, mw: AnkiQt, parent: QWidget, card_ids: Sequence[CardId] *, parent: QWidget, card_ids: Sequence[CardId]
) -> None: ) -> Optional[CollectionOp[OpChangesWithCount]]:
from aqt import mw
assert mw
assert mw.col.db assert mw.col.db
row = mw.col.db.first( row = mw.col.db.first(
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0" f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
) )
@ -92,15 +100,14 @@ def reposition_new_cards_dialog(
frm.start.selectAll() frm.start.selectAll()
if not d.exec_(): if not d.exec_():
return return None
start = frm.start.value() start = frm.start.value()
step = frm.step.value() step = frm.step.value()
randomize = frm.randomize.isChecked() randomize = frm.randomize.isChecked()
shift = frm.shift.isChecked() shift = frm.shift.isChecked()
reposition_new_cards( return reposition_new_cards(
mw=mw,
parent=parent, parent=parent,
card_ids=card_ids, card_ids=card_ids,
starting_from=start, starting_from=start,
@ -112,89 +119,80 @@ def reposition_new_cards_dialog(
def reposition_new_cards( def reposition_new_cards(
*, *,
mw: AnkiQt,
parent: QWidget, parent: QWidget,
card_ids: Sequence[CardId], card_ids: Sequence[CardId],
starting_from: int, starting_from: int,
step_size: int, step_size: int,
randomize: bool, randomize: bool,
shift_existing: bool, shift_existing: bool,
) -> None: ) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(
lambda: mw.col.sched.reposition_new_cards( parent,
lambda col: col.sched.reposition_new_cards(
card_ids=card_ids, card_ids=card_ids,
starting_from=starting_from, starting_from=starting_from,
step_size=step_size, step_size=step_size,
randomize=randomize, randomize=randomize,
shift_existing=shift_existing, shift_existing=shift_existing,
), ),
success=lambda out: tooltip( ).success(
lambda out: tooltip(
tr.browsing_changed_new_position(count=out.count), parent=parent tr.browsing_changed_new_position(count=out.count), parent=parent
), )
) )
def suspend_cards( def suspend_cards(
*, *,
mw: AnkiQt, parent: QWidget,
card_ids: Sequence[CardId], card_ids: Sequence[CardId],
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(parent, lambda col: col.sched.suspend_cards(card_ids))
mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success)
def suspend_note( def suspend_note(
*, *,
mw: AnkiQt, parent: QWidget,
note_id: NoteId, note_ids: Sequence[NoteId],
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(parent, lambda col: col.sched.suspend_notes(note_ids))
mw.taskman.run_in_background(
lambda: mw.col.card_ids_of_note(note_id),
lambda future: suspend_cards(mw=mw, card_ids=future.result(), success=success),
)
def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[CardId]) -> None: def unsuspend_cards(
mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids)) *, parent: QWidget, card_ids: Sequence[CardId]
) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.sched.unsuspend_cards(card_ids))
def bury_cards( def bury_cards(
*, *,
mw: AnkiQt, parent: QWidget,
card_ids: Sequence[CardId], card_ids: Sequence[CardId],
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(parent, lambda col: col.sched.bury_cards(card_ids))
mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success)
def bury_note( def bury_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_id: NoteId, note_ids: Sequence[NoteId],
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(parent, lambda col: col.sched.bury_notes(note_ids))
mw.taskman.run_in_background(
lambda: mw.col.card_ids_of_note(note_id),
lambda future: bury_cards(mw=mw, card_ids=future.result(), success=success),
)
def rebuild_filtered_deck(*, mw: AnkiQt, deck_id: DeckId) -> None: def rebuild_filtered_deck(
mw.perform_op(lambda: mw.col.sched.rebuild_filtered_deck(deck_id)) *, parent: QWidget, deck_id: DeckId
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.sched.rebuild_filtered_deck(deck_id))
def empty_filtered_deck(*, mw: AnkiQt, deck_id: DeckId) -> None: def empty_filtered_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]:
mw.perform_op(lambda: mw.col.sched.empty_filtered_deck(deck_id)) return CollectionOp(parent, lambda col: col.sched.empty_filtered_deck(deck_id))
def add_or_update_filtered_deck( def add_or_update_filtered_deck(
*, *,
mw: AnkiQt, parent: QWidget,
deck: FilteredDeckForUpdate, deck: FilteredDeckForUpdate,
success: PerformOpOptionalSuccessCallback, ) -> CollectionOp[OpChangesWithId]:
) -> None: return CollectionOp(parent, lambda col: col.sched.add_or_update_filtered_deck(deck))
mw.perform_op(
lambda: mw.col.sched.add_or_update_filtered_deck(deck),
success=success,
)

View File

@ -3,90 +3,116 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, Sequence from typing import Sequence
from anki.collection import OpChangesWithCount from anki.collection import OpChanges, OpChangesWithCount
from anki.notes import NoteId from anki.notes import NoteId
from aqt import AnkiQt, QWidget from aqt import QWidget
from aqt.main import PerformOpOptionalSuccessCallback from aqt.operations import CollectionOp
from aqt.utils import showInfo, tooltip, tr from aqt.utils import showInfo, tooltip, tr
def add_tags_to_notes( def add_tags_to_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_ids: Sequence[NoteId], note_ids: Sequence[NoteId],
space_separated_tags: str, space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(
mw.perform_op( parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags)
lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success ).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
) )
def remove_tags_from_notes( def remove_tags_from_notes(
*, *,
mw: AnkiQt, parent: QWidget,
note_ids: Sequence[NoteId], note_ids: Sequence[NoteId],
space_separated_tags: str, space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None, ) -> CollectionOp[OpChangesWithCount]:
) -> None: return CollectionOp(
mw.perform_op( parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags)
lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success ).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
) )
def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: def clear_unused_tags(*, parent: QWidget) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success(
mw.col.tags.clear_unused_tags, lambda out: tooltip(
success=lambda out: tooltip(
tr.browsing_removed_unused_tags_count(count=out.count), parent=parent tr.browsing_removed_unused_tags_count(count=out.count), parent=parent
), )
) )
def rename_tag( def rename_tag(
*, *,
mw: AnkiQt,
parent: QWidget, parent: QWidget,
current_name: str, current_name: str,
new_name: str, new_name: str,
after_rename: Callable[[], None], ) -> CollectionOp[OpChangesWithCount]:
) -> None:
def success(out: OpChangesWithCount) -> None: def success(out: OpChangesWithCount) -> None:
if out.count: if out.count:
tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
else: else:
showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent) showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent)
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.rename(old=current_name, new=new_name), parent,
success=success, lambda col: col.tags.rename(old=current_name, new=new_name),
after_hooks=after_rename, ).success(success)
)
def remove_tags_from_all_notes( def remove_tags_from_all_notes(
*, mw: AnkiQt, parent: QWidget, space_separated_tags: str *, parent: QWidget, space_separated_tags: str
) -> None: ) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags)
success=lambda out: tooltip( ).success(
tr.browsing_notes_updated(count=out.count), parent=parent lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
),
) )
def reparent_tags( def reparent_tags(
*, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str *, parent: QWidget, tags: Sequence[str], new_parent: str
) -> None: ) -> CollectionOp[OpChangesWithCount]:
mw.perform_op( return CollectionOp(
lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent), parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent)
success=lambda out: tooltip( ).success(
tr.browsing_notes_updated(count=out.count), parent=parent lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
),
) )
def set_tag_collapsed(*, mw: AnkiQt, tag: str, collapsed: bool) -> None: def set_tag_collapsed(
mw.perform_op(lambda: mw.col.tags.set_collapsed(tag=tag, collapsed=collapsed)) *, parent: QWidget, tag: str, collapsed: bool
) -> CollectionOp[OpChanges]:
return CollectionOp(
parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed)
)
def find_and_replace_tag(
*,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent,
lambda col: col.tags.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
),
).success(
lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)

View File

@ -64,8 +64,10 @@ class Overview:
if self._refresh_needed: if self._refresh_needed:
self.refresh() self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool: def op_executed(
if self.mw.col.op_affects_study_queue(changes): self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if changes.study_queues:
self._refresh_needed = True self._refresh_needed = True
if focused: if focused:
@ -117,12 +119,14 @@ class Overview:
return self.mw.col.decks.current()["dyn"] return self.mw.col.decks.current()["dyn"]
def rebuild_current_filtered_deck(self) -> None: def rebuild_current_filtered_deck(self) -> None:
if self._current_deck_is_filtered(): rebuild_filtered_deck(
rebuild_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) parent=self.mw, deck_id=self.mw.col.decks.selected()
).run_in_background()
def empty_current_filtered_deck(self) -> None: def empty_current_filtered_deck(self) -> None:
if self._current_deck_is_filtered(): empty_filtered_deck(
empty_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) parent=self.mw, deck_id=self.mw.col.decks.selected()
).run_in_background()
def onCustomStudyKey(self) -> None: def onCustomStudyKey(self) -> None:
if not self._current_deck_is_filtered(): if not self._current_deck_is_filtered():

View File

@ -14,7 +14,7 @@ from PyQt5.QtCore import Qt
from anki import hooks from anki import hooks
from anki.cards import Card, CardId from anki.cards import Card, CardId
from anki.collection import Config, OpChanges from anki.collection import Config, OpChanges, OpChangesWithCount
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import stripHTML from anki.utils import stripHTML
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -22,7 +22,7 @@ from aqt.operations.card import set_card_flag
from aqt.operations.note import remove_notes from aqt.operations.note import remove_notes
from aqt.operations.scheduling import ( from aqt.operations.scheduling import (
bury_cards, bury_cards,
bury_note, bury_notes,
set_due_date_dialog, set_due_date_dialog,
suspend_cards, suspend_cards,
suspend_note, suspend_note,
@ -38,7 +38,6 @@ from aqt.webview import AnkiWebView
class RefreshNeeded(Enum): class RefreshNeeded(Enum):
NO = auto()
NOTE_TEXT = auto() NOTE_TEXT = auto()
QUEUES = auto() QUEUES = auto()
@ -71,7 +70,7 @@ class Reviewer:
self._recordedAudio: Optional[str] = None self._recordedAudio: Optional[str] = None
self.typeCorrect: str = None # web init happens before this is set self.typeCorrect: str = None # web init happens before this is set
self.state: Optional[str] = None self.state: Optional[str] = None
self._refresh_needed = RefreshNeeded.NO self._refresh_needed: Optional[RefreshNeeded] = None
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech) hooks.card_did_leech.append(self.onLeech)
@ -102,29 +101,25 @@ class Reviewer:
self.mw.col.reset() self.mw.col.reset()
self.nextCard() self.nextCard()
self.mw.fade_in_webview() self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO self._refresh_needed = None
elif self._refresh_needed is RefreshNeeded.NOTE_TEXT: elif self._refresh_needed is RefreshNeeded.NOTE_TEXT:
self._redraw_current_card() self._redraw_current_card()
self.mw.fade_in_webview() self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO self._refresh_needed = None
def op_executed(self, changes: OpChanges, focused: bool) -> bool: def op_executed(
if changes.note and changes.kind == OpChanges.UPDATE_NOTE_TAGS: self, changes: OpChanges, handler: Optional[object], focused: bool
self.card.load() ) -> bool:
self._update_mark_icon() if handler is not self:
elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG: if changes.study_queues:
# fixme: v3 mtime check
self.card.load()
self._update_flag_icon()
elif self.mw.col.op_affects_study_queue(changes):
self._refresh_needed = RefreshNeeded.QUEUES self._refresh_needed = RefreshNeeded.QUEUES
elif changes.note or changes.notetype or changes.tag: elif changes.editor:
self._refresh_needed = RefreshNeeded.NOTE_TEXT self._refresh_needed = RefreshNeeded.NOTE_TEXT
if focused and self._refresh_needed is not RefreshNeeded.NO: if focused and self._refresh_needed:
self.refresh_if_needed() self.refresh_if_needed()
return self._refresh_needed is not RefreshNeeded.NO return bool(self._refresh_needed)
def _redraw_current_card(self) -> None: def _redraw_current_card(self) -> None:
self.card.load() self.card.load()
@ -830,63 +825,70 @@ time = %(time)d;
self.mw.onDeckConf(self.mw.col.decks.get(self.card.current_deck_id())) self.mw.onDeckConf(self.mw.col.decks.get(self.card.current_deck_id()))
def set_flag_on_current_card(self, desired_flag: int) -> None: def set_flag_on_current_card(self, desired_flag: int) -> None:
def redraw_flag(out: OpChanges) -> None:
self.card.load()
self._update_flag_icon()
# need to toggle off? # need to toggle off?
if self.card.user_flag() == desired_flag: if self.card.user_flag() == desired_flag:
flag = 0 flag = 0
else: else:
flag = desired_flag flag = desired_flag
set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag) set_card_flag(parent=self.mw, card_ids=[self.card.id], flag=flag).success(
redraw_flag
).run_in_background(initiator=self)
def toggle_mark_on_current_note(self) -> None: def toggle_mark_on_current_note(self) -> None:
def redraw_mark(out: OpChangesWithCount) -> None:
self.card.load()
self._update_mark_icon()
note = self.card.note() note = self.card.note()
if note.has_tag(MARKED_TAG): if note.has_tag(MARKED_TAG):
remove_tags_from_notes( remove_tags_from_notes(
mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG
) ).success(redraw_mark).run_in_background(initiator=self)
else: else:
add_tags_to_notes( add_tags_to_notes(
mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG parent=self.mw,
) note_ids=[note.id],
space_separated_tags=MARKED_TAG,
).success(redraw_mark).run_in_background(initiator=self)
def on_set_due(self) -> None: def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
return return
set_due_date_dialog( set_due_date_dialog(
mw=self.mw,
parent=self.mw, parent=self.mw,
card_ids=[self.card.id], card_ids=[self.card.id],
config_key=Config.String.SET_DUE_REVIEWER, config_key=Config.String.SET_DUE_REVIEWER,
) ).run_in_background()
def suspend_current_note(self) -> None: def suspend_current_note(self) -> None:
suspend_note( suspend_note(
mw=self.mw, parent=self.mw,
note_id=self.card.nid, note_ids=[self.card.nid],
success=lambda _: tooltip(tr.studying_note_suspended()), ).success(lambda _: tooltip(tr.studying_note_suspended())).run_in_background()
)
def suspend_current_card(self) -> None: def suspend_current_card(self) -> None:
suspend_cards( suspend_cards(
mw=self.mw, parent=self.mw,
card_ids=[self.card.id], card_ids=[self.card.id],
success=lambda _: tooltip(tr.studying_card_suspended()), ).success(lambda _: tooltip(tr.studying_card_suspended())).run_in_background()
)
def bury_current_note(self) -> None: def bury_current_note(self) -> None:
bury_note( bury_notes(
mw=self.mw, parent=self.mw,
note_id=self.card.nid, note_ids=[self.card.nid],
success=lambda _: tooltip(tr.studying_note_buried()), ).success(lambda _: tooltip(tr.studying_note_buried())).run_in_background()
)
def bury_current_card(self) -> None: def bury_current_card(self) -> None:
bury_cards( bury_cards(
mw=self.mw, parent=self.mw,
card_ids=[self.card.id], card_ids=[self.card.id],
success=lambda _: tooltip(tr.studying_card_buried()), ).success(lambda _: tooltip(tr.studying_card_buried())).run_in_background()
)
def delete_current_note(self) -> None: def delete_current_note(self) -> None:
# need to check state because the shortcut is global to the main # need to check state because the shortcut is global to the main
@ -894,14 +896,7 @@ time = %(time)d;
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
return return
# fixme: pass this back from the backend method instead remove_notes(parent=self.mw, note_ids=[self.card.nid]).run_in_background()
cnt = len(self.card.note().cards())
remove_notes(
mw=self.mw,
note_ids=[self.card.nid],
success=lambda _: tooltip(tr.studying_note_and_its_card_deleted(count=cnt)),
)
def onRecordVoice(self) -> None: def onRecordVoice(self) -> None:
def after_record(path: str) -> None: def after_record(path: str) -> None:

View File

@ -16,7 +16,6 @@ from anki.types import assert_exhaustive
from aqt import colors, gui_hooks from aqt import colors, gui_hooks
from aqt.clayout import CardLayout from aqt.clayout import CardLayout
from aqt.models import Models from aqt.models import Models
from aqt.operations import OpMeta
from aqt.operations.deck import ( from aqt.operations.deck import (
remove_decks, remove_decks,
rename_deck, rename_deck,
@ -173,6 +172,18 @@ class SidebarItem:
) )
return self._search_matches_self or self._search_matches_child return self._search_matches_self or self._search_matches_child
def has_same_id(self, other: SidebarItem) -> bool:
"True if `other` is same type, with same id/name."
if other.item_type == self.item_type:
if self.item_type == SidebarItemType.TAG:
return self.full_name == other.full_name
elif self.item_type == SidebarItemType.SAVED_SEARCH:
return self.name == other.name
else:
return other.id == self.id
return False
class SidebarModel(QAbstractItemModel): class SidebarModel(QAbstractItemModel):
def __init__(self, sidebar: SidebarTreeView, root: SidebarItem) -> None: def __init__(self, sidebar: SidebarTreeView, root: SidebarItem) -> None:
@ -420,8 +431,10 @@ class SidebarTreeView(QTreeView):
# Refreshing # Refreshing
########################### ###########################
def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None: def op_executed(
if changes.browser_sidebar and not meta.handler is self: self, changes: OpChanges, handler: Optional[object], focused: bool
) -> None:
if changes.browser_sidebar and not handler is self:
self._refresh_needed = True self._refresh_needed = True
if focused: if focused:
self.refresh_if_needed() self.refresh_if_needed()
@ -431,13 +444,16 @@ class SidebarTreeView(QTreeView):
self.refresh() self.refresh()
self._refresh_needed = False self._refresh_needed = False
def refresh( def refresh(self) -> None:
self, is_current: Optional[Callable[[SidebarItem], bool]] = None
) -> None:
"Refresh list. No-op if sidebar is not visible." "Refresh list. No-op if sidebar is not visible."
if not self.isVisible(): if not self.isVisible():
return return
if self.model() and (idx := self.currentIndex()):
current_item = self.model().item_for_index(idx)
else:
current_item = None
def on_done(root: SidebarItem) -> None: def on_done(root: SidebarItem) -> None:
# user may have closed browser # user may have closed browser
if sip.isdeleted(self): if sip.isdeleted(self):
@ -453,8 +469,8 @@ class SidebarTreeView(QTreeView):
self.search_for(self.current_search) self.search_for(self.current_search)
else: else:
self._expand_where_necessary(model) self._expand_where_necessary(model)
if is_current: if current_item:
self.restore_current(is_current) self.restore_current(current_item)
self.setUpdatesEnabled(True) self.setUpdatesEnabled(True)
@ -463,8 +479,8 @@ class SidebarTreeView(QTreeView):
self.mw.query_op(self._root_tree, success=on_done) self.mw.query_op(self._root_tree, success=on_done)
def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None: def restore_current(self, current: SidebarItem) -> None:
if current := self.find_item(is_current): if current := self.find_item(current.has_same_id):
index = self.model().index_for_item(current) index = self.model().index_for_item(current)
self.selectionModel().setCurrentIndex( self.selectionModel().setCurrentIndex(
index, QItemSelectionModel.SelectCurrent index, QItemSelectionModel.SelectCurrent
@ -615,8 +631,8 @@ class SidebarTreeView(QTreeView):
new_parent = DeckId(target.id) new_parent = DeckId(target.id)
reparent_decks( reparent_decks(
mw=self.mw, parent=self.browser, deck_ids=deck_ids, new_parent=new_parent parent=self.browser, deck_ids=deck_ids, new_parent=new_parent
) ).run_in_background()
return True return True
@ -636,7 +652,9 @@ class SidebarTreeView(QTreeView):
else: else:
new_parent = target.full_name new_parent = target.full_name
reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent) reparent_tags(
parent=self.browser, tags=tags, new_parent=new_parent
).run_in_background()
return True return True
@ -931,8 +949,8 @@ class SidebarTreeView(QTreeView):
def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]: def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]:
full_name = head + node.name full_name = head + node.name
return lambda expanded: set_tag_collapsed( return lambda expanded: set_tag_collapsed(
mw=self.mw, tag=full_name, collapsed=not expanded parent=self, tag=full_name, collapsed=not expanded
) ).run_in_background()
for node in nodes: for node in nodes:
item = SidebarItem( item = SidebarItem(
@ -977,11 +995,12 @@ class SidebarTreeView(QTreeView):
) -> None: ) -> None:
def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]: def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]:
return lambda expanded: set_deck_collapsed( return lambda expanded: set_deck_collapsed(
mw=self.mw, parent=self,
deck_id=DeckId(node.deck_id), deck_id=DeckId(node.deck_id),
collapsed=not expanded, collapsed=not expanded,
scope=DeckCollapseScope.BROWSER, scope=DeckCollapseScope.BROWSER,
handler=self, ).run_in_background(
initiator=self,
) )
for node in nodes: for node in nodes:
@ -1164,27 +1183,27 @@ class SidebarTreeView(QTreeView):
def rename_deck(self, item: SidebarItem, new_name: str) -> None: def rename_deck(self, item: SidebarItem, new_name: str) -> None:
if not new_name: if not new_name:
return return
new_name = item.name_prefix + new_name
# update UI immediately, to avoid redraw
item.name = new_name
full_name = item.name_prefix + new_name
deck_id = DeckId(item.id) deck_id = DeckId(item.id)
def after_fetch(deck: Deck) -> None: def after_fetch(deck: Deck) -> None:
if new_name == deck.name: if full_name == deck.name:
return return
rename_deck( rename_deck(
mw=self.mw, parent=self,
deck_id=deck_id, deck_id=deck_id,
new_name=new_name, new_name=full_name,
after_rename=lambda: self.refresh( ).run_in_background()
lambda other: other.item_type == SidebarItemType.DECK
and other.id == item.id
),
)
self.mw.query_op(lambda: self.mw.col.get_deck(deck_id), success=after_fetch) self.mw.query_op(lambda: self.mw.col.get_deck(deck_id), success=after_fetch)
def delete_decks(self, _item: SidebarItem) -> None: def delete_decks(self, _item: SidebarItem) -> None:
remove_decks(mw=self.mw, parent=self.browser, deck_ids=self._selected_decks()) remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background()
# Tags # Tags
########################### ###########################
@ -1194,8 +1213,8 @@ class SidebarTreeView(QTreeView):
item.name = "..." item.name = "..."
remove_tags_from_all_notes( remove_tags_from_all_notes(
mw=self.mw, parent=self.browser, space_separated_tags=tags parent=self.browser, space_separated_tags=tags
) ).run_in_background()
def rename_tag(self, item: SidebarItem, new_name: str) -> None: def rename_tag(self, item: SidebarItem, new_name: str) -> None:
if not new_name or new_name == item.name: if not new_name or new_name == item.name:
@ -1207,17 +1226,13 @@ class SidebarTreeView(QTreeView):
new_name = item.name_prefix + new_name new_name = item.name_prefix + new_name
item.name = new_name_base item.name = new_name_base
item.full_name = new_name
rename_tag( rename_tag(
mw=self.mw,
parent=self.browser, parent=self.browser,
current_name=old_name, current_name=old_name,
new_name=new_name, new_name=new_name,
after_rename=lambda: self.refresh( ).run_in_background()
lambda item: item.item_type == SidebarItemType.TAG
and item.full_name == new_name
),
)
# Saved searches # Saved searches
#################################### ####################################
@ -1249,10 +1264,7 @@ class SidebarTreeView(QTreeView):
return return
conf[name] = search conf[name] = search
self._set_saved_searches(conf) self._set_saved_searches(conf)
self.refresh( self.refresh()
lambda item: item.item_type == SidebarItemType.SAVED_SEARCH
and item.name == name
)
def remove_saved_searches(self, _item: SidebarItem) -> None: def remove_saved_searches(self, _item: SidebarItem) -> None:
selected = self._selected_saved_searches() selected = self._selected_saved_searches()
@ -1276,10 +1288,8 @@ class SidebarTreeView(QTreeView):
conf[new_name] = filt conf[new_name] = filt
del conf[old_name] del conf[old_name]
self._set_saved_searches(conf) self._set_saved_searches(conf)
self.refresh( item.name = new_name
lambda item: item.item_type == SidebarItemType.SAVED_SEARCH self.refresh()
and item.name == new_name
)
def save_current_search(self) -> None: def save_current_search(self) -> None:
if (search := self._get_current_search()) is None: if (search := self._get_current_search()) is None:

View File

@ -175,4 +175,6 @@ class StudyDeck(QDialog):
QDialog.accept(self) QDialog.accept(self)
add_deck_dialog(mw=self.mw, parent=self, default_text=default, success=success) add_deck_dialog(parent=self, default_text=default).success(
success
).run_in_background()

View File

@ -29,7 +29,6 @@ from anki.errors import NotFoundError
from anki.notes import Note, NoteId from anki.notes import Note, NoteId
from anki.utils import ids2str, isWin from anki.utils import ids2str, isWin
from aqt import colors, gui_hooks from aqt import colors, gui_hooks
from aqt.operations import OpMeta
from aqt.qt import * from aqt.qt import *
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
@ -180,7 +179,9 @@ class Table:
def redraw_cells(self) -> None: def redraw_cells(self) -> None:
self._model.redraw_cells() self._model.redraw_cells()
def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None: def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> None:
if changes.browser_table: if changes.browser_table:
self._model.mark_cache_stale() self._model.mark_cache_stale()
if focused: if focused:

View File

@ -4,7 +4,7 @@
""" """
Helper for running tasks on background threads. Helper for running tasks on background threads.
See mw.query_op() and mw.perform_op() for slightly higher-level routines. See mw.query_op() and CollectionOp() for higher-level routines.
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -41,7 +41,7 @@ from anki.sound import AVTag, TTSTag
from anki.utils import checksum, isWin, tmpdir from anki.utils import checksum, isWin, tmpdir
from aqt import gui_hooks from aqt import gui_hooks
from aqt.sound import OnDoneCallback, SimpleProcessPlayer from aqt.sound import OnDoneCallback, SimpleProcessPlayer
from aqt.utils import tooltip from aqt.utils import tooltip, tr
@dataclass @dataclass
@ -569,10 +569,7 @@ if isWin:
try: try:
ret.result() ret.result()
except RuntimeError: except RuntimeError:
# fixme: i18n if this turns out to happen frequently tooltip(tr.errors_windows_tts_runtime_error())
tooltip(
"TTS failed to play. Please check available languages in system settings."
)
return return
# inject file into the top of the audio queue # inject file into the top of the audio queue

View File

@ -9,17 +9,7 @@ check_untyped_defs = true
disallow_untyped_defs = True disallow_untyped_defs = True
strict_equality = true strict_equality = true
[mypy-aqt.scheduling_ops] [mypy-aqt.operations.*]
no_strict_optional = false
[mypy-aqt.note_ops]
no_strict_optional = false
[mypy-aqt.card_ops]
no_strict_optional = false
[mypy-aqt.deck_ops]
no_strict_optional = false
[mypy-aqt.find_and_replace]
no_strict_optional = false
[mypy-aqt.tag_ops]
no_strict_optional = false no_strict_optional = false
[mypy-aqt.winpaths] [mypy-aqt.winpaths]

View File

@ -26,7 +26,6 @@ from anki.hooks import runFilter, runHook
from anki.models import NotetypeDict from anki.models import NotetypeDict
from aqt.qt import QDialog, QEvent, QMenu, QWidget from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit from aqt.tagedit import TagEdit
import aqt.operations
""" """
# Hook list # Hook list
@ -464,14 +463,14 @@ hooks = [
Hook( Hook(
name="state_did_reset", name="state_did_reset",
legacy_hook="reset", legacy_hook="reset",
doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI. doc="""Legacy 'reset' hook. Called by mw.reset() and CollectionOp() to redraw the UI.
New code should use `operation_did_execute` instead. New code should use `operation_did_execute` instead.
""", """,
), ),
Hook( Hook(
name="operation_did_execute", name="operation_did_execute",
args=["changes: anki.collection.OpChanges", "meta: aqt.operations.OpMeta"], args=["changes: anki.collection.OpChanges", "handler: Optional[object]"],
doc="""Called after an operation completes. doc="""Called after an operation completes.
Changes can be inspected to determine whether the UI needs updating. Changes can be inspected to determine whether the UI needs updating.
@ -489,7 +488,7 @@ hooks = [
), ),
Hook( Hook(
name="backend_will_block", name="backend_will_block",
doc="""Called before one or more operations are executed with mw.perform_op(). doc="""Called before one or more DB tasks are run in the background.
Subscribers can use this to set a flag to avoid DB queries until the operation Subscribers can use this to set a flag to avoid DB queries until the operation
completes, as doing so will freeze the UI until the long-running operation completes, as doing so will freeze the UI until the long-running operation
@ -498,7 +497,7 @@ hooks = [
), ),
Hook( Hook(
name="backend_did_block", name="backend_did_block",
doc="""Called after one or more operations are executed with mw.perform_op(). doc="""Called after one or more DB tasks finish running in the background.
Called regardless of the success of individual operations, and only called when Called regardless of the success of individual operations, and only called when
there are no outstanding ops. there are no outstanding ops.
""", """,

View File

@ -33,11 +33,11 @@ def register_repos():
maybe( maybe(
http_archive, http_archive,
name = "rules_rust", name = "rules_rust",
strip_prefix = "rules_rust-anki-2021-03-30", strip_prefix = "rules_rust-anki-2021-04-09",
urls = [ urls = [
"https://github.com/ankitects/rules_rust/archive/anki-2021-03-30.tar.gz", "https://github.com/ankitects/rules_rust/archive/anki-2021-04-09.tar.gz",
], ],
sha256 = "ad6286615fd21f71db4490207aa8d5ecdf5f526643cd65d682458d92aa84ff85", sha256 = "2821b22e065c1b4dc73610b1d6ccbed7ed4d755b316e7e0641cd079b7abe4900",
) )
# python # python
@ -87,40 +87,8 @@ def register_repos():
http_archive( http_archive(
name = "build_bazel_rules_nodejs", name = "build_bazel_rules_nodejs",
sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8", sha256 = "f533eeefc8fe1ddfe93652ec50f82373d0c431f7faabd5e6323f6903195ef227",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.tar.gz"], urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.3.0/rules_nodejs-3.3.0.tar.gz"],
)
_ESBUILD_VERSION = "0.8.48" # reminder: update SHAs below when changing this value
http_archive(
name = "esbuild_darwin",
urls = [
"https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-%s.tgz" % _ESBUILD_VERSION,
],
strip_prefix = "package",
build_file_content = """exports_files(["bin/esbuild"])""",
sha256 = "d21a722873ed24586f071973b77223553fca466946f3d7e3976eeaccb14424e6",
)
http_archive(
name = "esbuild_windows",
urls = [
"https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-%s.tgz" % _ESBUILD_VERSION,
],
strip_prefix = "package",
build_file_content = """exports_files(["esbuild.exe"])""",
sha256 = "fe5dcb97b4c47f9567012f0a45c19c655f3d2e0d76932f6dd12715dbebbd6eb0",
)
http_archive(
name = "esbuild_linux",
urls = [
"https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-%s.tgz" % _ESBUILD_VERSION,
],
strip_prefix = "package",
build_file_content = """exports_files(["bin/esbuild"])""",
sha256 = "60dabe141e5dfcf99e7113bded6012868132068a582a102b258fb7b1cfdac14b",
) )
# sass # sass

View File

@ -120,7 +120,7 @@ service SchedulingService {
rpc CongratsInfo(Empty) returns (CongratsInfoOut); rpc CongratsInfo(Empty) returns (CongratsInfoOut);
rpc RestoreBuriedAndSuspendedCards(CardIds) returns (OpChanges); rpc RestoreBuriedAndSuspendedCards(CardIds) returns (OpChanges);
rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty);
rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges); rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChangesWithCount);
rpc EmptyFilteredDeck(DeckId) returns (OpChanges); rpc EmptyFilteredDeck(DeckId) returns (OpChanges);
rpc RebuildFilteredDeck(DeckId) returns (OpChangesWithCount); rpc RebuildFilteredDeck(DeckId) returns (OpChangesWithCount);
rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
@ -154,6 +154,8 @@ service DecksService {
rpc GetOrCreateFilteredDeck(DeckId) returns (FilteredDeckForUpdate); rpc GetOrCreateFilteredDeck(DeckId) returns (FilteredDeckForUpdate);
rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (OpChangesWithId); rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (OpChangesWithId);
rpc FilteredDeckOrderLabels(Empty) returns (StringList); rpc FilteredDeckOrderLabels(Empty) returns (StringList);
rpc SetCurrentDeck(DeckId) returns (OpChanges);
rpc GetCurrentDeck(Empty) returns (Deck);
} }
service NotesService { service NotesService {
@ -163,7 +165,7 @@ service NotesService {
rpc DefaultDeckForNotetype(NotetypeId) returns (DeckId); rpc DefaultDeckForNotetype(NotetypeId) returns (DeckId);
rpc UpdateNote(UpdateNoteIn) returns (OpChanges); rpc UpdateNote(UpdateNoteIn) returns (OpChanges);
rpc GetNote(NoteId) returns (Note); rpc GetNote(NoteId) returns (Note);
rpc RemoveNotes(RemoveNotesIn) returns (OpChanges); rpc RemoveNotes(RemoveNotesIn) returns (OpChangesWithCount);
rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut);
rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges);
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
@ -1313,7 +1315,8 @@ message BuryOrSuspendCardsIn {
BURY_USER = 2; BURY_USER = 2;
} }
repeated int64 card_ids = 1; repeated int64 card_ids = 1;
Mode mode = 2; repeated int64 note_ids = 2;
Mode mode = 3;
} }
message ScheduleCardsAsNewIn { message ScheduleCardsAsNewIn {
@ -1497,26 +1500,17 @@ message GetQueuedCardsOut {
} }
message OpChanges { message OpChanges {
// this is not an exhaustive list; we can add more cases as we need them bool card = 1;
enum Kind { bool note = 2;
OTHER = 0; bool deck = 3;
UPDATE_NOTE_TAGS = 1; bool tag = 4;
SET_CARD_FLAG = 2; bool notetype = 5;
UPDATE_NOTE = 3; bool config = 6;
}
Kind kind = 1; bool browser_table = 7;
bool card = 2; bool browser_sidebar = 8;
bool note = 3; bool editor = 9;
bool deck = 4; bool study_queues = 10;
bool tag = 5;
bool notetype = 6;
bool preference = 7;
bool browser_table = 8;
bool browser_sidebar = 9;
bool editor = 10;
bool study_queues = 11;
} }
message UndoStatus { message UndoStatus {

View File

@ -163,7 +163,7 @@ impl From<String> for Variable {
let kind = match name.as_str() { let kind = match name.as_str() {
"cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected" "cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected"
| "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining" | "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining"
| "hourStart" | "hourEnd" | "correct" | "decks" => VariableKind::Int, | "hourStart" | "hourEnd" | "correct" | "decks" | "changed" => VariableKind::Int,
"average-seconds" | "cards-per-minute" => VariableKind::Float, "average-seconds" | "cards-per-minute" => VariableKind::Float,
"val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up" "val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up"
| "down" | "seconds" | "megs" => VariableKind::Any, | "down" | "seconds" | "megs" => VariableKind::Any,

View File

@ -67,7 +67,7 @@ impl ConfigService for Backend {
col.transact_no_undo(|col| { col.transact_no_undo(|col| {
// ensure it's a well-formed object // ensure it's a well-formed object
let val: Value = serde_json::from_slice(&input.value_json)?; let val: Value = serde_json::from_slice(&input.value_json)?;
col.set_config(input.key.as_str(), &val) col.set_config(input.key.as_str(), &val).map(|_| ())
}) })
}) })
.map(Into::into) .map(Into::into)
@ -98,7 +98,7 @@ impl ConfigService for Backend {
self.with_col(|col| { self.with_col(|col| {
col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value)) col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value))
}) })
.map(Into::into) .map(|_| ().into())
} }
fn get_config_string(&self, input: pb::config::String) -> Result<pb::String> { fn get_config_string(&self, input: pb::config::String) -> Result<pb::String> {
@ -113,7 +113,7 @@ impl ConfigService for Backend {
self.with_col(|col| { self.with_col(|col| {
col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value)) col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value))
}) })
.map(Into::into) .map(|_| ().into())
} }
fn get_preferences(&self, _input: pb::Empty) -> Result<pb::Preferences> { fn get_preferences(&self, _input: pb::Empty) -> Result<pb::Preferences> {

View File

@ -187,6 +187,16 @@ impl DecksService for Backend {
}) })
.map(Into::into) .map(Into::into)
} }
fn set_current_deck(&self, input: pb::DeckId) -> Result<pb::OpChanges> {
self.with_col(|col| col.set_current_deck(input.did.into()))
.map(Into::into)
}
fn get_current_deck(&self, _input: pb::Empty) -> Result<pb::Deck> {
self.with_col(|col| col.get_current_deck())
.map(|deck| (*deck).clone().into())
}
} }
impl From<pb::DeckId> for DeckId { impl From<pb::DeckId> for DeckId {

View File

@ -63,7 +63,7 @@ impl NotesService for Backend {
}) })
} }
fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::OpChanges> { fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| { self.with_col(|col| {
if !input.note_ids.is_empty() { if !input.note_ids.is_empty() {
col.remove_notes( col.remove_notes(
@ -131,7 +131,7 @@ impl NotesService for Backend {
fn cards_of_note(&self, input: pb::NoteId) -> Result<pb::CardIds> { fn cards_of_note(&self, input: pb::NoteId) -> Result<pb::CardIds> {
self.with_col(|col| { self.with_col(|col| {
col.storage col.storage
.all_card_ids_of_note(NoteId(input.nid)) .all_card_ids_of_note_in_order(NoteId(input.nid))
.map(|v| pb::CardIds { .map(|v| pb::CardIds {
cids: v.into_iter().map(Into::into).collect(), cids: v.into_iter().map(Into::into).collect(),
}) })

View File

@ -1,8 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use pb::op_changes::Kind;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
ops::OpChanges, ops::OpChanges,
@ -10,27 +8,15 @@ use crate::{
undo::{UndoOutput, UndoStatus}, undo::{UndoOutput, UndoStatus},
}; };
impl From<Op> for Kind {
fn from(o: Op) -> Self {
match o {
Op::SetFlag => Kind::SetCardFlag,
Op::UpdateTag => Kind::UpdateNoteTags,
Op::UpdateNote => Kind::UpdateNote,
_ => Kind::Other,
}
}
}
impl From<OpChanges> for pb::OpChanges { impl From<OpChanges> for pb::OpChanges {
fn from(c: OpChanges) -> Self { fn from(c: OpChanges) -> Self {
pb::OpChanges { pb::OpChanges {
kind: Kind::from(c.op) as i32,
card: c.changes.card, card: c.changes.card,
note: c.changes.note, note: c.changes.note,
deck: c.changes.deck, deck: c.changes.deck,
tag: c.changes.tag, tag: c.changes.tag,
notetype: c.changes.notetype, notetype: c.changes.notetype,
preference: c.changes.preference, config: c.changes.config,
browser_table: c.requires_browser_table_redraw(), browser_table: c.requires_browser_table_redraw(),
browser_sidebar: c.requires_browser_sidebar_redraw(), browser_sidebar: c.requires_browser_sidebar_redraw(),
editor: c.requires_editor_redraw(), editor: c.requires_editor_redraw(),

View File

@ -87,10 +87,18 @@ impl SchedulingService for Backend {
}) })
} }
fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::OpChanges> { fn bury_or_suspend_cards(
&self,
input: pb::BuryOrSuspendCardsIn,
) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| { self.with_col(|col| {
let mode = input.mode(); let mode = input.mode();
let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); let cids = if input.card_ids.is_empty() {
col.storage
.card_ids_of_notes(&input.note_ids.into_newtype(NoteId))?
} else {
input.card_ids.into_newtype(CardId)
};
col.bury_or_suspend_cards(&cids, mode).map(Into::into) col.bury_or_suspend_cards(&cids, mode).map(Into::into)
}) })
} }
@ -105,7 +113,7 @@ impl SchedulingService for Backend {
fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::OpChanges> { fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); let cids = input.card_ids.into_newtype(CardId);
let log = input.log; let log = input.log;
col.reschedule_cards_as_new(&cids, log).map(Into::into) col.reschedule_cards_as_new(&cids, log).map(Into::into)
}) })
@ -114,12 +122,12 @@ impl SchedulingService for Backend {
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::OpChanges> { fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::OpChanges> {
let config = input.config_key.map(Into::into); let config = input.config_key.map(Into::into);
let days = input.days; let days = input.days;
let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); let cids = input.card_ids.into_newtype(CardId);
self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into)) self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into))
} }
fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::OpChangesWithCount> { fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::OpChangesWithCount> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); let cids = input.card_ids.into_newtype(CardId);
let (start, step, random, shift) = ( let (start, step, random, shift) = (
input.starting_from, input.starting_from,
input.step_size, input.step_size,

View File

@ -239,7 +239,7 @@ impl Collection {
self.storage.set_search_table_to_card_ids(cards, false)?; self.storage.set_search_table_to_card_ids(cards, false)?;
let sched = self.scheduler_version(); let sched = self.scheduler_version();
let usn = self.usn()?; let usn = self.usn()?;
self.transact(Op::SetDeck, |col| { self.transact(Op::SetCardDeck, |col| {
for mut card in col.storage.all_searched_cards()? { for mut card in col.storage.all_searched_cards()? {
if card.deck_id == deck_id { if card.deck_id == deck_id {
continue; continue;

View File

@ -69,7 +69,7 @@ impl Collection {
} }
} }
pub(crate) fn set_bool(&mut self, key: BoolKey, value: bool) -> Result<()> { pub(crate) fn set_bool(&mut self, key: BoolKey, value: bool) -> Result<bool> {
self.set_config(key, &value) self.set_config(key, &value)
} }
} }

View File

@ -1,7 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::ConfigKey;
use crate::prelude::*; use crate::prelude::*;
use strum::IntoStaticStr; use strum::IntoStaticStr;
@ -20,11 +19,6 @@ impl DeckConfigKey {
} }
impl Collection { impl Collection {
pub(crate) fn get_current_deck_id(&self) -> DeckId {
self.get_config_optional(ConfigKey::CurrentDeckId)
.unwrap_or(DeckId(1))
}
pub(crate) fn clear_aux_config_for_deck(&self, ntid: DeckId) -> Result<()> { pub(crate) fn clear_aux_config_for_deck(&self, ntid: DeckId) -> Result<()> {
self.remove_config_prefix(&build_aux_deck_key(ntid, "")) self.remove_config_prefix(&build_aux_deck_key(ntid, ""))
} }
@ -38,7 +32,7 @@ impl Collection {
&mut self, &mut self,
did: DeckId, did: DeckId,
ntid: NotetypeId, ntid: NotetypeId,
) -> Result<()> { ) -> Result<bool> {
let key = DeckConfigKey::LastNotetype.for_deck(did); let key = DeckConfigKey::LastNotetype.for_deck(did);
self.set_config(key.as_str(), &ntid) self.set_config(key.as_str(), &ntid)
} }

View File

@ -94,7 +94,8 @@ impl Collection {
self.get_config_optional(key).unwrap_or_default() self.get_config_optional(key).unwrap_or_default()
} }
pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result<()> /// True if added, or new value is different.
pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result<bool>
where where
K: Into<&'a str>, K: Into<&'a str>,
{ {
@ -130,6 +131,7 @@ impl Collection {
self.state.scheduler_info = None; self.state.scheduler_info = None;
if let Some(mins) = mins { if let Some(mins) = mins {
self.set_config(ConfigKey::CreationOffset, &mins) self.set_config(ConfigKey::CreationOffset, &mins)
.map(|_| ())
} else { } else {
self.remove_config(ConfigKey::CreationOffset) self.remove_config(ConfigKey::CreationOffset)
} }
@ -141,7 +143,7 @@ impl Collection {
pub(crate) fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> { pub(crate) fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> {
self.state.scheduler_info = None; self.state.scheduler_info = None;
self.set_config(ConfigKey::LocalOffset, &mins) self.set_config(ConfigKey::LocalOffset, &mins).map(|_| ())
} }
pub(crate) fn get_v2_rollover(&self) -> Option<u8> { pub(crate) fn get_v2_rollover(&self) -> Option<u8> {
@ -151,7 +153,7 @@ impl Collection {
pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> { pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> {
self.state.scheduler_info = None; self.state.scheduler_info = None;
self.set_config(ConfigKey::Rollover, &hour) self.set_config(ConfigKey::Rollover, &hour).map(|_| ())
} }
pub(crate) fn get_next_card_position(&self) -> u32 { pub(crate) fn get_next_card_position(&self) -> u32 {
@ -168,6 +170,7 @@ impl Collection {
pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> { pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> {
self.set_config(ConfigKey::NextNewCardPosition, &pos) self.set_config(ConfigKey::NextNewCardPosition, &pos)
.map(|_| ())
} }
pub(crate) fn scheduler_version(&self) -> SchedulerVersion { pub(crate) fn scheduler_version(&self) -> SchedulerVersion {
@ -179,6 +182,7 @@ impl Collection {
pub(crate) fn set_scheduler_version_config_key(&mut self, ver: SchedulerVersion) -> Result<()> { pub(crate) fn set_scheduler_version_config_key(&mut self, ver: SchedulerVersion) -> Result<()> {
self.state.scheduler_info = None; self.state.scheduler_info = None;
self.set_config(ConfigKey::SchedulerVersion, &ver) self.set_config(ConfigKey::SchedulerVersion, &ver)
.map(|_| ())
} }
pub(crate) fn learn_ahead_secs(&self) -> u32 { pub(crate) fn learn_ahead_secs(&self) -> u32 {
@ -188,6 +192,7 @@ impl Collection {
pub(crate) fn set_learn_ahead_secs(&mut self, secs: u32) -> Result<()> { pub(crate) fn set_learn_ahead_secs(&mut self, secs: u32) -> Result<()> {
self.set_config(ConfigKey::LearnAheadSecs, &secs) self.set_config(ConfigKey::LearnAheadSecs, &secs)
.map(|_| ())
} }
pub(crate) fn get_new_review_mix(&self) -> NewReviewMix { pub(crate) fn get_new_review_mix(&self) -> NewReviewMix {
@ -200,6 +205,7 @@ impl Collection {
pub(crate) fn set_new_review_mix(&mut self, mix: NewReviewMix) -> Result<()> { pub(crate) fn set_new_review_mix(&mut self, mix: NewReviewMix) -> Result<()> {
self.set_config(ConfigKey::NewReviewMix, &(mix as u8)) self.set_config(ConfigKey::NewReviewMix, &(mix as u8))
.map(|_| ())
} }
pub(crate) fn get_first_day_of_week(&self) -> Weekday { pub(crate) fn get_first_day_of_week(&self) -> Weekday {
@ -209,6 +215,7 @@ impl Collection {
pub(crate) fn set_first_day_of_week(&mut self, weekday: Weekday) -> Result<()> { pub(crate) fn set_first_day_of_week(&mut self, weekday: Weekday) -> Result<()> {
self.set_config(ConfigKey::FirstDayOfWeek, &weekday) self.set_config(ConfigKey::FirstDayOfWeek, &weekday)
.map(|_| ())
} }
pub(crate) fn get_answer_time_limit_secs(&self) -> u32 { pub(crate) fn get_answer_time_limit_secs(&self) -> u32 {
@ -218,6 +225,7 @@ impl Collection {
pub(crate) fn set_answer_time_limit_secs(&mut self, secs: u32) -> Result<()> { pub(crate) fn set_answer_time_limit_secs(&mut self, secs: u32) -> Result<()> {
self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs) self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs)
.map(|_| ())
} }
pub(crate) fn get_last_unburied_day(&self) -> u32 { pub(crate) fn get_last_unburied_day(&self) -> u32 {
@ -227,6 +235,7 @@ impl Collection {
pub(crate) fn set_last_unburied_day(&mut self, day: u32) -> Result<()> { pub(crate) fn set_last_unburied_day(&mut self, day: u32) -> Result<()> {
self.set_config(ConfigKey::LastUnburiedDay, &day) self.set_config(ConfigKey::LastUnburiedDay, &day)
.map(|_| ())
} }
} }

View File

@ -30,6 +30,7 @@ impl Collection {
pub(crate) fn set_current_notetype_id(&mut self, ntid: NotetypeId) -> Result<()> { pub(crate) fn set_current_notetype_id(&mut self, ntid: NotetypeId) -> Result<()> {
self.set_config(ConfigKey::CurrentNotetypeId, &ntid) self.set_config(ConfigKey::CurrentNotetypeId, &ntid)
.map(|_| ())
} }
pub(crate) fn clear_aux_config_for_notetype(&self, ntid: NotetypeId) -> Result<()> { pub(crate) fn clear_aux_config_for_notetype(&self, ntid: NotetypeId) -> Result<()> {
@ -43,7 +44,7 @@ impl Collection {
pub(crate) fn set_last_deck_for_notetype(&mut self, id: NotetypeId, did: DeckId) -> Result<()> { pub(crate) fn set_last_deck_for_notetype(&mut self, id: NotetypeId, did: DeckId) -> Result<()> {
let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id); let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id);
self.set_config(key.as_str(), &did) self.set_config(key.as_str(), &did).map(|_| ())
} }
} }

View File

@ -22,7 +22,7 @@ impl Collection {
.unwrap_or_else(|| default.to_string()) .unwrap_or_else(|| default.to_string())
} }
pub(crate) fn set_string(&mut self, key: StringKey, val: &str) -> Result<()> { pub(crate) fn set_string(&mut self, key: StringKey, val: &str) -> Result<bool> {
self.set_config(key, &val) self.set_config(key, &val)
} }
} }

View File

@ -21,16 +21,19 @@ impl Collection {
.get_config_entry(&entry.key)? .get_config_entry(&entry.key)?
.ok_or_else(|| AnkiError::invalid_input("config disappeared"))?; .ok_or_else(|| AnkiError::invalid_input("config disappeared"))?;
self.update_config_entry_undoable(entry, current) self.update_config_entry_undoable(entry, current)
.map(|_| ())
} }
UndoableConfigChange::Removed(entry) => self.add_config_entry_undoable(entry), UndoableConfigChange::Removed(entry) => self.add_config_entry_undoable(entry),
} }
} }
pub(super) fn set_config_undoable(&mut self, entry: Box<ConfigEntry>) -> Result<()> { /// True if added, or value changed.
pub(super) fn set_config_undoable(&mut self, entry: Box<ConfigEntry>) -> Result<bool> {
if let Some(original) = self.storage.get_config_entry(&entry.key)? { if let Some(original) = self.storage.get_config_entry(&entry.key)? {
self.update_config_entry_undoable(entry, original) self.update_config_entry_undoable(entry, original)
} else { } else {
self.add_config_entry_undoable(entry) self.add_config_entry_undoable(entry)?;
Ok(true)
} }
} }
@ -49,16 +52,19 @@ impl Collection {
Ok(()) Ok(())
} }
/// True if new value differed.
fn update_config_entry_undoable( fn update_config_entry_undoable(
&mut self, &mut self,
entry: Box<ConfigEntry>, entry: Box<ConfigEntry>,
original: Box<ConfigEntry>, original: Box<ConfigEntry>,
) -> Result<()> { ) -> Result<bool> {
if entry.value != original.value { if entry.value != original.value {
self.save_undo(UndoableConfigChange::Updated(original)); self.save_undo(UndoableConfigChange::Updated(original));
self.storage.set_config_entry(&entry)?; self.storage.set_config_entry(&entry)?;
Ok(true)
} else {
Ok(false)
} }
Ok(())
} }
} }

View File

@ -0,0 +1,41 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::sync::Arc;
use crate::{config::ConfigKey, prelude::*};
impl Collection {
pub fn set_current_deck(&mut self, deck: DeckId) -> Result<OpOutput<()>> {
self.transact(Op::SetCurrentDeck, |col| col.set_current_deck_inner(deck))
}
/// Fetch the current deck, falling back to the default if the previously
/// selected deck is invalid.
pub fn get_current_deck(&mut self) -> Result<Arc<Deck>> {
if let Some(deck) = self.get_deck(self.get_current_deck_id())? {
return Ok(deck);
}
self.get_deck(DeckId(1))?.ok_or(AnkiError::NotFound)
}
}
impl Collection {
/// The returned id may reference a deck that does not exist;
/// prefer using get_current_deck() instead.
pub(crate) fn get_current_deck_id(&self) -> DeckId {
self.get_config_optional(ConfigKey::CurrentDeckId)
.unwrap_or(DeckId(1))
}
fn set_current_deck_inner(&mut self, deck: DeckId) -> Result<()> {
if self.set_current_deck_id(deck)? {
self.state.card_queues = None;
}
Ok(())
}
fn set_current_deck_id(&mut self, did: DeckId) -> Result<bool> {
self.set_config(ConfigKey::CurrentDeckId, &did)
}
}

View File

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod counts; mod counts;
mod current;
mod filtered; mod filtered;
mod schema11; mod schema11;
mod tree; mod tree;

View File

@ -402,19 +402,21 @@ impl Collection {
} }
/// Remove provided notes, and any cards that use them. /// Remove provided notes, and any cards that use them.
pub(crate) fn remove_notes(&mut self, nids: &[NoteId]) -> Result<OpOutput<()>> { pub(crate) fn remove_notes(&mut self, nids: &[NoteId]) -> Result<OpOutput<usize>> {
let usn = self.usn()?; let usn = self.usn()?;
self.transact(Op::RemoveNote, |col| { self.transact(Op::RemoveNote, |col| {
let mut card_count = 0;
for nid in nids { for nid in nids {
let nid = *nid; let nid = *nid;
if let Some(_existing_note) = col.storage.get_note(nid)? { if let Some(_existing_note) = col.storage.get_note(nid)? {
for card in col.storage.all_cards_of_note(nid)? { for card in col.storage.all_cards_of_note(nid)? {
card_count += 1;
col.remove_card_and_add_grave_undoable(card, usn)?; col.remove_card_and_add_grave_undoable(card, usn)?;
} }
col.remove_note_only_undoable(nid, usn)?; col.remove_note_only_undoable(nid, usn)?;
} }
} }
Ok(()) Ok(card_count)
}) })
} }

View File

@ -23,7 +23,7 @@ pub enum Op {
RenameTag, RenameTag,
ReparentTag, ReparentTag,
ScheduleAsNew, ScheduleAsNew,
SetDeck, SetCardDeck,
SetDueDate, SetDueDate,
SetFlag, SetFlag,
SortCards, SortCards,
@ -34,6 +34,7 @@ pub enum Op {
UpdateNote, UpdateNote,
UpdatePreferences, UpdatePreferences,
UpdateTag, UpdateTag,
SetCurrentDeck,
} }
impl Op { impl Op {
@ -55,7 +56,7 @@ impl Op {
Op::UpdateNote => tr.undo_update_note(), Op::UpdateNote => tr.undo_update_note(),
Op::UpdatePreferences => tr.preferences_preferences(), Op::UpdatePreferences => tr.preferences_preferences(),
Op::UpdateTag => tr.undo_update_tag(), Op::UpdateTag => tr.undo_update_tag(),
Op::SetDeck => tr.browsing_change_deck(), Op::SetCardDeck => tr.browsing_change_deck(),
Op::SetFlag => tr.undo_set_flag(), Op::SetFlag => tr.undo_set_flag(),
Op::FindAndReplace => tr.browsing_find_and_replace(), Op::FindAndReplace => tr.browsing_find_and_replace(),
Op::ClearUnusedTags => tr.browsing_clear_unused_tags(), Op::ClearUnusedTags => tr.browsing_clear_unused_tags(),
@ -68,6 +69,7 @@ impl Op {
Op::RebuildFilteredDeck => tr.undo_build_filtered_deck(), Op::RebuildFilteredDeck => tr.undo_build_filtered_deck(),
Op::EmptyFilteredDeck => tr.studying_empty(), Op::EmptyFilteredDeck => tr.studying_empty(),
Op::ExpandCollapse => tr.undo_expand_collapse(), Op::ExpandCollapse => tr.undo_expand_collapse(),
Op::SetCurrentDeck => tr.browsing_change_deck(),
} }
.into() .into()
} }
@ -80,7 +82,7 @@ pub struct StateChanges {
pub deck: bool, pub deck: bool,
pub tag: bool, pub tag: bool,
pub notetype: bool, pub notetype: bool,
pub preference: bool, pub config: bool,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -134,7 +136,12 @@ impl OpChanges {
pub fn requires_study_queue_rebuild(&self) -> bool { pub fn requires_study_queue_rebuild(&self) -> bool {
let c = &self.changes; let c = &self.changes;
!matches!(self.op, Op::AnswerCard | Op::ExpandCollapse) if self.op == Op::AnswerCard {
&& (c.card || c.deck || c.preference) return false;
}
c.card
|| (c.deck && self.op != Op::ExpandCollapse)
|| (c.config && matches!(self.op, Op::SetCurrentDeck))
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub(crate) use crate::types::IntoNewtypeVec;
pub use crate::{ pub use crate::{
card::{Card, CardId}, card::{Card, CardId},
collection::Collection, collection::Collection,

View File

@ -33,7 +33,7 @@ mod test {
let queued = col.next_card()?.unwrap(); let queued = col.next_card()?.unwrap();
let nid = note.id; let nid = note.id;
let cid = queued.card.id; let cid = queued.card.id;
let sibling_cid = col.storage.all_card_ids_of_note(nid)?[1]; let sibling_cid = col.storage.all_card_ids_of_note_in_order(nid)?[1];
let assert_initial_state = |col: &mut Collection| -> Result<()> { let assert_initial_state = |col: &mut Collection| -> Result<()> {
let first = col.storage.get_card(cid)?.unwrap(); let first = col.storage.get_card(cid)?.unwrap();

View File

@ -89,7 +89,8 @@ impl Collection {
/// Bury/suspend cards in search table, and clear it. /// Bury/suspend cards in search table, and clear it.
/// Marks the cards as modified. /// Marks the cards as modified.
fn bury_or_suspend_searched_cards(&mut self, mode: BuryOrSuspendMode) -> Result<()> { fn bury_or_suspend_searched_cards(&mut self, mode: BuryOrSuspendMode) -> Result<usize> {
let mut count = 0;
let usn = self.usn()?; let usn = self.usn()?;
let sched = self.scheduler_version(); let sched = self.scheduler_version();
@ -113,18 +114,21 @@ impl Collection {
card.remove_from_learning(); card.remove_from_learning();
} }
card.queue = desired_queue; card.queue = desired_queue;
count += 1;
self.update_card_inner(&mut card, original, usn)?; self.update_card_inner(&mut card, original, usn)?;
} }
} }
self.storage.clear_searched_cards_table() self.storage.clear_searched_cards_table()?;
Ok(count)
} }
pub fn bury_or_suspend_cards( pub fn bury_or_suspend_cards(
&mut self, &mut self,
cids: &[CardId], cids: &[CardId],
mode: BuryOrSuspendMode, mode: BuryOrSuspendMode,
) -> Result<OpOutput<()>> { ) -> Result<OpOutput<usize>> {
let op = match mode { let op = match mode {
BuryOrSuspendMode::Suspend => Op::Suspend, BuryOrSuspendMode::Suspend => Op::Suspend,
BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury, BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury,
@ -141,7 +145,7 @@ impl Collection {
nid: NoteId, nid: NoteId,
include_new: bool, include_new: bool,
include_reviews: bool, include_reviews: bool,
) -> Result<()> { ) -> Result<usize> {
self.storage self.storage
.search_siblings_for_bury(cid, nid, include_new, include_reviews)?; .search_siblings_for_bury(cid, nid, include_new, include_reviews)?;
self.bury_or_suspend_searched_cards(BuryOrSuspendMode::BurySched) self.bury_or_suspend_searched_cards(BuryOrSuspendMode::BurySched)

View File

@ -308,13 +308,26 @@ impl super::SqliteStorage {
.collect() .collect()
} }
pub(crate) fn all_card_ids_of_note(&self, nid: NoteId) -> Result<Vec<CardId>> { pub(crate) fn all_card_ids_of_note_in_order(&self, nid: NoteId) -> Result<Vec<CardId>> {
self.db self.db
.prepare_cached("select id from cards where nid = ? order by ord")? .prepare_cached("select id from cards where nid = ? order by ord")?
.query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))? .query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))?
.collect() .collect()
} }
pub(crate) fn card_ids_of_notes(&self, nids: &[NoteId]) -> Result<Vec<CardId>> {
let mut stmt = self
.db
.prepare_cached("select id from cards where nid = ?")?;
let mut cids = vec![];
for nid in nids {
for cid in stmt.query_map(&[nid], |row| row.get(0))? {
cids.push(cid?);
}
}
Ok(cids)
}
/// Place matching card ids into the search table. /// Place matching card ids into the search table.
pub(crate) fn search_siblings_for_bury( pub(crate) fn search_siblings_for_bury(
&self, &self,

View File

@ -68,3 +68,18 @@ macro_rules! define_newtype {
} }
define_newtype!(Usn, i32); define_newtype!(Usn, i32);
pub(crate) trait IntoNewtypeVec {
fn into_newtype<F, T>(self, func: F) -> Vec<T>
where
F: FnMut(i64) -> T;
}
impl IntoNewtypeVec for Vec<i64> {
fn into_newtype<F, T>(self, func: F) -> Vec<T>
where
F: FnMut(i64) -> T,
{
self.into_iter().map(func).collect()
}
}

View File

@ -126,7 +126,7 @@ impl UndoManager {
UndoableChange::Tag(_) => changes.tag = true, UndoableChange::Tag(_) => changes.tag = true,
UndoableChange::Revlog(_) => {} UndoableChange::Revlog(_) => {}
UndoableChange::Queue(_) => {} UndoableChange::Queue(_) => {}
UndoableChange::Config(_) => {} // fixme: preferences? UndoableChange::Config(_) => changes.config = true,
} }
} }

View File

@ -37,6 +37,8 @@ esbuild(
args = [ args = [
"--global-name=anki", "--global-name=anki",
"--inject:$(location //ts:protobuf-shim.js)", "--inject:$(location //ts:protobuf-shim.js)",
"--resolve-extensions=.mjs,.js",
"--log-level=warning",
], ],
entry_point = "index.ts", entry_point = "index.ts",
external = [ external = [

View File

@ -36,6 +36,8 @@ esbuild(
name = "editor", name = "editor",
args = [ args = [
"--loader:.svg=text", "--loader:.svg=text",
"--resolve-extensions=.mjs,.js",
"--log-level=warning",
], ],
entry_point = "index_wrapper.ts", entry_point = "index_wrapper.ts",
visibility = ["//visibility:public"], visibility = ["//visibility:public"],

View File

@ -3,11 +3,6 @@ load("//ts/esbuild:upstream.bzl", _esbuild = "esbuild_macro")
def esbuild(name, **kwargs): def esbuild(name, **kwargs):
_esbuild( _esbuild(
name = name, name = name,
tool = select({
"@bazel_tools//src/conditions:darwin": "@esbuild_darwin//:bin/esbuild",
"@bazel_tools//src/conditions:windows": "@esbuild_windows//:esbuild.exe",
"@bazel_tools//src/conditions:linux_x86_64": "@esbuild_linux//:bin/esbuild",
}),
minify = select({ minify = select({
"//:release": True, "//:release": True,
"//conditions:default": False, "//conditions:default": False,

View File

@ -0,0 +1,8 @@
load(":toolchain.bzl", "define_default_toolchains", "esbuild_toolchain")
toolchain_type(
name = "toolchain_type",
visibility = ["//visibility:public"],
)
define_default_toolchains()

4
ts/esbuild/README.md Normal file
View File

@ -0,0 +1,4 @@
This folder vendors the esbuild support in rules_nodejs while we wait
for some upstream changes to be applied:
- https://github.com/bazelbuild/rules_nodejs/pull/2545

View File

@ -0,0 +1,45 @@
""" Generated code; do not edit
Update by running yarn update-esbuild-versions
Helper macro for fetching esbuild versions for internal tests and examples in rules_nodejs
"""
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
load(":toolchain.bzl", "register_default_toolchains")
_VERSION = "0.11.5"
def esbuild_dependencies():
"""Helper to install required dependencies for the esbuild rules"""
version = _VERSION
http_archive(
name = "esbuild_darwin",
urls = [
"https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-%s.tgz" % version,
],
strip_prefix = "package",
build_file_content = """exports_files(["bin/esbuild"])""",
sha256 = "98436890727bdb0d4beddd9c9e07d0aeff0e8dfe0169f85e568eca0dd43f665e",
)
http_archive(
name = "esbuild_windows",
urls = [
"https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-%s.tgz" % version,
],
strip_prefix = "package",
build_file_content = """exports_files(["esbuild.exe"])""",
sha256 = "589c8ff97210bd41de106e6317ce88f9e88d2cacfd8178ae1217f2b857ff6c3a",
)
http_archive(
name = "esbuild_linux",
urls = [
"https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-%s.tgz" % version,
],
strip_prefix = "package",
build_file_content = """exports_files(["bin/esbuild"])""",
sha256 = "113c2e84895f4422a3676db4e15d9f01b2b4fac041edab25284fdb9574ba58a0",
)
register_default_toolchains()

42
ts/esbuild/toolchain.bzl Normal file
View File

@ -0,0 +1,42 @@
def _esbuild_toolchain_impl(ctx):
return [platform_common.ToolchainInfo(
binary = ctx.executable.binary,
)]
esbuild_toolchain = rule(
implementation = _esbuild_toolchain_impl,
attrs = {
"binary": attr.label(allow_single_file = True, executable = True, cfg = "exec"),
},
)
_package_path = "@net_ankiweb_anki//ts/esbuild"
TOOLCHAIN = _package_path + ":toolchain_type"
_default_toolchains = [
["@esbuild_darwin//:bin/esbuild", "macos"],
["@esbuild_linux//:bin/esbuild", "linux"],
["@esbuild_windows//:esbuild.exe", "windows"],
]
def define_default_toolchains():
for repo_path, platform in _default_toolchains:
esbuild_toolchain(
name = "esbuild_" + platform,
binary = repo_path,
)
native.toolchain(
name = "esbuild_{}_toolchain".format(platform),
exec_compatible_with = [
"@platforms//os:" + platform,
"@platforms//cpu:x86_64",
],
toolchain = ":esbuild_" + platform,
toolchain_type = ":toolchain_type",
)
def register_default_toolchains():
for _, platform in _default_toolchains:
native.register_toolchains(_package_path + ":esbuild_{}_toolchain".format(platform))

View File

@ -1,17 +1,11 @@
""" """
NOTE: this file was forked from the following repo (Apache2)
https://github.com/bazelbuild/rules_nodejs/blob/c47b770a122e9614516df2e3fdca6fe0bf6e3420/packages/esbuild/esbuild.bzl
Local changes not in upstream:
https://github.com/bazelbuild/rules_nodejs/pull/2545
https://github.com/bazelbuild/rules_nodejs/pull/2564
esbuild rule esbuild rule
""" """
load("@build_bazel_rules_nodejs//:providers.bzl", "JSEcmaScriptModuleInfo", "JSModuleInfo", "NpmPackageInfo", "node_modules_aspect") load("@build_bazel_rules_nodejs//:providers.bzl", "JSEcmaScriptModuleInfo", "JSModuleInfo", "NpmPackageInfo", "node_modules_aspect")
load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "MODULE_MAPPINGS_ASPECT_RESULTS_NAME", "module_mappings_aspect") load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "MODULE_MAPPINGS_ASPECT_RESULTS_NAME", "module_mappings_aspect")
load(":helpers.bzl", "filter_files", "generate_path_mapping", "resolve_js_input", "write_jsconfig_file") load(":helpers.bzl", "filter_files", "generate_path_mapping", "resolve_js_input", "write_jsconfig_file")
load(":toolchain.bzl", "TOOLCHAIN")
def _esbuild_impl(ctx): def _esbuild_impl(ctx):
# For each dep, JSEcmaScriptModuleInfo is used if found, then JSModuleInfo and finally # For each dep, JSEcmaScriptModuleInfo is used if found, then JSModuleInfo and finally
@ -35,6 +29,9 @@ def _esbuild_impl(ctx):
elif hasattr(dep, "files"): elif hasattr(dep, "files"):
deps_depsets.append(dep.files) deps_depsets.append(dep.files)
if DefaultInfo in dep:
deps_depsets.append(dep[DefaultInfo].data_runfiles.files)
if NpmPackageInfo in dep: if NpmPackageInfo in dep:
deps_depsets.append(dep[NpmPackageInfo].sources) deps_depsets.append(dep[NpmPackageInfo].sources)
npm_workspaces.append(dep[NpmPackageInfo].workspace) npm_workspaces.append(dep[NpmPackageInfo].workspace)
@ -61,7 +58,12 @@ def _esbuild_impl(ctx):
args = ctx.actions.args() args = ctx.actions.args()
args.add("--bundle", entry_point.path) args.add("--bundle", entry_point.path)
if len(ctx.attr.sourcemap) > 0:
args.add_joined(["--sourcemap", ctx.attr.sourcemap], join_with = "=")
else:
args.add("--sourcemap") args.add("--sourcemap")
args.add("--preserve-symlinks") args.add("--preserve-symlinks")
args.add_joined(["--platform", ctx.attr.platform], join_with = "=") args.add_joined(["--platform", ctx.attr.platform], join_with = "=")
args.add_joined(["--target", ctx.attr.target], join_with = "=") args.add_joined(["--target", ctx.attr.target], join_with = "=")
@ -70,8 +72,8 @@ def _esbuild_impl(ctx):
args.add_all(ctx.attr.define, format_each = "--define:%s") args.add_all(ctx.attr.define, format_each = "--define:%s")
args.add_all(ctx.attr.external, format_each = "--external:%s") args.add_all(ctx.attr.external, format_each = "--external:%s")
# disable the error limit and show all errors # disable the log limit and show all logs
args.add_joined(["--error-limit", "0"], join_with = "=") args.add_joined(["--log-limit", "0"], join_with = "=")
if ctx.attr.minify: if ctx.attr.minify:
args.add("--minify") args.add("--minify")
@ -94,8 +96,14 @@ def _esbuild_impl(ctx):
args.add_joined(["--outdir", js_out.path], join_with = "=") args.add_joined(["--outdir", js_out.path], join_with = "=")
else: else:
js_out = ctx.outputs.output js_out = ctx.outputs.output
outputs.append(js_out)
js_out_map = ctx.outputs.output_map js_out_map = ctx.outputs.output_map
outputs.extend([js_out, js_out_map]) if ctx.attr.sourcemap != "inline":
if js_out_map == None:
fail("output_map must be specified if sourcemap is not set to 'inline'")
outputs.append(js_out_map)
if ctx.outputs.output_css: if ctx.outputs.output_css:
outputs.append(ctx.outputs.output_css) outputs.append(ctx.outputs.output_css)
@ -110,15 +118,23 @@ def _esbuild_impl(ctx):
args.add_all([ctx.expand_location(arg) for arg in ctx.attr.args]) args.add_all([ctx.expand_location(arg) for arg in ctx.attr.args])
env = {}
if ctx.attr.max_threads > 0:
env["GOMAXPROCS"] = str(ctx.attr.max_threads)
execution_requirements = {}
if "no-remote-exec" in ctx.attr.tags:
execution_requirements = {"no-remote-exec": "1"}
ctx.actions.run( ctx.actions.run(
inputs = inputs, inputs = inputs,
outputs = outputs, outputs = outputs,
executable = ctx.executable.tool, executable = ctx.toolchains[TOOLCHAIN].binary,
arguments = [args], arguments = [args],
progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.output_dir else "Splitting", entry_point.short_path), progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.output_dir else "Splitting", entry_point.short_path),
execution_requirements = { execution_requirements = execution_requirements,
"no-remote-exec": "1", mnemonic = "esbuild",
}, env = env,
) )
return [ return [
@ -144,6 +160,7 @@ esbuild(
], ],
) )
``` ```
See https://esbuild.github.io/api/#define for more details See https://esbuild.github.io/api/#define for more details
""", """,
), ),
@ -160,6 +177,7 @@ See https://esbuild.github.io/api/#define for more details
"external": attr.string_list( "external": attr.string_list(
default = [], default = [],
doc = """A list of module names that are treated as external and not included in the resulting bundle doc = """A list of module names that are treated as external and not included in the resulting bundle
See https://esbuild.github.io/api/#external for more details See https://esbuild.github.io/api/#external for more details
""", """,
), ),
@ -168,6 +186,7 @@ See https://esbuild.github.io/api/#external for more details
mandatory = False, mandatory = False,
doc = """The output format of the bundle, defaults to iife when platform is browser doc = """The output format of the bundle, defaults to iife when platform is browser
and cjs when platform is node. If performing code splitting, defaults to esm. and cjs when platform is node. If performing code splitting, defaults to esm.
See https://esbuild.github.io/api/#format for more details See https://esbuild.github.io/api/#format for more details
""", """,
), ),
@ -175,11 +194,20 @@ See https://esbuild.github.io/api/#format for more details
doc = """Link the workspace root to the bin_dir to support absolute requires like 'my_wksp/path/to/file'. doc = """Link the workspace root to the bin_dir to support absolute requires like 'my_wksp/path/to/file'.
If source files need to be required then they can be copied to the bin_dir with copy_to_bin.""", If source files need to be required then they can be copied to the bin_dir with copy_to_bin.""",
), ),
"max_threads": attr.int(
mandatory = False,
doc = """Sets the `GOMAXPROCS` variable to limit the number of threads that esbuild can run with.
This can be useful if running many esbuild rule invocations in parallel, which has the potential to cause slowdown.
For general use, leave this attribute unset.
""",
),
"minify": attr.bool( "minify": attr.bool(
default = False, default = False,
doc = """Minifies the bundle with the built in minification. doc = """Minifies the bundle with the built in minification.
Removes whitespace, shortens identifieres and uses equivalent but shorter syntax. Removes whitespace, shortens identifieres and uses equivalent but shorter syntax.
Sets all --minify-* flags Sets all --minify-* flags
See https://esbuild.github.io/api/#minify for more details See https://esbuild.github.io/api/#minify for more details
""", """,
), ),
@ -190,6 +218,7 @@ See https://esbuild.github.io/api/#minify for more details
"output_dir": attr.bool( "output_dir": attr.bool(
default = False, default = False,
doc = """If true, esbuild produces an output directory containing all the output files from code splitting doc = """If true, esbuild produces an output directory containing all the output files from code splitting
See https://esbuild.github.io/api/#splitting for more details See https://esbuild.github.io/api/#splitting for more details
""", """,
), ),
@ -205,13 +234,23 @@ See https://esbuild.github.io/api/#splitting for more details
default = "browser", default = "browser",
values = ["node", "browser", "neutral", ""], values = ["node", "browser", "neutral", ""],
doc = """The platform to bundle for. doc = """The platform to bundle for.
See https://esbuild.github.io/api/#platform for more details See https://esbuild.github.io/api/#platform for more details
""", """,
), ),
"sourcemap": attr.string(
values = ["external", "inline", "both", ""],
mandatory = False,
doc = """Defines where sourcemaps are output and how they are included in the bundle. By default, a separate `.js.map` file is generated and referenced by the bundle. If 'external', a separate `.js.map` file is generated but not referenced by the bundle. If 'inline', a sourcemap is generated and its contents are inlined into the bundle (and no external sourcemap file is created). If 'both', a sourcemap is inlined and a `.js.map` file is created.
See https://esbuild.github.io/api/#sourcemap for more details
""",
),
"sources_content": attr.bool( "sources_content": attr.bool(
mandatory = False, mandatory = False,
default = False, default = False,
doc = """If False, omits the `sourcesContent` field from generated source maps doc = """If False, omits the `sourcesContent` field from generated source maps
See https://esbuild.github.io/api/#sources-content for more details See https://esbuild.github.io/api/#sources-content for more details
""", """,
), ),
@ -219,36 +258,37 @@ See https://esbuild.github.io/api/#sources-content for more details
allow_files = True, allow_files = True,
default = [], default = [],
doc = """Non-entry point JavaScript source files from the workspace. doc = """Non-entry point JavaScript source files from the workspace.
You must not repeat file(s) passed to entry_point""", You must not repeat file(s) passed to entry_point""",
), ),
"target": attr.string( "target": attr.string(
default = "es2015", default = "es2015",
doc = """Environment target (e.g. es2017, chrome58, firefox57, safari11, doc = """Environment target (e.g. es2017, chrome58, firefox57, safari11,
edge16, node10, default esnext) edge16, node10, default esnext)
See https://esbuild.github.io/api/#target for more details See https://esbuild.github.io/api/#target for more details
""", """,
), ),
"tool": attr.label(
allow_single_file = True,
mandatory = True,
executable = True,
cfg = "exec",
doc = "An executable for the esbuild binary",
),
}, },
implementation = _esbuild_impl, implementation = _esbuild_impl,
doc = """Runs the esbuild bundler under Bazel doc = """Runs the esbuild bundler under Bazel
For further information about esbuild, see https://esbuild.github.io/ For further information about esbuild, see https://esbuild.github.io/
""", """,
toolchains = [
TOOLCHAIN,
],
) )
def esbuild_macro(name, output_dir = False, output_css = False, **kwargs): def esbuild_macro(name, output_dir = False, output_css = False, **kwargs):
"""esbuild helper macro around the `esbuild_bundle` rule """esbuild helper macro around the `esbuild_bundle` rule
For a full list of attributes, see the `esbuild_bundle` rule For a full list of attributes, see the `esbuild_bundle` rule
Args: Args:
name: The name used for this rule and output files name: The name used for this rule and output files
output_dir: If `True`, produce a code split bundle in an output directory output_dir: If `True`, produce a code split bundle in an output directory
output_css: If `True`, declare a .css file will be outputted, which is the output_css: If `True`, declare name.css as an output, which is the
case when your code imports a css file. case when your code imports a css file.
**kwargs: All other args from `esbuild_bundle` **kwargs: All other args from `esbuild_bundle`
""" """
@ -260,10 +300,19 @@ def esbuild_macro(name, output_dir = False, output_css = False, **kwargs):
**kwargs **kwargs
) )
else: else:
output = "%s.js" % name
if "output" in kwargs:
output = kwargs.pop("output")
output_map = None
sourcemap = kwargs.get("sourcemap", None)
if sourcemap != "inline":
output_map = "%s.map" % output
esbuild( esbuild(
name = name, name = name,
output = "%s.js" % name, output = output,
output_map = "%s.js.map" % name, output_map = output_map,
output_css = None if not output_css else "%s.css" % name, output_css = None if not output_css else "%s.css" % name,
**kwargs **kwargs
) )

View File

@ -53,6 +53,8 @@ esbuild(
args = [ args = [
"--global-name=anki", "--global-name=anki",
"--inject:$(location //ts:protobuf-shim.js)", "--inject:$(location //ts:protobuf-shim.js)",
"--resolve-extensions=.mjs,.js",
"--log-level=warning",
], ],
entry_point = "index.ts", entry_point = "index.ts",
external = [ external = [