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
# incrementally compile Anki crates in fastbuild mode, but not release mode.
# The worker must be separately enabled; see docs/development.md
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
# disable incremental compilation in release mode
build:opt -c opt --@rules_rust//:experimental_incremental_prefixes=
# 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
# with the optional Rust worker.
# still running, so shut them down at the end of the build.
build:windows --worker_quit_after_build
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("@com_github_ali5h_rules_pip//:defs.bzl", "pip_import")
load("//pip/pyqt5:defs.bzl", "install_pyqt5")
load("//ts/esbuild:esbuild_repo.bzl", "esbuild_dependencies")
anki_version = "2.1.44"
@ -52,3 +53,5 @@ def setup_deps():
)
sass_repositories()
esbuild_dependencies()

View File

@ -153,7 +153,7 @@ following in your user.bazelrc file to enable incremental compilation
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

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.
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)
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)
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 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:
for field in changes.DESCRIPTOR.fields:
if field.name != "kind":

View File

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

View File

@ -16,7 +16,7 @@ from typing import List, Optional, Sequence
from anki.cards import CardId
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.notes import Note
from anki.notes import NoteId
from anki.utils import ids2str, intTime
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;
# schedv2 defines separate version
def _deckLimit(self) -> str:
self.col.decks.update_active()
return ids2str(self.col.decks.active())
return ids2str(
self.col.decks.deck_and_child_ids(self.col.decks.get_current_id())
)
# Filtered deck handling
##########################################################################
@ -123,20 +124,31 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
) -> None:
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(
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:
mode = BuryOrSuspend.BURY_USER
else:
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:
self.bury_cards(note.card_ids())
def bury_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount:
return self.col._backend.bury_or_suspend_cards(
card_ids=[], note_ids=note_ids, mode=BuryOrSuspend.BURY_USER
)
# Resetting/rescheduling
##########################################################################

View File

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

View File

@ -48,7 +48,16 @@ class Scheduler(SchedulerBaseWithLegacy):
self.reps = 0
self._haveQueues = False
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
##########################################################################
@ -65,8 +74,8 @@ class Scheduler(SchedulerBaseWithLegacy):
##########################################################################
def reset(self) -> None:
self.col.decks.update_active()
self._updateCutoff()
self._current_deck_id = self.col.decks.selected()
self._update_active_decks()
self._reset_counts()
self._resetLrn()
self._resetRev()
@ -74,10 +83,8 @@ class Scheduler(SchedulerBaseWithLegacy):
self._haveQueues = True
def _reset_counts(self) -> None:
tree = self.deck_due_tree(self.col.decks.selected())
node = self.col.decks.find_deck_in_tree(
tree, DeckId(int(self.col.conf["curDeck"]))
)
tree = self.deck_due_tree(self._current_deck_id)
node = self.col.decks.find_deck_in_tree(tree, self._current_deck_id)
if not node:
# current deck points to a missing deck
self.newCount = 0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from typing import Any, Optional
import aqt
from anki.collection import OpChanges
@ -17,6 +17,7 @@ from aqt.operations.deck import (
remove_decks,
rename_deck,
reparent_decks,
set_current_deck,
set_deck_collapsed,
)
from aqt.qt import *
@ -76,8 +77,10 @@ class DeckBrowser:
if self._refresh_needed:
self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
if self.mw.col.op_affects_study_queue(changes):
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if changes.study_queues and handler is not self:
self._refresh_needed = True
if focused:
@ -94,7 +97,7 @@ class DeckBrowser:
else:
cmd = url
if cmd == "open":
self._selDeck(arg)
self.set_current_deck(DeckId(int(arg)))
elif cmd == "opts":
self._showOptions(arg)
elif cmd == "shared":
@ -117,9 +120,10 @@ class DeckBrowser:
self.refresh()
return False
def _selDeck(self, did: str) -> None:
self.mw.col.decks.select(DeckId(int(did)))
self.mw.onOverview()
def set_current_deck(self, deck_id: DeckId) -> None:
set_current_deck(parent=self.mw, deck_id=deck_id).success(
lambda _: self.mw.onOverview()
).run_in_background(initiator=self)
# HTML generation
##########################################################################
@ -276,7 +280,9 @@ class DeckBrowser:
if not new_name or new_name == deck.name:
return
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)
@ -291,18 +297,20 @@ class DeckBrowser:
if node:
node.collapsed = not node.collapsed
set_deck_collapsed(
mw=self.mw,
parent=self.mw,
deck_id=did,
collapsed=node.collapsed,
scope=DeckCollapseScope.REVIEWER,
)
).run_in_background()
self._renderPage(reuse=True)
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:
remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
remove_decks(parent=self.mw, deck_ids=[did]).run_in_background()
# Top buttons
######################################################################
@ -333,7 +341,8 @@ class DeckBrowser:
openLink(f"{aqt.appShared}decks/")
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
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import Optional
import aqt.editor
from anki.collection import OpChanges
from anki.errors import NotFoundError
from aqt import gui_hooks
from aqt.operations import OpMeta
from aqt.qt import *
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)
self.show()
def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None:
if changes.editor and meta.handler is not self.editor:
def on_operation_did_execute(
self, changes: OpChanges, handler: Optional[object]
) -> None:
if changes.editor and handler is not self.editor:
# reload note
note = self.editor.note
try:

View File

@ -100,7 +100,7 @@ class Editor:
redrawing.
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.
"""
@ -558,7 +558,9 @@ class Editor:
def _save_current_note(self) -> None:
"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]]:
return [

View File

@ -310,7 +310,9 @@ class FilteredDeckConfigDialog(QDialog):
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
########################################################

View File

@ -3,11 +3,13 @@
from __future__ import annotations
from typing import List, Optional, Sequence
from typing import List, Sequence
import aqt
from anki.notes import NoteId
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.utils import (
HelpPage,
@ -22,63 +24,10 @@ from aqt.utils import (
save_combo_index_for_session,
save_is_checked,
saveGeom,
tooltip,
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):
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.ignoreCase, self.COMBO_NAME + "ignoreCase")
# tags?
if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
@ -157,23 +105,22 @@ class FindAndReplaceDialog(QDialog):
regex=regex,
match_case=match_case,
)
return
if self.form.field.currentIndex() == 0:
field = None
else:
field = self.field_names[self.form.field.currentIndex() - 2]
# fields
if self.form.field.currentIndex() == 0:
field = None
else:
field = self.field_names[self.form.field.currentIndex() - 2]
find_and_replace(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
find_and_replace(
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
super().accept()

View File

@ -21,7 +21,6 @@ from typing import (
List,
Literal,
Optional,
Protocol,
Sequence,
TextIO,
Tuple,
@ -40,15 +39,7 @@ import aqt.toolbar
import aqt.webview
from anki import hooks
from anki._backend import RustBackend as _RustBackend
from anki.collection import (
Collection,
Config,
OpChanges,
OpChangesAfterUndo,
OpChangesWithCount,
OpChangesWithId,
UndoStatus,
)
from anki.collection import Collection, Config, OpChanges, UndoStatus
from anki.decks import DeckDict, DeckId
from anki.hooks import runHook
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.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer
from aqt.operations import OpMeta
from aqt.operations.collection import undo
from aqt.operations.deck import set_current_deck
from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import *
from aqt.qt import sip
@ -92,30 +83,6 @@ from aqt.utils import (
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()
MainWindowState = Literal[
@ -123,6 +90,9 @@ MainWindowState = Literal[
]
T = TypeVar("T")
class AnkiQt(QMainWindow):
col: Collection
pm: ProfileManagerType
@ -733,10 +703,9 @@ class AnkiQt(QMainWindow):
) -> None:
"""Run an operation that queries the DB on a background thread.
Similar interface to perform_op(), but intended to be used for operations
that do not change collection state. Undo status will not be changed,
and `operation_did_execute` will not fire. No progress window will
be shown either.
Intended to be used for operations that do not change collection
state. Undo status will not be changed, and `operation_did_execute`
will not fire. No progress window will be shown either.
`operations_will|did_execute` will still fire, so the UI can defer
updates during a background task.
@ -766,71 +735,6 @@ class AnkiQt(QMainWindow):
# 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:
if not self._background_op_count:
gui_hooks.backend_will_block()
@ -842,27 +746,6 @@ class AnkiQt(QMainWindow):
gui_hooks.backend_did_block()
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:
"""Fire the `operation_did_execute` hook with everything marked as changed,
after legacy code has called .reset()"""
@ -872,15 +755,17 @@ class AnkiQt(QMainWindow):
setattr(op, field.name, True)
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."
focused = current_top_level_widget() == self
if self.state == "review":
dirty = self.reviewer.op_executed(changes, focused)
dirty = self.reviewer.op_executed(changes, handler, focused)
elif self.state == "overview":
dirty = self.overview.op_executed(changes, focused)
dirty = self.overview.op_executed(changes, handler, focused)
elif self.state == "deckBrowser":
dirty = self.deckBrowser.op_executed(changes, focused)
dirty = self.deckBrowser.op_executed(changes, handler, focused)
else:
dirty = False
@ -908,7 +793,7 @@ class AnkiQt(QMainWindow):
def reset(self, unused_arg: bool = False) -> None:
"""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:
# fire new `operation_did_execute` hook first. If the overview
# or review screen are currently open, they will rebuild the study
@ -1218,7 +1103,7 @@ title="%s" %s>%s</button>""" % (
def undo(self) -> None:
"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:
"""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"])
if ret.name:
self.col.decks.select(self.col.decks.id(ret.name))
self.moveToState("overview")
# fixme: this is silly, it should be returning an ID
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:
show_empty_cards(self)

View File

@ -1,16 +1,142 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from dataclasses import dataclass
from typing import Optional
from __future__ import annotations
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 OpMeta:
"""Metadata associated with an operation.
class HasChangesProperty(Protocol):
changes: OpChanges
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 anki.cards import CardId
from anki.collection import OpChanges
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:
mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id))
def set_card_deck(
*, 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:
mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids))
def set_card_flag(
*,
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
import aqt
from anki.collection import LegacyCheckpoint, LegacyReviewUndo, OpChangesAfterUndo
from anki.collection import LegacyCheckpoint, LegacyReviewUndo
from anki.errors import UndoEmpty
from anki.types import assert_exhaustive
from aqt import gui_hooks
from aqt.operations import CollectionOp
from aqt.qt import QWidget
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."
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:
if isinstance(exc, UndoEmpty):
# backend has no undo, but there may be a checkpoint
# or v1/v2 review waiting
_legacy_undo(mw=mw, parent=parent)
_legacy_undo(parent=parent)
else:
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"
just_refresh_reviewer = False

View File

@ -3,85 +3,80 @@
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 aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.operations import OpMeta
from aqt import QWidget
from aqt.operations import CollectionOp
from aqt.utils import getOnlyText, tooltip, tr
def remove_decks(
*,
mw: AnkiQt,
parent: QWidget,
deck_ids: Sequence[DeckId],
) -> None:
mw.perform_op(
lambda: mw.col.decks.remove(deck_ids),
success=lambda out: tooltip(
tr.browsing_cards_deleted(count=out.count), parent=parent
),
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.decks.remove(deck_ids)).success(
lambda out: tooltip(tr.browsing_cards_deleted(count=out.count), parent=parent)
)
def reparent_decks(
*, mw: AnkiQt, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId
) -> None:
mw.perform_op(
lambda: mw.col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent),
success=lambda out: tooltip(
*, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent, lambda col: col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent)
).success(
lambda out: tooltip(
tr.browsing_reparented_decks(count=out.count), parent=parent
),
)
)
def rename_deck(
*,
mw: AnkiQt,
parent: QWidget,
deck_id: DeckId,
new_name: str,
after_rename: Callable[[], None] = None,
) -> None:
mw.perform_op(
lambda: mw.col.decks.rename(deck_id, new_name), after_hooks=after_rename
) -> CollectionOp[OpChanges]:
return CollectionOp(
parent,
lambda col: col.decks.rename(deck_id, new_name),
)
def add_deck_dialog(
*,
mw: AnkiQt,
parent: QWidget,
default_text: str = "",
success: PerformOpOptionalSuccessCallback = None,
) -> None:
) -> Optional[CollectionOp[OpChangesWithId]]:
if name := getOnlyText(
tr.decks_new_deck_name(), default=default_text, parent=parent
).strip():
add_deck(mw=mw, name=name, success=success)
return add_deck(parent=parent, name=name)
else:
return None
def add_deck(
*, mw: AnkiQt, name: str, success: PerformOpOptionalSuccessCallback = None
) -> None:
mw.perform_op(
lambda: mw.col.decks.add_normal_deck_with_name(name),
success=success,
)
def add_deck(*, parent: QWidget, name: str) -> CollectionOp[OpChangesWithId]:
return CollectionOp(parent, lambda col: col.decks.add_normal_deck_with_name(name))
def set_deck_collapsed(
*,
mw: AnkiQt,
parent: QWidget,
deck_id: DeckId,
collapsed: bool,
scope: DeckCollapseScope.V,
handler: Optional[object] = None,
) -> None:
mw.perform_op(
lambda: mw.col.decks.set_collapsed(
) -> CollectionOp[OpChanges]:
return CollectionOp(
parent,
lambda col: col.decks.set_collapsed(
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 anki.collection import OpChanges, OpChangesWithCount
from anki.decks import DeckId
from anki.notes import Note, NoteId
from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.operations import OpMeta
from aqt.operations import CollectionOp
from aqt.qt import QWidget
from aqt.utils import tooltip, tr
def add_note(
*,
mw: AnkiQt,
parent: QWidget,
note: Note,
target_deck_id: DeckId,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success)
) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.add_note(note, target_deck_id))
def update_note(*, mw: AnkiQt, note: Note, handler: Optional[object]) -> None:
mw.perform_op(
lambda: mw.col.update_note(note),
meta=OpMeta(handler=handler),
)
def update_note(*, parent: QWidget, note: Note) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.update_note(note))
def remove_notes(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[NoteId],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success)
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).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
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.notes import NoteId
from anki.scheduler import FilteredDeckForUpdate
from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.operations import CollectionOp
from aqt.qt import *
from aqt.utils import disable_help_button, getText, tooltip, tr
def set_due_date_dialog(
*,
mw: aqt.AnkiQt,
parent: QWidget,
card_ids: Sequence[CardId],
config_key: Optional[Config.String.Key.V],
) -> None:
) -> Optional[CollectionOp[OpChanges]]:
assert aqt.mw
if not card_ids:
return
return None
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(
[
@ -43,35 +48,38 @@ def set_due_date_dialog(
title=tr.actions_set_due_date(),
)
if not success or not days.strip():
return
mw.perform_op(
lambda: mw.col.sched.set_due_date(card_ids, days, config_key),
success=lambda _: tooltip(
tr.scheduling_set_due_date_done(cards=len(card_ids)),
parent=parent,
),
)
return None
else:
return CollectionOp(
parent, lambda col: col.sched.set_due_date(card_ids, days, config_key)
).success(
lambda _: tooltip(
tr.scheduling_set_due_date_done(cards=len(card_ids)),
parent=parent,
)
)
def forget_cards(
*, mw: aqt.AnkiQt, parent: QWidget, card_ids: Sequence[CardId]
) -> None:
if not card_ids:
return
mw.perform_op(
lambda: mw.col.sched.schedule_cards_as_new(card_ids),
success=lambda _: tooltip(
*, parent: QWidget, card_ids: Sequence[CardId]
) -> CollectionOp[OpChanges]:
return CollectionOp(
parent, lambda col: col.sched.schedule_cards_as_new(card_ids)
).success(
lambda _: tooltip(
tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent
),
)
)
def reposition_new_cards_dialog(
*, mw: AnkiQt, parent: QWidget, card_ids: Sequence[CardId]
) -> None:
*, parent: QWidget, card_ids: Sequence[CardId]
) -> Optional[CollectionOp[OpChangesWithCount]]:
from aqt import mw
assert mw
assert mw.col.db
row = mw.col.db.first(
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()
if not d.exec_():
return
return None
start = frm.start.value()
step = frm.step.value()
randomize = frm.randomize.isChecked()
shift = frm.shift.isChecked()
reposition_new_cards(
mw=mw,
return reposition_new_cards(
parent=parent,
card_ids=card_ids,
starting_from=start,
@ -112,89 +119,80 @@ def reposition_new_cards_dialog(
def reposition_new_cards(
*,
mw: AnkiQt,
parent: QWidget,
card_ids: Sequence[CardId],
starting_from: int,
step_size: int,
randomize: bool,
shift_existing: bool,
) -> None:
mw.perform_op(
lambda: mw.col.sched.reposition_new_cards(
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent,
lambda col: col.sched.reposition_new_cards(
card_ids=card_ids,
starting_from=starting_from,
step_size=step_size,
randomize=randomize,
shift_existing=shift_existing,
),
success=lambda out: tooltip(
).success(
lambda out: tooltip(
tr.browsing_changed_new_position(count=out.count), parent=parent
),
)
)
def suspend_cards(
*,
mw: AnkiQt,
parent: QWidget,
card_ids: Sequence[CardId],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success)
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.sched.suspend_cards(card_ids))
def suspend_note(
*,
mw: AnkiQt,
note_id: NoteId,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
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),
)
parent: QWidget,
note_ids: Sequence[NoteId],
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.sched.suspend_notes(note_ids))
def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[CardId]) -> None:
mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids))
def unsuspend_cards(
*, parent: QWidget, card_ids: Sequence[CardId]
) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.sched.unsuspend_cards(card_ids))
def bury_cards(
*,
mw: AnkiQt,
parent: QWidget,
card_ids: Sequence[CardId],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success)
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.sched.bury_cards(card_ids))
def bury_note(
def bury_notes(
*,
mw: AnkiQt,
note_id: NoteId,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
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),
)
parent: QWidget,
note_ids: Sequence[NoteId],
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.sched.bury_notes(note_ids))
def rebuild_filtered_deck(*, mw: AnkiQt, deck_id: DeckId) -> None:
mw.perform_op(lambda: mw.col.sched.rebuild_filtered_deck(deck_id))
def rebuild_filtered_deck(
*, 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:
mw.perform_op(lambda: mw.col.sched.empty_filtered_deck(deck_id))
def empty_filtered_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]:
return CollectionOp(parent, lambda col: col.sched.empty_filtered_deck(deck_id))
def add_or_update_filtered_deck(
*,
mw: AnkiQt,
parent: QWidget,
deck: FilteredDeckForUpdate,
success: PerformOpOptionalSuccessCallback,
) -> None:
mw.perform_op(
lambda: mw.col.sched.add_or_update_filtered_deck(deck),
success=success,
)
) -> CollectionOp[OpChangesWithId]:
return CollectionOp(parent, lambda col: col.sched.add_or_update_filtered_deck(deck))

View File

@ -3,90 +3,116 @@
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 aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback
from aqt import QWidget
from aqt.operations import CollectionOp
from aqt.utils import showInfo, tooltip, tr
def add_tags_to_notes(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[NoteId],
space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(
lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags)
).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
)
def remove_tags_from_notes(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[NoteId],
space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(
lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags)
).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
)
def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None:
mw.perform_op(
mw.col.tags.clear_unused_tags,
success=lambda out: tooltip(
def clear_unused_tags(*, parent: QWidget) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success(
lambda out: tooltip(
tr.browsing_removed_unused_tags_count(count=out.count), parent=parent
),
)
)
def rename_tag(
*,
mw: AnkiQt,
parent: QWidget,
current_name: str,
new_name: str,
after_rename: Callable[[], None],
) -> None:
) -> CollectionOp[OpChangesWithCount]:
def success(out: OpChangesWithCount) -> None:
if out.count:
tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
else:
showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent)
mw.perform_op(
lambda: mw.col.tags.rename(old=current_name, new=new_name),
success=success,
after_hooks=after_rename,
)
return CollectionOp(
parent,
lambda col: col.tags.rename(old=current_name, new=new_name),
).success(success)
def remove_tags_from_all_notes(
*, mw: AnkiQt, parent: QWidget, space_separated_tags: str
) -> None:
mw.perform_op(
lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags),
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=parent
),
*, parent: QWidget, space_separated_tags: str
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags)
).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
)
def reparent_tags(
*, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str
) -> None:
mw.perform_op(
lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent),
success=lambda out: tooltip(
tr.browsing_notes_updated(count=out.count), parent=parent
),
*, parent: QWidget, tags: Sequence[str], new_parent: str
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent)
).success(
lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)
)
def set_tag_collapsed(*, mw: AnkiQt, tag: str, collapsed: bool) -> None:
mw.perform_op(lambda: mw.col.tags.set_collapsed(tag=tag, collapsed=collapsed))
def set_tag_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:
self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
if self.mw.col.op_affects_study_queue(changes):
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if changes.study_queues:
self._refresh_needed = True
if focused:
@ -117,12 +119,14 @@ class Overview:
return self.mw.col.decks.current()["dyn"]
def rebuild_current_filtered_deck(self) -> None:
if self._current_deck_is_filtered():
rebuild_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected())
rebuild_filtered_deck(
parent=self.mw, deck_id=self.mw.col.decks.selected()
).run_in_background()
def empty_current_filtered_deck(self) -> None:
if self._current_deck_is_filtered():
empty_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected())
empty_filtered_deck(
parent=self.mw, deck_id=self.mw.col.decks.selected()
).run_in_background()
def onCustomStudyKey(self) -> None:
if not self._current_deck_is_filtered():

View File

@ -14,7 +14,7 @@ from PyQt5.QtCore import Qt
from anki import hooks
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.utils import stripHTML
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.scheduling import (
bury_cards,
bury_note,
bury_notes,
set_due_date_dialog,
suspend_cards,
suspend_note,
@ -38,7 +38,6 @@ from aqt.webview import AnkiWebView
class RefreshNeeded(Enum):
NO = auto()
NOTE_TEXT = auto()
QUEUES = auto()
@ -71,7 +70,7 @@ class Reviewer:
self._recordedAudio: Optional[str] = None
self.typeCorrect: str = None # web init happens before this is set
self.state: Optional[str] = None
self._refresh_needed = RefreshNeeded.NO
self._refresh_needed: Optional[RefreshNeeded] = None
self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech)
@ -102,29 +101,25 @@ class Reviewer:
self.mw.col.reset()
self.nextCard()
self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO
self._refresh_needed = None
elif self._refresh_needed is RefreshNeeded.NOTE_TEXT:
self._redraw_current_card()
self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO
self._refresh_needed = None
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
if changes.note and changes.kind == OpChanges.UPDATE_NOTE_TAGS:
self.card.load()
self._update_mark_icon()
elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG:
# 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
elif changes.note or changes.notetype or changes.tag:
self._refresh_needed = RefreshNeeded.NOTE_TEXT
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if handler is not self:
if changes.study_queues:
self._refresh_needed = RefreshNeeded.QUEUES
elif changes.editor:
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()
return self._refresh_needed is not RefreshNeeded.NO
return bool(self._refresh_needed)
def _redraw_current_card(self) -> None:
self.card.load()
@ -830,63 +825,70 @@ time = %(time)d;
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 redraw_flag(out: OpChanges) -> None:
self.card.load()
self._update_flag_icon()
# need to toggle off?
if self.card.user_flag() == desired_flag:
flag = 0
else:
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 redraw_mark(out: OpChangesWithCount) -> None:
self.card.load()
self._update_mark_icon()
note = self.card.note()
if note.has_tag(MARKED_TAG):
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:
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:
if self.mw.state != "review" or not self.card:
return
set_due_date_dialog(
mw=self.mw,
parent=self.mw,
card_ids=[self.card.id],
config_key=Config.String.SET_DUE_REVIEWER,
)
).run_in_background()
def suspend_current_note(self) -> None:
suspend_note(
mw=self.mw,
note_id=self.card.nid,
success=lambda _: tooltip(tr.studying_note_suspended()),
)
parent=self.mw,
note_ids=[self.card.nid],
).success(lambda _: tooltip(tr.studying_note_suspended())).run_in_background()
def suspend_current_card(self) -> None:
suspend_cards(
mw=self.mw,
parent=self.mw,
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:
bury_note(
mw=self.mw,
note_id=self.card.nid,
success=lambda _: tooltip(tr.studying_note_buried()),
)
bury_notes(
parent=self.mw,
note_ids=[self.card.nid],
).success(lambda _: tooltip(tr.studying_note_buried())).run_in_background()
def bury_current_card(self) -> None:
bury_cards(
mw=self.mw,
parent=self.mw,
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:
# 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:
return
# fixme: pass this back from the backend method instead
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)),
)
remove_notes(parent=self.mw, note_ids=[self.card.nid]).run_in_background()
def onRecordVoice(self) -> 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.clayout import CardLayout
from aqt.models import Models
from aqt.operations import OpMeta
from aqt.operations.deck import (
remove_decks,
rename_deck,
@ -173,6 +172,18 @@ class SidebarItem:
)
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):
def __init__(self, sidebar: SidebarTreeView, root: SidebarItem) -> None:
@ -420,8 +431,10 @@ class SidebarTreeView(QTreeView):
# Refreshing
###########################
def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None:
if changes.browser_sidebar and not meta.handler is self:
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> None:
if changes.browser_sidebar and not handler is self:
self._refresh_needed = True
if focused:
self.refresh_if_needed()
@ -431,13 +444,16 @@ class SidebarTreeView(QTreeView):
self.refresh()
self._refresh_needed = False
def refresh(
self, is_current: Optional[Callable[[SidebarItem], bool]] = None
) -> None:
def refresh(self) -> None:
"Refresh list. No-op if sidebar is not visible."
if not self.isVisible():
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:
# user may have closed browser
if sip.isdeleted(self):
@ -453,8 +469,8 @@ class SidebarTreeView(QTreeView):
self.search_for(self.current_search)
else:
self._expand_where_necessary(model)
if is_current:
self.restore_current(is_current)
if current_item:
self.restore_current(current_item)
self.setUpdatesEnabled(True)
@ -463,8 +479,8 @@ class SidebarTreeView(QTreeView):
self.mw.query_op(self._root_tree, success=on_done)
def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None:
if current := self.find_item(is_current):
def restore_current(self, current: SidebarItem) -> None:
if current := self.find_item(current.has_same_id):
index = self.model().index_for_item(current)
self.selectionModel().setCurrentIndex(
index, QItemSelectionModel.SelectCurrent
@ -615,8 +631,8 @@ class SidebarTreeView(QTreeView):
new_parent = DeckId(target.id)
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
@ -636,7 +652,9 @@ class SidebarTreeView(QTreeView):
else:
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
@ -931,8 +949,8 @@ class SidebarTreeView(QTreeView):
def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]:
full_name = head + node.name
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:
item = SidebarItem(
@ -977,11 +995,12 @@ class SidebarTreeView(QTreeView):
) -> None:
def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]:
return lambda expanded: set_deck_collapsed(
mw=self.mw,
parent=self,
deck_id=DeckId(node.deck_id),
collapsed=not expanded,
scope=DeckCollapseScope.BROWSER,
handler=self,
).run_in_background(
initiator=self,
)
for node in nodes:
@ -1164,27 +1183,27 @@ class SidebarTreeView(QTreeView):
def rename_deck(self, item: SidebarItem, new_name: str) -> None:
if not new_name:
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)
def after_fetch(deck: Deck) -> None:
if new_name == deck.name:
if full_name == deck.name:
return
rename_deck(
mw=self.mw,
parent=self,
deck_id=deck_id,
new_name=new_name,
after_rename=lambda: self.refresh(
lambda other: other.item_type == SidebarItemType.DECK
and other.id == item.id
),
)
new_name=full_name,
).run_in_background()
self.mw.query_op(lambda: self.mw.col.get_deck(deck_id), success=after_fetch)
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
###########################
@ -1194,8 +1213,8 @@ class SidebarTreeView(QTreeView):
item.name = "..."
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:
if not new_name or new_name == item.name:
@ -1207,17 +1226,13 @@ class SidebarTreeView(QTreeView):
new_name = item.name_prefix + new_name
item.name = new_name_base
item.full_name = new_name
rename_tag(
mw=self.mw,
parent=self.browser,
current_name=old_name,
new_name=new_name,
after_rename=lambda: self.refresh(
lambda item: item.item_type == SidebarItemType.TAG
and item.full_name == new_name
),
)
).run_in_background()
# Saved searches
####################################
@ -1249,10 +1264,7 @@ class SidebarTreeView(QTreeView):
return
conf[name] = search
self._set_saved_searches(conf)
self.refresh(
lambda item: item.item_type == SidebarItemType.SAVED_SEARCH
and item.name == name
)
self.refresh()
def remove_saved_searches(self, _item: SidebarItem) -> None:
selected = self._selected_saved_searches()
@ -1276,10 +1288,8 @@ class SidebarTreeView(QTreeView):
conf[new_name] = filt
del conf[old_name]
self._set_saved_searches(conf)
self.refresh(
lambda item: item.item_type == SidebarItemType.SAVED_SEARCH
and item.name == new_name
)
item.name = new_name
self.refresh()
def save_current_search(self) -> None:
if (search := self._get_current_search()) is None:

View File

@ -175,4 +175,6 @@ class StudyDeck(QDialog):
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.utils import ids2str, isWin
from aqt import colors, gui_hooks
from aqt.operations import OpMeta
from aqt.qt import *
from aqt.theme import theme_manager
from aqt.utils import (
@ -180,7 +179,9 @@ class Table:
def redraw_cells(self) -> None:
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:
self._model.mark_cache_stale()
if focused:

View File

@ -4,7 +4,7 @@
"""
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

View File

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

View File

@ -9,17 +9,7 @@ check_untyped_defs = true
disallow_untyped_defs = True
strict_equality = true
[mypy-aqt.scheduling_ops]
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]
[mypy-aqt.operations.*]
no_strict_optional = false
[mypy-aqt.winpaths]

View File

@ -26,7 +26,6 @@ from anki.hooks import runFilter, runHook
from anki.models import NotetypeDict
from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit
import aqt.operations
"""
# Hook list
@ -464,14 +463,14 @@ hooks = [
Hook(
name="state_did_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.
""",
),
Hook(
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.
Changes can be inspected to determine whether the UI needs updating.
@ -489,7 +488,7 @@ hooks = [
),
Hook(
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
completes, as doing so will freeze the UI until the long-running operation
@ -498,7 +497,7 @@ hooks = [
),
Hook(
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
there are no outstanding ops.
""",

View File

@ -33,11 +33,11 @@ def register_repos():
maybe(
http_archive,
name = "rules_rust",
strip_prefix = "rules_rust-anki-2021-03-30",
strip_prefix = "rules_rust-anki-2021-04-09",
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
@ -87,40 +87,8 @@ def register_repos():
http_archive(
name = "build_bazel_rules_nodejs",
sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.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",
sha256 = "f533eeefc8fe1ddfe93652ec50f82373d0c431f7faabd5e6323f6903195ef227",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.3.0/rules_nodejs-3.3.0.tar.gz"],
)
# sass

View File

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

View File

@ -163,7 +163,7 @@ impl From<String> for Variable {
let kind = match name.as_str() {
"cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected"
| "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,
"val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up"
| "down" | "seconds" | "megs" => VariableKind::Any,

View File

@ -67,7 +67,7 @@ impl ConfigService for Backend {
col.transact_no_undo(|col| {
// ensure it's a well-formed object
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)
@ -98,7 +98,7 @@ impl ConfigService for Backend {
self.with_col(|col| {
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> {
@ -113,7 +113,7 @@ impl ConfigService for Backend {
self.with_col(|col| {
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> {

View File

@ -187,6 +187,16 @@ impl DecksService for Backend {
})
.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 {

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

View File

@ -1,8 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use pb::op_changes::Kind;
use crate::{
backend_proto as pb,
ops::OpChanges,
@ -10,27 +8,15 @@ use crate::{
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 {
fn from(c: OpChanges) -> Self {
pb::OpChanges {
kind: Kind::from(c.op) as i32,
card: c.changes.card,
note: c.changes.note,
deck: c.changes.deck,
tag: c.changes.tag,
notetype: c.changes.notetype,
preference: c.changes.preference,
config: c.changes.config,
browser_table: c.requires_browser_table_redraw(),
browser_sidebar: c.requires_browser_sidebar_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| {
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)
})
}
@ -105,7 +113,7 @@ impl SchedulingService for Backend {
fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::OpChanges> {
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;
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> {
let config = input.config_key.map(Into::into);
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))
}
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) = (
input.starting_from,
input.step_size,

View File

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

View File

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

View File

@ -94,7 +94,8 @@ impl Collection {
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
K: Into<&'a str>,
{
@ -130,6 +131,7 @@ impl Collection {
self.state.scheduler_info = None;
if let Some(mins) = mins {
self.set_config(ConfigKey::CreationOffset, &mins)
.map(|_| ())
} else {
self.remove_config(ConfigKey::CreationOffset)
}
@ -141,7 +143,7 @@ impl Collection {
pub(crate) fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> {
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> {
@ -151,7 +153,7 @@ impl Collection {
pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> {
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 {
@ -168,6 +170,7 @@ impl Collection {
pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> {
self.set_config(ConfigKey::NextNewCardPosition, &pos)
.map(|_| ())
}
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<()> {
self.state.scheduler_info = None;
self.set_config(ConfigKey::SchedulerVersion, &ver)
.map(|_| ())
}
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<()> {
self.set_config(ConfigKey::LearnAheadSecs, &secs)
.map(|_| ())
}
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<()> {
self.set_config(ConfigKey::NewReviewMix, &(mix as u8))
.map(|_| ())
}
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<()> {
self.set_config(ConfigKey::FirstDayOfWeek, &weekday)
.map(|_| ())
}
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<()> {
self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs)
.map(|_| ())
}
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<()> {
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<()> {
self.set_config(ConfigKey::CurrentNotetypeId, &ntid)
.map(|_| ())
}
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<()> {
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())
}
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)
}
}

View File

@ -21,16 +21,19 @@ impl Collection {
.get_config_entry(&entry.key)?
.ok_or_else(|| AnkiError::invalid_input("config disappeared"))?;
self.update_config_entry_undoable(entry, current)
.map(|_| ())
}
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)? {
self.update_config_entry_undoable(entry, original)
} else {
self.add_config_entry_undoable(entry)
self.add_config_entry_undoable(entry)?;
Ok(true)
}
}
@ -49,16 +52,19 @@ impl Collection {
Ok(())
}
/// True if new value differed.
fn update_config_entry_undoable(
&mut self,
entry: Box<ConfigEntry>,
original: Box<ConfigEntry>,
) -> Result<()> {
) -> Result<bool> {
if entry.value != original.value {
self.save_undo(UndoableConfigChange::Updated(original));
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
mod counts;
mod current;
mod filtered;
mod schema11;
mod tree;

View File

@ -402,19 +402,21 @@ impl Collection {
}
/// 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()?;
self.transact(Op::RemoveNote, |col| {
let mut card_count = 0;
for nid in nids {
let nid = *nid;
if let Some(_existing_note) = col.storage.get_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_note_only_undoable(nid, usn)?;
}
}
Ok(())
Ok(card_count)
})
}

View File

@ -23,7 +23,7 @@ pub enum Op {
RenameTag,
ReparentTag,
ScheduleAsNew,
SetDeck,
SetCardDeck,
SetDueDate,
SetFlag,
SortCards,
@ -34,6 +34,7 @@ pub enum Op {
UpdateNote,
UpdatePreferences,
UpdateTag,
SetCurrentDeck,
}
impl Op {
@ -55,7 +56,7 @@ impl Op {
Op::UpdateNote => tr.undo_update_note(),
Op::UpdatePreferences => tr.preferences_preferences(),
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::FindAndReplace => tr.browsing_find_and_replace(),
Op::ClearUnusedTags => tr.browsing_clear_unused_tags(),
@ -68,6 +69,7 @@ impl Op {
Op::RebuildFilteredDeck => tr.undo_build_filtered_deck(),
Op::EmptyFilteredDeck => tr.studying_empty(),
Op::ExpandCollapse => tr.undo_expand_collapse(),
Op::SetCurrentDeck => tr.browsing_change_deck(),
}
.into()
}
@ -80,7 +82,7 @@ pub struct StateChanges {
pub deck: bool,
pub tag: bool,
pub notetype: bool,
pub preference: bool,
pub config: bool,
}
#[derive(Debug, Clone, Copy)]
@ -134,7 +136,12 @@ impl OpChanges {
pub fn requires_study_queue_rebuild(&self) -> bool {
let c = &self.changes;
!matches!(self.op, Op::AnswerCard | Op::ExpandCollapse)
&& (c.card || c.deck || c.preference)
if self.op == Op::AnswerCard {
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
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub(crate) use crate::types::IntoNewtypeVec;
pub use crate::{
card::{Card, CardId},
collection::Collection,

View File

@ -33,7 +33,7 @@ mod test {
let queued = col.next_card()?.unwrap();
let nid = note.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 first = col.storage.get_card(cid)?.unwrap();

View File

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

View File

@ -308,13 +308,26 @@ impl super::SqliteStorage {
.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
.prepare_cached("select id from cards where nid = ? order by ord")?
.query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))?
.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.
pub(crate) fn search_siblings_for_bury(
&self,

View File

@ -68,3 +68,18 @@ macro_rules! define_newtype {
}
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::Revlog(_) => {}
UndoableChange::Queue(_) => {}
UndoableChange::Config(_) => {} // fixme: preferences?
UndoableChange::Config(_) => changes.config = true,
}
}

View File

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

View File

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

View File

@ -3,11 +3,6 @@ load("//ts/esbuild:upstream.bzl", _esbuild = "esbuild_macro")
def esbuild(name, **kwargs):
_esbuild(
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({
"//:release": True,
"//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
"""
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(":helpers.bzl", "filter_files", "generate_path_mapping", "resolve_js_input", "write_jsconfig_file")
load(":toolchain.bzl", "TOOLCHAIN")
def _esbuild_impl(ctx):
# For each dep, JSEcmaScriptModuleInfo is used if found, then JSModuleInfo and finally
@ -35,6 +29,9 @@ def _esbuild_impl(ctx):
elif hasattr(dep, "files"):
deps_depsets.append(dep.files)
if DefaultInfo in dep:
deps_depsets.append(dep[DefaultInfo].data_runfiles.files)
if NpmPackageInfo in dep:
deps_depsets.append(dep[NpmPackageInfo].sources)
npm_workspaces.append(dep[NpmPackageInfo].workspace)
@ -61,7 +58,12 @@ def _esbuild_impl(ctx):
args = ctx.actions.args()
args.add("--bundle", entry_point.path)
args.add("--sourcemap")
if len(ctx.attr.sourcemap) > 0:
args.add_joined(["--sourcemap", ctx.attr.sourcemap], join_with = "=")
else:
args.add("--sourcemap")
args.add("--preserve-symlinks")
args.add_joined(["--platform", ctx.attr.platform], 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.external, format_each = "--external:%s")
# disable the error limit and show all errors
args.add_joined(["--error-limit", "0"], join_with = "=")
# disable the log limit and show all logs
args.add_joined(["--log-limit", "0"], join_with = "=")
if ctx.attr.minify:
args.add("--minify")
@ -94,8 +96,14 @@ def _esbuild_impl(ctx):
args.add_joined(["--outdir", js_out.path], join_with = "=")
else:
js_out = ctx.outputs.output
outputs.append(js_out)
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:
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])
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(
inputs = inputs,
outputs = outputs,
executable = ctx.executable.tool,
executable = ctx.toolchains[TOOLCHAIN].binary,
arguments = [args],
progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.output_dir else "Splitting", entry_point.short_path),
execution_requirements = {
"no-remote-exec": "1",
},
execution_requirements = execution_requirements,
mnemonic = "esbuild",
env = env,
)
return [
@ -144,6 +160,7 @@ esbuild(
],
)
```
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(
default = [],
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
""",
),
@ -168,6 +186,7 @@ See https://esbuild.github.io/api/#external for more details
mandatory = False,
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.
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'.
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(
default = False,
doc = """Minifies the bundle with the built in minification.
Removes whitespace, shortens identifieres and uses equivalent but shorter syntax.
Sets all --minify-* flags
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(
default = False,
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
""",
),
@ -205,13 +234,23 @@ See https://esbuild.github.io/api/#splitting for more details
default = "browser",
values = ["node", "browser", "neutral", ""],
doc = """The platform to bundle for.
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(
mandatory = False,
default = False,
doc = """If False, omits the `sourcesContent` field from generated source maps
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,
default = [],
doc = """Non-entry point JavaScript source files from the workspace.
You must not repeat file(s) passed to entry_point""",
),
"target": attr.string(
default = "es2015",
doc = """Environment target (e.g. es2017, chrome58, firefox57, safari11,
edge16, node10, default esnext)
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,
doc = """Runs the esbuild bundler under Bazel
For further information about esbuild, see https://esbuild.github.io/
""",
toolchains = [
TOOLCHAIN,
],
)
def esbuild_macro(name, output_dir = False, output_css = False, **kwargs):
"""esbuild helper macro around the `esbuild_bundle` rule
For a full list of attributes, see the `esbuild_bundle` rule
Args:
name: The name used for this rule and output files
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.
**kwargs: All other args from `esbuild_bundle`
"""
@ -260,10 +300,19 @@ def esbuild_macro(name, output_dir = False, output_css = False, **kwargs):
**kwargs
)
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(
name = name,
output = "%s.js" % name,
output_map = "%s.js.map" % name,
output = output,
output_map = output_map,
output_css = None if not output_css else "%s.css" % name,
**kwargs
)

View File

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