Merge pull request #1072 from ankitects/refresh

Experimental changes to resetting
This commit is contained in:
Damien Elmes 2021-03-19 19:46:48 +10:00 committed by GitHub
commit 2ab39e7c76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
106 changed files with 4164 additions and 2429 deletions

View file

@ -140,3 +140,14 @@ browsing-edited-today = Edited
browsing-sidebar-due-today = Due browsing-sidebar-due-today = Due
browsing-sidebar-untagged = Untagged browsing-sidebar-untagged = Untagged
browsing-sidebar-overdue = Overdue browsing-sidebar-overdue = Overdue
browsing-row-deleted = (deleted)
browsing-removed-unused-tags-count =
{ $count ->
[one] Removed { $count } unused tag.
*[other] Removed { $count } unused tags.
}
browsing-changed-new-position =
{ $count ->
[one] Changed position of { $count } new card.
*[other] Changed position of { $count } new cards.
}

View file

@ -18,3 +18,6 @@ undo-update-note = Update Note
undo-update-card = Update Card undo-update-card = Update Card
undo-update-deck = Update Deck undo-update-deck = Update Deck
undo-forget-card = Forget Card undo-forget-card = Forget Card
undo-set-flag = Set Flag
# when dragging/dropping tags and decks in the sidebar
undo-reparent = Change Parent

View file

@ -36,7 +36,6 @@ qt-misc-please-select-a-deck = Please select a deck.
qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file. qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file.
qt-misc-processing = Processing... qt-misc-processing = Processing...
qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup? qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup?
qt-misc-resume-now = Resume Now
qt-misc-revert-to-backup = Revert to backup qt-misc-revert-to-backup = Revert to backup
qt-misc-reverted-to-state-prior-to = Reverted to state prior to '{ $val }'. qt-misc-reverted-to-state-prior-to = Reverted to state prior to '{ $val }'.
qt-misc-segoe-ui = "Segoe UI" qt-misc-segoe-ui = "Segoe UI"
@ -56,7 +55,6 @@ qt-misc-unable-to-move-existing-file-to = Unable to move existing file to trash
qt-misc-undo = Undo qt-misc-undo = Undo
qt-misc-undo2 = Undo { $val } qt-misc-undo2 = Undo { $val }
qt-misc-unexpected-response-code = Unexpected response code: { $val } qt-misc-unexpected-response-code = Unexpected response code: { $val }
qt-misc-waiting-for-editing-to-finish = Waiting for editing to finish.
qt-misc-would-you-like-to-download-it = Would you like to download it now? qt-misc-would-you-like-to-download-it = Would you like to download it now?
qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen. qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen.
qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again. qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again.

View file

@ -91,7 +91,17 @@ def copy_and_fix_pyi(source, dest):
with open(source) as input_file: with open(source) as input_file:
with open(dest, "w") as output_file: with open(dest, "w") as output_file:
for line in input_file.readlines(): for line in input_file.readlines():
# assigning to None is a syntax error
line = fix_none.sub(r"\1_ =", line) line = fix_none.sub(r"\1_ =", line)
# inheriting from the missing sip.sipwrapper definition
# causes missing attributes not to be detected, as it's treating
# the class as inheriting from Any
line = line.replace("sip.simplewrapper", "object")
line = line.replace("sip.wrapper", "object")
# remove blanket getattr in QObject which also causes missing
# attributes not to be detected
if "def __getattr__(self, name: str) -> typing.Any" in line:
continue
output_file.write(line) output_file.write(line)

View file

@ -196,7 +196,8 @@ class Card:
return self.flags & 0b111 return self.flags & 0b111
def set_user_flag(self, flag: int) -> None: def set_user_flag(self, flag: int) -> None:
assert 0 <= flag <= 7 print("use col.set_user_flag_for_cards() instead")
assert 0 <= flag <= 4
self.flags = (self.flags & ~0b111) | flag self.flags = (self.flags & ~0b111) | flag
# legacy # legacy

View file

@ -3,6 +3,22 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, List, Literal, Optional, Sequence, Tuple, Union
import anki._backend.backend_pb2 as _pb
# protobuf we publicly export - listed first to avoid circular imports
SearchNode = _pb.SearchNode
Progress = _pb.Progress
EmptyCardsReport = _pb.EmptyCardsReport
GraphPreferences = _pb.GraphPreferences
BuiltinSort = _pb.SortOrder.Builtin
Preferences = _pb.Preferences
UndoStatus = _pb.UndoStatus
OpChanges = _pb.OpChanges
OpChangesWithCount = _pb.OpChangesWithCount
DefaultsForAdding = _pb.DeckAndNotetype
import copy import copy
import os import os
import pprint import pprint
@ -12,12 +28,8 @@ import time
import traceback import traceback
import weakref import weakref
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, List, Literal, Optional, Sequence, Tuple, Union
import anki._backend.backend_pb2 as _pb import anki.latex
import anki.find
import anki.latex # sets up hook
import anki.template
from anki import hooks from anki import hooks
from anki._backend import RustBackend from anki._backend import RustBackend
from anki.cards import Card from anki.cards import Card
@ -45,16 +57,10 @@ from anki.utils import (
stripHTMLMedia, stripHTMLMedia,
) )
# public exports anki.latex.setup_hook()
SearchNode = _pb.SearchNode
SearchJoiner = Literal["AND", "OR"] SearchJoiner = Literal["AND", "OR"]
Progress = _pb.Progress
EmptyCardsReport = _pb.EmptyCardsReport
GraphPreferences = _pb.GraphPreferences
BuiltinSort = _pb.SortOrder.Builtin
Preferences = _pb.Preferences
UndoStatus = _pb.UndoStatus
DefaultsForAdding = _pb.DeckAndNotetype
@dataclass @dataclass
@ -195,23 +201,17 @@ class Collection:
flush = setMod flush = setMod
def modified_after_begin(self) -> bool: def modified_by_backend(self) -> bool:
# Until we can move away from long-running transactions, the Python # Until we can move away from long-running transactions, the Python
# code needs to know if transaction should be committed, so we need # code needs to know if the transaction should be committed, so we need
# to check if the backend updated the modification time. # to check if the backend updated the modification time.
return self.db.last_begin_at != self.mod return self.db.last_begin_at != self.mod
def save(self, name: Optional[str] = None, trx: bool = True) -> None: def save(self, name: Optional[str] = None, trx: bool = True) -> None:
"Flush, commit DB, and take out another write lock if trx=True." "Flush, commit DB, and take out another write lock if trx=True."
# commit needed? # commit needed?
if self.db.modified_in_python or self.modified_after_begin(): if self.db.modified_in_python or self.modified_by_backend():
if self.db.modified_in_python:
self.db.execute("update col set mod = ?", intTime(1000))
self.db.modified_in_python = False self.db.modified_in_python = False
else:
# modifications made by the backend will have already bumped
# mtime
pass
self.db.commit() self.db.commit()
if trx: if trx:
self.db.begin() self.db.begin()
@ -328,10 +328,12 @@ class Collection:
def get_note(self, id: int) -> Note: def get_note(self, id: int) -> Note:
return Note(self, id=id) return Note(self, id=id)
def update_note(self, note: Note) -> None: def update_note(self, note: Note) -> OpChanges:
"""Save note changes to database, and add an undo entry. """Save note changes to database, and add an undo entry.
Unlike note.flush(), this will invalidate any current checkpoint.""" Unlike note.flush(), this will invalidate any current checkpoint."""
self._backend.update_note(note=note._to_backend_note(), skip_undo_entry=False) return self._backend.update_note(
note=note._to_backend_note(), skip_undo_entry=False
)
getCard = get_card getCard = get_card
getNote = get_note getNote = get_note
@ -366,12 +368,14 @@ class Collection:
def new_note(self, notetype: NoteType) -> Note: def new_note(self, notetype: NoteType) -> Note:
return Note(self, notetype) return Note(self, notetype)
def add_note(self, note: Note, deck_id: int) -> None: def add_note(self, note: Note, deck_id: int) -> OpChanges:
note.id = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id) out = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id)
note.id = out.note_id
return out.changes
def remove_notes(self, note_ids: Sequence[int]) -> None: def remove_notes(self, note_ids: Sequence[int]) -> OpChanges:
hooks.notes_will_be_deleted(self, note_ids) hooks.notes_will_be_deleted(self, note_ids)
self._backend.remove_notes(note_ids=note_ids, card_ids=[]) return self._backend.remove_notes(note_ids=note_ids, card_ids=[])
def remove_notes_by_card(self, card_ids: List[int]) -> None: def remove_notes_by_card(self, card_ids: List[int]) -> None:
if hooks.notes_will_be_deleted.count(): if hooks.notes_will_be_deleted.count():
@ -445,8 +449,8 @@ class Collection:
"You probably want .remove_notes_by_card() instead." "You probably want .remove_notes_by_card() instead."
self._backend.remove_cards(card_ids=card_ids) self._backend.remove_cards(card_ids=card_ids)
def set_deck(self, card_ids: List[int], deck_id: int) -> None: def set_deck(self, card_ids: Sequence[int], deck_id: int) -> OpChanges:
self._backend.set_deck(card_ids=card_ids, deck_id=deck_id) return self._backend.set_deck(card_ids=card_ids, deck_id=deck_id)
def get_empty_cards(self) -> EmptyCardsReport: def get_empty_cards(self) -> EmptyCardsReport:
return self._backend.get_empty_cards() return self._backend.get_empty_cards()
@ -536,14 +540,26 @@ class Collection:
def find_and_replace( def find_and_replace(
self, self,
nids: List[int], *,
src: str, note_ids: Sequence[int],
dst: str, search: str,
regex: Optional[bool] = None, replacement: str,
field: Optional[str] = None, regex: bool = False,
fold: bool = True, field_name: Optional[str] = None,
) -> int: match_case: bool = False,
return anki.find.findReplace(self, nids, src, dst, regex, field, fold) ) -> OpChangesWithCount:
"Find and replace fields in a note. Returns changed note count."
return self._backend.find_and_replace(
nids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
field_name=field_name or "",
)
def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]:
return self._backend.field_names_for_notes(nids)
# returns array of ("dupestr", [nids]) # returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
@ -788,8 +804,6 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo) assert_exhaustive(self._undo)
assert False assert False
return status
def clear_python_undo(self) -> None: def clear_python_undo(self) -> None:
"""Clear the Python undo state. """Clear the Python undo state.
The backend will automatically clear backend undo state when The backend will automatically clear backend undo state when
@ -817,6 +831,18 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo) assert_exhaustive(self._undo)
assert False assert False
def op_affects_study_queue(self, changes: OpChanges) -> bool:
if changes.kind == changes.SET_CARD_FLAG:
return False
return changes.card or changes.deck or changes.preference
def op_made_changes(self, changes: OpChanges) -> bool:
for field in changes.DESCRIPTOR.fields:
if field.name != "kind":
if getattr(changes, field.name, False):
return True
return False
def _check_backend_undo_status(self) -> Optional[UndoStatus]: def _check_backend_undo_status(self) -> Optional[UndoStatus]:
"""Return undo status if undo available on backend. """Return undo status if undo available on backend.
If backend has undo available, clear the Python undo state.""" If backend has undo available, clear the Python undo state."""
@ -986,21 +1012,10 @@ table.review-log {{ {revlog_style} }}
self._logHnd.close() self._logHnd.close()
self._logHnd = None self._logHnd = None
# Card Flags
########################################################################## ##########################################################################
def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None: def set_user_flag_for_cards(self, flag: int, cids: Sequence[int]) -> OpChanges:
assert 0 <= flag <= 7 return self._backend.set_flag(card_ids=cids, flag=flag)
self.db.execute(
"update cards set flags = (flags & ~?) | ?, usn=?, mod=? where id in %s"
% ids2str(cids),
0b111,
flag,
self.usn(),
intTime(),
)
##########################################################################
def set_wants_abort(self) -> None: def set_wants_abort(self) -> None:
self._backend.set_wants_abort() self._backend.set_wants_abort()

View file

@ -11,6 +11,7 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
import anki # pylint: disable=unused-import import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
from anki.collection import OpChangesWithCount
from anki.consts import * from anki.consts import *
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_bytes from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_bytes
@ -138,7 +139,7 @@ class DeckManager:
assert cardsToo and childrenToo assert cardsToo and childrenToo
self.remove([did]) self.remove([did])
def remove(self, dids: List[int]) -> int: def remove(self, dids: Sequence[int]) -> OpChangesWithCount:
return self.col._backend.remove_decks(dids) return self.col._backend.remove_decks(dids)
def all_names_and_ids( def all_names_and_ids(

View file

@ -37,18 +37,19 @@ def findReplace(
fold: bool = True, fold: bool = True,
) -> int: ) -> int:
"Find and replace fields in a note. Returns changed note count." "Find and replace fields in a note. Returns changed note count."
return col._backend.find_and_replace( print("use col.find_and_replace() instead of findReplace()")
nids=nids, return col.find_and_replace(
note_ids=nids,
search=src, search=src,
replacement=dst, replacement=dst,
regex=regex, regex=regex,
match_case=not fold, match_case=not fold,
field_name=field, field_name=field,
) ).count
def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]: def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]:
return list(col._backend.field_names_for_notes(nids)) return list(col.field_names_for_note_ids(nids))
# Find duplicates # Find duplicates

View file

@ -178,4 +178,5 @@ def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
return msg return msg
def setup_hook() -> None:
hooks.card_did_render.append(on_card_did_render) hooks.card_did_render.append(on_card_did_render)

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import anki import anki
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
from anki.collection import OpChanges, OpChangesWithCount
from anki.config import Config from anki.config import Config
SchedTimingToday = _pb.SchedTimingTodayOut SchedTimingToday = _pb.SchedTimingTodayOut
@ -96,11 +97,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Suspending & burying # Suspending & burying
########################################################################## ##########################################################################
def unsuspend_cards(self, ids: List[int]) -> None: def unsuspend_cards(self, ids: Sequence[int]) -> OpChanges:
self.col._backend.restore_buried_and_suspended_cards(ids) return self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards(self, ids: List[int]) -> None: def unbury_cards(self, ids: List[int]) -> OpChanges:
self.col._backend.restore_buried_and_suspended_cards(ids) return self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards_in_current_deck( def unbury_cards_in_current_deck(
self, self,
@ -108,17 +109,17 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
) -> None: ) -> None:
self.col._backend.unbury_cards_in_current_deck(mode) self.col._backend.unbury_cards_in_current_deck(mode)
def suspend_cards(self, ids: Sequence[int]) -> None: def suspend_cards(self, ids: Sequence[int]) -> OpChanges:
self.col._backend.bury_or_suspend_cards( return self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND card_ids=ids, mode=BuryOrSuspend.SUSPEND
) )
def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None: def bury_cards(self, ids: Sequence[int], manual: bool = True) -> OpChanges:
if manual: if manual:
mode = BuryOrSuspend.BURY_USER mode = BuryOrSuspend.BURY_USER
else: else:
mode = BuryOrSuspend.BURY_SCHED mode = BuryOrSuspend.BURY_SCHED
self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode) return self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
def bury_note(self, note: Note) -> None: def bury_note(self, note: Note) -> None:
self.bury_cards(note.card_ids()) self.bury_cards(note.card_ids())
@ -126,16 +127,16 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Resetting/rescheduling # Resetting/rescheduling
########################################################################## ##########################################################################
def schedule_cards_as_new(self, card_ids: List[int]) -> None: def schedule_cards_as_new(self, card_ids: List[int]) -> OpChanges:
"Put cards at the end of the new queue." "Put cards at the end of the new queue."
self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True) return self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
def set_due_date( def set_due_date(
self, self,
card_ids: List[int], card_ids: List[int],
days: str, days: str,
config_key: Optional[Config.String.Key.V] = None, config_key: Optional[Config.String.Key.V] = None,
) -> None: ) -> OpChanges:
"""Set cards to be due in `days`, turning them into review cards if necessary. """Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7' `days` can be of the form '5' or '5..7'
If `config_key` is provided, provided days will be remembered in config.""" If `config_key` is provided, provided days will be remembered in config."""
@ -143,7 +144,9 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
key = Config.String(key=config_key) key = Config.String(key=config_key)
else: else:
key = None key = None
self.col._backend.set_due_date(card_ids=card_ids, days=days, config_key=key) return self.col._backend.set_due_date(
card_ids=card_ids, days=days, config_key=key
)
def resetCards(self, ids: List[int]) -> None: def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export." "Completely reset cards for export."
@ -164,20 +167,20 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Repositioning new cards # Repositioning new cards
########################################################################## ##########################################################################
def sortCards( def reposition_new_cards(
self, self,
cids: List[int], card_ids: Sequence[int],
start: int = 1, starting_from: int,
step: int = 1, step_size: int,
shuffle: bool = False, randomize: bool,
shift: bool = False, shift_existing: bool,
) -> None: ) -> OpChangesWithCount:
self.col._backend.sort_cards( return self.col._backend.sort_cards(
card_ids=cids, card_ids=card_ids,
starting_from=start, starting_from=starting_from,
step_size=step, step_size=step_size,
randomize=shuffle, randomize=randomize,
shift_existing=shift, shift_existing=shift_existing,
) )
def randomizeCards(self, did: int) -> None: def randomizeCards(self, did: int) -> None:
@ -201,3 +204,14 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# in order due? # in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM: if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did) self.randomizeCards(did)
# legacy
def sortCards(
self,
cids: List[int],
start: int = 1,
step: int = 1,
shuffle: bool = False,
shift: bool = False,
) -> None:
self.reposition_new_cards(cids, start, step, shuffle, shift)

View file

@ -18,10 +18,12 @@ from typing import Collection, List, Match, Optional, Sequence
import anki # pylint: disable=unused-import import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
import anki.collection import anki.collection
from anki.collection import OpChangesWithCount
from anki.utils import ids2str from anki.utils import ids2str
# public exports # public exports
TagTreeNode = _pb.TagTreeNode TagTreeNode = _pb.TagTreeNode
MARKED_TAG = "marked"
class TagManager: class TagManager:
@ -43,17 +45,8 @@ class TagManager:
# Registering and fetching tags # Registering and fetching tags
############################################################# #############################################################
def register( def clear_unused_tags(self) -> OpChangesWithCount:
self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False return self.col._backend.clear_unused_tags()
) -> None:
print("tags.register() is deprecated and no longer works")
def registerNotes(self, nids: Optional[List[int]] = None) -> None:
"Clear unused tags and add any missing tags from notes to the tag list."
self.clear_unused_tags()
def clear_unused_tags(self) -> None:
self.col._backend.clear_unused_tags()
def byDeck(self, did: int, children: bool = False) -> List[str]: def byDeck(self, did: int, children: bool = False) -> List[str]:
basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id"
@ -72,52 +65,53 @@ class TagManager:
"Set browser expansion state for tag, registering the tag if missing." "Set browser expansion state for tag, registering the tag if missing."
self.col._backend.set_tag_expanded(name=tag, expanded=expanded) self.col._backend.set_tag_expanded(name=tag, expanded=expanded)
# Bulk addition/removal from notes # Bulk addition/removal from specific notes
############################################################# #############################################################
def bulk_add(self, nids: List[int], tags: str) -> int: def bulk_add(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount:
"""Add space-separate tags to provided notes, returning changed count.""" """Add space-separate tags to provided notes, returning changed count."""
return self.col._backend.add_note_tags(nids=nids, tags=tags) return self.col._backend.add_note_tags(note_ids=note_ids, tags=tags)
def bulk_update( def bulk_remove(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount:
self, nids: Sequence[int], tags: str, replacement: str, regex: bool return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags)
) -> int:
"""Replace space-separated tags, returning changed count. # Find&replace
Tags replaced with an empty string will be removed.""" #############################################################
return self.col._backend.update_note_tags(
nids=nids, tags=tags, replacement=replacement, regex=regex def find_and_replace(
self,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> OpChangesWithCount:
"""Replace instances of 'search' with 'replacement' in tags.
Each tag is matched separately. If the replacement results in an empty string,
the tag will be removed."""
return self.col._backend.find_and_replace_tag(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
) )
def bulk_remove(self, nids: Sequence[int], tags: str) -> int: # Bulk addition/removal based on tag
return self.bulk_update(nids, tags, "", False) #############################################################
def rename(self, old: str, new: str) -> int: def rename(self, old: str, new: str) -> OpChangesWithCount:
"Rename provided tag, returning number of changed notes." "Rename provided tag and its children, returning number of changed notes."
nids = self.col.find_notes(anki.collection.SearchNode(tag=old)) return self.col._backend.rename_tags(current_prefix=old, new_prefix=new)
if not nids:
return 0
escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)
return self.bulk_update(nids, escaped_name, new, False)
def remove(self, tag: str) -> None: def remove(self, space_separated_tags: str) -> OpChangesWithCount:
self.col._backend.clear_tag(tag) "Remove the provided tag(s) and their children from notes and the tag list."
return self.col._backend.remove_tags(val=space_separated_tags)
def drag_drop(self, source_tags: List[str], target_tag: str) -> None: def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount:
"""Rename one or more source tags that were dropped on `target_tag`. """Change the parent of the provided tags.
If target_tag is "", tags will be placed at the top level.""" If new_parent is empty, tags will be reparented to the top-level."""
self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag) return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent)
# legacy routines
def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None:
"Add tags in bulk. TAGS is space-separated."
if add:
self.bulk_add(ids, tags)
else:
self.bulk_update(ids, tags, "", False)
def bulkRem(self, ids: List[int], tags: str) -> None:
self.bulkAdd(ids, tags, False)
# String-based utilities # String-based utilities
########################################################################## ##########################################################################
@ -169,3 +163,24 @@ class TagManager:
def inList(self, tag: str, tags: List[str]) -> bool: def inList(self, tag: str, tags: List[str]) -> bool:
"True if TAG is in TAGS. Ignore case." "True if TAG is in TAGS. Ignore case."
return tag.lower() in [t.lower() for t in tags] return tag.lower() in [t.lower() for t in tags]
# legacy
##########################################################################
def registerNotes(self, nids: Optional[List[int]] = None) -> None:
self.clear_unused_tags()
def register(
self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False
) -> None:
print("tags.register() is deprecated and no longer works")
def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None:
"Add tags in bulk. TAGS is space-separated."
if add:
self.bulk_add(ids, tags)
else:
self.bulk_remove(ids, tags)
def bulkRem(self, ids: List[int], tags: str) -> None:
self.bulkAdd(ids, tags, False)

View file

@ -243,24 +243,40 @@ def test_findReplace():
col.addNote(note2) col.addNote(note2)
nids = [note.id, note2.id] nids = [note.id, note2.id]
# should do nothing # should do nothing
assert col.findReplace(nids, "abc", "123") == 0 assert (
col.find_and_replace(note_ids=nids, search="abc", replacement="123").count == 0
)
# global replace # global replace
assert col.findReplace(nids, "foo", "qux") == 2 assert (
col.find_and_replace(note_ids=nids, search="foo", replacement="qux").count == 2
)
note.load() note.load()
assert note["Front"] == "qux" assert note["Front"] == "qux"
note2.load() note2.load()
assert note2["Back"] == "qux" assert note2["Back"] == "qux"
# single field replace # single field replace
assert col.findReplace(nids, "qux", "foo", field="Front") == 1 assert (
col.find_and_replace(
note_ids=nids, search="qux", replacement="foo", field_name="Front"
).count
== 1
)
note.load() note.load()
assert note["Front"] == "foo" assert note["Front"] == "foo"
note2.load() note2.load()
assert note2["Back"] == "qux" assert note2["Back"] == "qux"
# regex replace # regex replace
assert col.findReplace(nids, "B.r", "reg") == 0 assert (
col.find_and_replace(note_ids=nids, search="B.r", replacement="reg").count == 0
)
note.load() note.load()
assert note["Back"] != "reg" assert note["Back"] != "reg"
assert col.findReplace(nids, "B.r", "reg", regex=True) == 1 assert (
col.find_and_replace(
note_ids=nids, search="B.r", replacement="reg", regex=True
).count
== 1
)
note.load() note.load()
assert note["Back"] == "reg" assert note["Back"] == "reg"

View file

@ -1023,62 +1023,6 @@ def test_deckFlow():
col.sched.answerCard(c, 2) col.sched.answerCard(c, 2)
def test_reorder():
col = getEmptyCol()
# add a note with default deck
note = col.newNote()
note["Front"] = "one"
col.addNote(note)
note2 = col.newNote()
note2["Front"] = "two"
col.addNote(note2)
assert note2.cards()[0].due == 2
found = False
# 50/50 chance of being reordered
for i in range(20):
col.sched.randomizeCards(1)
if note.cards()[0].due != note.id:
found = True
break
assert found
col.sched.orderCards(1)
assert note.cards()[0].due == 1
# shifting
note3 = col.newNote()
note3["Front"] = "three"
col.addNote(note3)
note4 = col.newNote()
note4["Front"] = "four"
col.addNote(note4)
assert note.cards()[0].due == 1
assert note2.cards()[0].due == 2
assert note3.cards()[0].due == 3
assert note4.cards()[0].due == 4
col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True)
assert note.cards()[0].due == 3
assert note2.cards()[0].due == 4
assert note3.cards()[0].due == 1
assert note4.cards()[0].due == 2
def test_forget():
col = getEmptyCol()
note = col.newNote()
note["Front"] = "one"
col.addNote(note)
c = note.cards()[0]
c.queue = QUEUE_TYPE_REV
c.type = CARD_TYPE_REV
c.ivl = 100
c.due = 0
c.flush()
col.reset()
assert col.sched.counts() == (0, 0, 1)
col.sched.forgetCards([c.id])
col.reset()
assert col.sched.counts() == (1, 0, 0)
def test_norelearn(): def test_norelearn():
col = getEmptyCol() col = getEmptyCol()
# add a note # add a note

View file

@ -1211,7 +1211,13 @@ def test_reorder():
assert note2.cards()[0].due == 2 assert note2.cards()[0].due == 2
assert note3.cards()[0].due == 3 assert note3.cards()[0].due == 3
assert note4.cards()[0].due == 4 assert note4.cards()[0].due == 4
col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True) col.sched.reposition_new_cards(
[note3.cards()[0].id, note4.cards()[0].id],
starting_from=1,
shift_existing=True,
step_size=1,
randomize=False,
)
assert note.cards()[0].due == 3 assert note.cards()[0].due == 3
assert note2.cards()[0].due == 4 assert note2.cards()[0].due == 4
assert note3.cards()[0].due == 1 assert note3.cards()[0].due == 1

View file

@ -8,6 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
ignored-classes= ignored-classes=
SearchNode, SearchNode,
Config, Config,
OpChanges
[REPORTS] [REPORTS]
output-format=colorized output-format=colorized

View file

@ -10,7 +10,7 @@ import os
import sys import sys
import tempfile import tempfile
import traceback import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
import anki.lang import anki.lang
from anki import version as _version from anki import version as _version
@ -299,7 +299,7 @@ class AnkiApp(QApplication):
if not sock.waitForReadyRead(self.TMOUT): if not sock.waitForReadyRead(self.TMOUT):
sys.stderr.write(sock.errorString()) sys.stderr.write(sock.errorString())
return return
path = bytes(sock.readAll()).decode("utf8") path = bytes(cast(bytes, sock.readAll())).decode("utf8")
self.appMsg.emit(path) # type: ignore self.appMsg.emit(path) # type: ignore
sock.disconnectFromServer() sock.disconnectFromServer()

View file

@ -6,12 +6,12 @@ from typing import Callable, List, Optional
import aqt.deckchooser import aqt.deckchooser
import aqt.editor import aqt.editor
import aqt.forms import aqt.forms
from anki.collection import SearchNode from anki.collection import OpChanges, SearchNode
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.notes import DuplicateOrEmptyResult, Note from anki.notes import DuplicateOrEmptyResult, Note
from anki.utils import htmlToTextLine, isMac from anki.utils import htmlToTextLine, isMac
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.main import ResetReason from aqt.note_ops import add_note
from aqt.notetypechooser import NoteTypeChooser from aqt.notetypechooser import NoteTypeChooser
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
@ -104,10 +104,10 @@ class AddCards(QDialog):
self.historyButton = b self.historyButton = b
def setAndFocusNote(self, note: Note) -> None: def setAndFocusNote(self, note: Note) -> None:
self.editor.setNote(note, focusTo=0) self.editor.set_note(note, focusTo=0)
def show_notetype_selector(self) -> None: def show_notetype_selector(self) -> None:
self.editor.saveNow(self.notetype_chooser.choose_notetype) self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
def on_notetype_change(self, notetype_id: int) -> None: def on_notetype_change(self, notetype_id: int) -> None:
# need to adjust current deck? # need to adjust current deck?
@ -182,7 +182,7 @@ class AddCards(QDialog):
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def add_current_note(self) -> None: def add_current_note(self) -> None:
self.editor.saveNow(self._add_current_note) self.editor.call_after_note_saved(self._add_current_note)
def _add_current_note(self) -> None: def _add_current_note(self) -> None:
note = self.editor.note note = self.editor.note
@ -191,13 +191,12 @@ class AddCards(QDialog):
return return
target_deck_id = self.deck_chooser.selected_deck_id target_deck_id = self.deck_chooser.selected_deck_id
self.mw.col.add_note(note, target_deck_id)
def on_success(changes: OpChanges) -> None:
# only used for detecting changed sticky fields on close # only used for detecting changed sticky fields on close
self._last_added_note = note self._last_added_note = note
self.addHistory(note) self.addHistory(note)
self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self)
# workaround for PyQt focus bug # workaround for PyQt focus bug
self.editor.hideCompleters() self.editor.hideCompleters()
@ -205,10 +204,12 @@ class AddCards(QDialog):
tooltip(tr(TR.ADDING_ADDED), period=500) tooltip(tr(TR.ADDING_ADDED), period=500)
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note) self._load_new_note(sticky_fields_from=note)
self.mw.col.autosave() # fixme:
gui_hooks.add_cards_did_add_note(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
)
def _note_can_be_added(self, note: Note) -> bool: def _note_can_be_added(self, note: Note) -> bool:
result = note.duplicate_or_empty() result = note.duplicate_or_empty()
if result == DuplicateOrEmptyResult.EMPTY: if result == DuplicateOrEmptyResult.EMPTY:
@ -258,7 +259,7 @@ class AddCards(QDialog):
if ok: if ok:
onOk() onOk()
self.editor.saveNow(afterSave) self.editor.call_after_note_saved(afterSave)
def closeWithCallback(self, cb: Callable[[], None]) -> None: def closeWithCallback(self, cb: Callable[[], None]) -> None:
def doClose() -> None: def doClose() -> None:

View file

@ -715,7 +715,7 @@ class AddonsDialog(QDialog):
gui_hooks.addons_dialog_will_show(self) gui_hooks.addons_dialog_will_show(self)
self.show() self.show()
def dragEnterEvent(self, event: QEvent) -> None: def dragEnterEvent(self, event: QDragEnterEvent) -> None:
mime = event.mimeData() mime = event.mimeData()
if not mime.hasUrls(): if not mime.hasUrls():
return None return None
@ -724,7 +724,7 @@ class AddonsDialog(QDialog):
if all(url.toLocalFile().endswith(ext) for url in urls): if all(url.toLocalFile().endswith(ext) for url in urls):
event.acceptProposedAction() event.acceptProposedAction()
def dropEvent(self, event: QEvent) -> None: def dropEvent(self, event: QDropEvent) -> None:
mime = event.mimeData() mime = event.mimeData()
paths = [] paths = []
for url in mime.urls(): for url in mime.urls():
@ -908,7 +908,7 @@ class AddonsDialog(QDialog):
class GetAddons(QDialog): class GetAddons(QDialog):
def __init__(self, dlg: QDialog) -> None: def __init__(self, dlg: AddonsDialog) -> None:
QDialog.__init__(self, dlg) QDialog.__init__(self, dlg)
self.addonsDlg = dlg self.addonsDlg = dlg
self.mgr = dlg.mgr self.mgr = dlg.mgr
@ -1079,7 +1079,9 @@ class DownloaderInstaller(QObject):
self.on_done = on_done self.on_done = on_done
self.mgr.mw.progress.start(immediate=True, parent=self.parent()) parent = self.parent()
assert isinstance(parent, QWidget)
self.mgr.mw.progress.start(immediate=True, parent=parent)
self.mgr.mw.taskman.run_in_background(self._download_all, self._download_done) self.mgr.mw.taskman.run_in_background(self._download_all, self._download_done)
def _progress_callback(self, up: int, down: int) -> None: def _progress_callback(self, up: int, down: int) -> None:
@ -1438,7 +1440,7 @@ def prompt_to_update(
class ConfigEditor(QDialog): class ConfigEditor(QDialog):
def __init__(self, dlg: QDialog, addon: str, conf: Dict) -> None: def __init__(self, dlg: AddonsDialog, addon: str, conf: Dict) -> None:
super().__init__(dlg) super().__init__(dlg)
self.addon = addon self.addon = addon
self.conf = conf self.conf = conf
@ -1506,7 +1508,7 @@ class ConfigEditor(QDialog):
txt = gui_hooks.addon_config_editor_will_save_json(txt) txt = gui_hooks.addon_config_editor_will_save_json(txt)
try: try:
new_conf = json.loads(txt) new_conf = json.loads(txt)
jsonschema.validate(new_conf, self.parent().mgr._addon_schema(self.addon)) jsonschema.validate(new_conf, self.mgr._addon_schema(self.addon))
except ValidationError as e: except ValidationError as e:
# The user did edit the configuration and entered a value # The user did edit the configuration and entered a value
# which can not be interpreted. # which can not be interpreted.

File diff suppressed because it is too large Load diff

16
qt/aqt/card_ops.py Normal file
View file

@ -0,0 +1,16 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Sequence
from aqt import AnkiQt
def set_card_deck(*, mw: AnkiQt, card_ids: Sequence[int], deck_id: int) -> None:
mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id))
def set_card_flag(*, mw: AnkiQt, card_ids: Sequence[int], flag: int) -> None:
mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids))

View file

@ -795,7 +795,7 @@ class CardLayout(QDialog):
showWarning(str(e)) showWarning(str(e))
return return
self.mw.reset() self.mw.reset()
tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parent()) tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parentWidget())
self.cleanup() self.cleanup()
gui_hooks.sidebar_should_refresh_notetypes() gui_hooks.sidebar_should_refresh_notetypes()
return QDialog.accept(self) return QDialog.accept(self)

24
qt/aqt/deck_ops.py Normal file
View file

@ -0,0 +1,24 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Sequence
from anki.lang import TR
from aqt import AnkiQt, QWidget
from aqt.utils import tooltip, tr
def remove_decks(
*,
mw: AnkiQt,
parent: QWidget,
deck_ids: Sequence[int],
) -> None:
mw.perform_op(
lambda: mw.col.decks.remove(deck_ids),
success=lambda out: tooltip(
tr(TR.BROWSING_CARDS_DELETED, count=out.count), parent=parent
),
)

View file

@ -8,10 +8,12 @@ from dataclasses import dataclass
from typing import Any from typing import Any
import aqt import aqt
from anki.collection import OpChanges
from anki.decks import DeckTreeNode from anki.decks import DeckTreeNode
from anki.errors import DeckIsFilteredError from anki.errors import DeckIsFilteredError
from anki.utils import intTime from anki.utils import intTime
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.deck_ops import remove_decks
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
@ -23,7 +25,6 @@ from aqt.utils import (
shortcut, shortcut,
showInfo, showInfo,
showWarning, showWarning,
tooltip,
tr, tr,
) )
@ -61,6 +62,7 @@ class DeckBrowser:
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0) self.scrollPos = QPoint(0, 0)
self._v1_message_dismissed_at = 0 self._v1_message_dismissed_at = 0
self._refresh_needed = False
def show(self) -> None: def show(self) -> None:
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
@ -68,9 +70,24 @@ class DeckBrowser:
self._renderPage() self._renderPage()
# redraw top bar for theme change # redraw top bar for theme change
self.mw.toolbar.redraw() self.mw.toolbar.redraw()
self.refresh()
def refresh(self) -> None: def refresh(self) -> None:
self._renderPage() self._renderPage()
self._refresh_needed = False
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
if self.mw.col.op_affects_study_queue(changes):
self._refresh_needed = True
if focused:
self.refresh_if_needed()
return self._refresh_needed
# Event handlers # Event handlers
########################################################################## ##########################################################################
@ -145,7 +162,6 @@ class DeckBrowser:
], ],
context=self, context=self,
) )
self.web.key = "deckBrowser"
self._drawButtons() self._drawButtons()
if offset is not None: if offset is not None:
self._scrollToOffset(offset) self._scrollToOffset(offset)
@ -305,15 +321,7 @@ class DeckBrowser:
self.mw.taskman.with_progress(process, on_done) self.mw.taskman.with_progress(process, on_done)
def _delete(self, did: int) -> None: def _delete(self, did: int) -> None:
def do_delete() -> int: remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
return self.mw.col.decks.remove([did])
def on_done(fut: Future) -> None:
self.mw.update_undo_actions()
self.show()
tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()))
self.mw.taskman.with_progress(do_delete, on_done)
# Top buttons # Top buttons
###################################################################### ######################################################################

View file

@ -1,10 +1,12 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import aqt.editor import aqt.editor
from anki.collection import OpChanges
from anki.errors import NotFoundError
from aqt import gui_hooks from aqt import gui_hooks
from aqt.main import ResetReason
from aqt.qt import * from aqt.qt import *
from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, tr from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tr
class EditCurrent(QDialog): class EditCurrent(QDialog):
@ -23,59 +25,53 @@ class EditCurrent(QDialog):
) )
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self) self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self)
self.editor.card = self.mw.reviewer.card self.editor.card = self.mw.reviewer.card
self.editor.setNote(self.mw.reviewer.card.note(), focusTo=0) self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent") restoreGeom(self, "editcurrent")
gui_hooks.state_did_reset.append(self.onReset) gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.mw.requireReset(reason=ResetReason.EditCurrentInit, context=self)
self.show() self.show()
# reset focus after open, taking care not to retain webview
# pylint: disable=unnecessary-lambda
self.mw.progress.timer(100, lambda: self.editor.web.setFocus(), False)
def onReset(self) -> None: def on_operation_did_execute(self, changes: OpChanges) -> None:
# lazy approach for now: throw away edits if not (changes.note or changes.notetype):
try:
n = self.editor.note
n.load() # reload in case the model changed
except:
# card's been deleted
gui_hooks.state_did_reset.remove(self.onReset)
self.editor.setNote(None)
self.mw.reset()
aqt.dialogs.markClosed("EditCurrent")
self.close()
return return
self.editor.setNote(n) if self.editor.is_updating_note():
return
# reload note
note = self.editor.note
try:
note.load()
except NotFoundError:
# note's been deleted
self.cleanup_and_close()
return
self.editor.set_note(note)
def cleanup_and_close(self) -> None:
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup()
saveGeom(self, "editcurrent")
aqt.dialogs.markClosed("EditCurrent")
QDialog.reject(self)
def reopen(self, mw: aqt.AnkiQt) -> None: def reopen(self, mw: aqt.AnkiQt) -> None:
tooltip("Please finish editing the existing card first.") if card := self.mw.reviewer.card:
self.onReset() self.editor.set_note(card.note())
def reject(self) -> None: def reject(self) -> None:
self.saveAndClose() self.saveAndClose()
def saveAndClose(self) -> None: def saveAndClose(self) -> None:
self.editor.saveNow(self._saveAndClose) self.editor.call_after_note_saved(self._saveAndClose)
def _saveAndClose(self) -> None: def _saveAndClose(self) -> None:
gui_hooks.state_did_reset.remove(self.onReset) self.cleanup_and_close()
r = self.mw.reviewer
try:
r.card.load()
except:
# card was removed by clayout
pass
else:
self.mw.reviewer.cardQueue.append(self.mw.reviewer.card)
self.editor.cleanup()
self.mw.moveToState("review")
saveGeom(self, "editcurrent")
aqt.dialogs.markClosed("EditCurrent")
QDialog.reject(self)
def closeWithCallback(self, onsuccess: Callable[[], None]) -> None: def closeWithCallback(self, onsuccess: Callable[[], None]) -> None:
def callback() -> None: def callback() -> None:
self._saveAndClose() self._saveAndClose()
onsuccess() onsuccess()
self.editor.saveNow(callback) self.editor.call_after_note_saved(callback)
onReset = on_operation_did_execute

View file

@ -1,5 +1,8 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import base64 import base64
import html import html
import itertools import itertools
@ -24,16 +27,17 @@ from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter from anki.hooks import runFilter
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
from anki.notes import Note from anki.notes import DuplicateOrEmptyResult, Note
from anki.utils import checksum, isLin, isWin, namedtmp from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.main import ResetReason from aqt.note_ops import update_note
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
HelpPage, HelpPage,
KeyboardModifiersPressed,
disable_help_button, disable_help_button,
getFile, getFile,
openHelp, openHelp,
@ -90,8 +94,17 @@ _html = """
</div> </div>
""" """
# caller is responsible for resetting note on reset
class Editor: class Editor:
"""The screen that embeds an editing widget should listen for changes via
the `operation_did_execute` hook, and call set_note() when the editor needs
redrawing.
The editor will cause that hook to be fired when it saves changes. To avoid
an unwanted refresh, the parent widget should call editor.is_updating_note(),
and avoid re-setting the note if it returns true.
"""
def __init__( def __init__(
self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool = False self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool = False
) -> None: ) -> None:
@ -101,6 +114,7 @@ class Editor:
self.note: Optional[Note] = None self.note: Optional[Note] = None
self.addMode = addMode self.addMode = addMode
self.currentField: Optional[int] = None self.currentField: Optional[int] = None
self._is_updating_note = False
# current card, for card layout # current card, for card layout
self.card: Optional[Card] = None self.card: Optional[Card] = None
self.setupOuter() self.setupOuter()
@ -399,7 +413,7 @@ class Editor:
return checkFocus return checkFocus
def onFields(self) -> None: def onFields(self) -> None:
self.saveNow(self._onFields) self.call_after_note_saved(self._onFields)
def _onFields(self) -> None: def _onFields(self) -> None:
from aqt.fields import FieldDialog from aqt.fields import FieldDialog
@ -407,7 +421,7 @@ class Editor:
FieldDialog(self.mw, self.note.model(), parent=self.parentWindow) FieldDialog(self.mw, self.note.model(), parent=self.parentWindow)
def onCardLayout(self) -> None: def onCardLayout(self) -> None:
self.saveNow(self._onCardLayout) self.call_after_note_saved(self._onCardLayout)
def _onCardLayout(self) -> None: def _onCardLayout(self) -> None:
from aqt.clayout import CardLayout from aqt.clayout import CardLayout
@ -450,7 +464,6 @@ class Editor:
if not self.addMode: if not self.addMode:
self._save_current_note() self._save_current_note()
self.mw.requireReset(reason=ResetReason.EditorBridgeCmd, context=self)
if type == "blur": if type == "blur":
self.currentField = None self.currentField = None
# run any filters # run any filters
@ -459,10 +472,10 @@ class Editor:
# event has had time to fire # event has had time to fire
self.mw.progress.timer(100, self.loadNoteKeepingFocus, False) self.mw.progress.timer(100, self.loadNoteKeepingFocus, False)
else: else:
self.checkValid() self._check_and_update_duplicate_display_async()
else: else:
gui_hooks.editor_did_fire_typing_timer(self.note) gui_hooks.editor_did_fire_typing_timer(self.note)
self.checkValid() self._check_and_update_duplicate_display_async()
# focused into field? # focused into field?
elif cmd.startswith("focus"): elif cmd.startswith("focus"):
@ -492,7 +505,7 @@ class Editor:
# Setting/unsetting the current note # Setting/unsetting the current note
###################################################################### ######################################################################
def setNote( def set_note(
self, note: Optional[Note], hide: bool = True, focusTo: Optional[int] = None self, note: Optional[Note], hide: bool = True, focusTo: Optional[int] = None
) -> None: ) -> None:
"Make NOTE the current note." "Make NOTE the current note."
@ -519,11 +532,15 @@ class Editor:
self.widget.show() self.widget.show()
self.updateTags() self.updateTags()
dupe_status = self.note.duplicate_or_empty()
def oncallback(arg: Any) -> None: def oncallback(arg: Any) -> None:
if not self.note: if not self.note:
return return
self.setupForegroundButton() self.setupForegroundButton()
self.checkValid() # we currently do this synchronously to ensure we load before the
# sidebar on browser startup
self._update_duplicate_display(dupe_status)
if focusTo is not None: if focusTo is not None:
self.web.setFocus() self.web.setFocus()
gui_hooks.editor_did_load_note(self) gui_hooks.editor_did_load_note(self)
@ -544,7 +561,14 @@ class Editor:
def _save_current_note(self) -> None: def _save_current_note(self) -> None:
"Call after note is updated with data from webview." "Call after note is updated with data from webview."
self.mw.col.update_note(self.note) self._is_updating_note = True
update_note(mw=self.mw, note=self.note, after_hooks=self._after_updating_note)
def _after_updating_note(self) -> None:
self._is_updating_note = False
def is_updating_note(self) -> bool:
return self._is_updating_note
def fonts(self) -> List[Tuple[str, int, bool]]: def fonts(self) -> List[Tuple[str, int, bool]]:
return [ return [
@ -552,19 +576,34 @@ class Editor:
for f in self.note.model()["flds"] for f in self.note.model()["flds"]
] ]
def saveNow(self, callback: Callable, keepFocus: bool = False) -> None: def call_after_note_saved(
self, callback: Callable, keepFocus: bool = False
) -> None:
"Save unsaved edits then call callback()." "Save unsaved edits then call callback()."
if not self.note: if not self.note:
# calling code may not expect the callback to fire immediately # calling code may not expect the callback to fire immediately
self.mw.progress.timer(10, callback, False) self.mw.progress.timer(10, callback, False)
return return
self.saveTags() self.blur_tags_if_focused()
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
def checkValid(self) -> None: saveNow = call_after_note_saved
def _check_and_update_duplicate_display_async(self) -> None:
note = self.note
def on_done(result: DuplicateOrEmptyResult.V) -> None:
if self.note != note:
return
self._update_duplicate_display(result)
self.mw.query_op(self.note.duplicate_or_empty, success=on_done)
checkValid = _check_and_update_duplicate_display_async
def _update_duplicate_display(self, result: DuplicateOrEmptyResult.V) -> None:
cols = [""] * len(self.note.fields) cols = [""] * len(self.note.fields)
err = self.note.duplicate_or_empty() if result == DuplicateOrEmptyResult.DUPLICATE:
if err == 2:
cols[0] = "dupe" cols[0] = "dupe"
self.web.eval(f"setBackgrounds({json.dumps(cols)});") self.web.eval(f"setBackgrounds({json.dumps(cols)});")
@ -597,16 +636,20 @@ class Editor:
return True return True
def cleanup(self) -> None: def cleanup(self) -> None:
self.setNote(None) self.set_note(None)
# prevent any remaining evalWithCallback() events from firing after C++ object deleted # prevent any remaining evalWithCallback() events from firing after C++ object deleted
self.web = None self.web = None
# legacy
setNote = set_note
# HTML editing # HTML editing
###################################################################### ######################################################################
def onHtmlEdit(self) -> None: def onHtmlEdit(self) -> None:
field = self.currentField field = self.currentField
self.saveNow(lambda: self._onHtmlEdit(field)) self.call_after_note_saved(lambda: self._onHtmlEdit(field))
def _onHtmlEdit(self, field: int) -> None: def _onHtmlEdit(self, field: int) -> None:
d = QDialog(self.widget, Qt.Window) d = QDialog(self.widget, Qt.Window)
@ -656,7 +699,7 @@ class Editor:
l = QLabel(tr(TR.EDITING_TAGS)) l = QLabel(tr(TR.EDITING_TAGS))
tb.addWidget(l, 1, 0) tb.addWidget(l, 1, 0)
self.tags = aqt.tagedit.TagEdit(self.widget) self.tags = aqt.tagedit.TagEdit(self.widget)
qconnect(self.tags.lostFocus, self.saveTags) qconnect(self.tags.lostFocus, self.on_tag_focus_lost)
self.tags.setToolTip( self.tags.setToolTip(
shortcut(tr(TR.EDITING_JUMP_TO_TAGS_WITH_CTRLANDSHIFTANDT)) shortcut(tr(TR.EDITING_JUMP_TO_TAGS_WITH_CTRLANDSHIFTANDT))
) )
@ -672,13 +715,17 @@ class Editor:
if not self.tags.text() or not self.addMode: if not self.tags.text() or not self.addMode:
self.tags.setText(self.note.stringTags().strip()) self.tags.setText(self.note.stringTags().strip())
def saveTags(self) -> None: def on_tag_focus_lost(self) -> None:
if not self.note:
return
self.note.tags = self.mw.col.tags.split(self.tags.text()) self.note.tags = self.mw.col.tags.split(self.tags.text())
gui_hooks.editor_did_update_tags(self.note)
if not self.addMode: if not self.addMode:
self._save_current_note() self._save_current_note()
gui_hooks.editor_did_update_tags(self.note)
def blur_tags_if_focused(self) -> None:
if not self.note:
return
if self.tags.hasFocus():
self.widget.setFocus()
def hideCompleters(self) -> None: def hideCompleters(self) -> None:
self.tags.hideCompleter() self.tags.hideCompleter()
@ -687,9 +734,12 @@ class Editor:
self.tags.setFocus() self.tags.setFocus()
# legacy # legacy
def saveAddModeVars(self) -> None: def saveAddModeVars(self) -> None:
pass pass
saveTags = blur_tags_if_focused
# Format buttons # Format buttons
###################################################################### ######################################################################
@ -712,7 +762,7 @@ class Editor:
self.web.eval("setFormat('removeFormat');") self.web.eval("setFormat('removeFormat');")
def onCloze(self) -> None: def onCloze(self) -> None:
self.saveNow(self._onCloze, keepFocus=True) self.call_after_note_saved(self._onCloze, keepFocus=True)
def _onCloze(self) -> None: def _onCloze(self) -> None:
# check that the model is set up for cloze deletion # check that the model is set up for cloze deletion
@ -729,7 +779,7 @@ class Editor:
if m: if m:
highest = max(highest, sorted([int(x) for x in m])[-1]) highest = max(highest, sorted([int(x) for x in m])[-1])
# reuse last? # reuse last?
if not self.mw.app.keyboardModifiers() & Qt.AltModifier: if not KeyboardModifiersPressed().alt:
highest += 1 highest += 1
# must start at 1 # must start at 1
highest = max(1, highest) highest = max(1, highest)
@ -1106,7 +1156,7 @@ class EditorWebView(AnkiWebView):
strip_html = self.editor.mw.col.get_config_bool( strip_html = self.editor.mw.col.get_config_bool(
Config.Bool.PASTE_STRIPS_FORMATTING Config.Bool.PASTE_STRIPS_FORMATTING
) )
if self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier: if KeyboardModifiersPressed().shift:
strip_html = not strip_html strip_html = not strip_html
return strip_html return strip_html

View file

@ -4,7 +4,7 @@ import html
import re import re
import sys import sys
import traceback import traceback
from typing import Optional from typing import Optional, TextIO, cast
from markdown import markdown from markdown import markdown
@ -37,7 +37,7 @@ class ErrorHandler(QObject):
qconnect(self.errorTimer, self._setTimer) qconnect(self.errorTimer, self._setTimer)
self.pool = "" self.pool = ""
self._oldstderr = sys.stderr self._oldstderr = sys.stderr
sys.stderr = self sys.stderr = cast(TextIO, self)
def unload(self) -> None: def unload(self) -> None:
sys.stderr = self._oldstderr sys.stderr = self._oldstderr

View file

@ -26,7 +26,7 @@ from aqt.utils import (
class FieldDialog(QDialog): class FieldDialog(QDialog):
def __init__( def __init__(
self, mw: AnkiQt, nt: NoteType, parent: Optional[QDialog] = None self, mw: AnkiQt, nt: NoteType, parent: Optional[QWidget] = None
) -> None: ) -> None:
QDialog.__init__(self, parent or mw) QDialog.__init__(self, parent or mw)
self.mw = mw self.mw = mw

182
qt/aqt/find_and_replace.py Normal file
View file

@ -0,0 +1,182 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import List, Optional, Sequence
import aqt
from anki.lang import TR
from aqt import AnkiQt, QWidget
from aqt.qt import QDialog, Qt
from aqt.utils import (
HelpPage,
disable_help_button,
openHelp,
qconnect,
restore_combo_history,
restore_combo_index_for_session,
restore_is_checked,
restoreGeom,
save_combo_history,
save_combo_index_for_session,
save_is_checked,
saveGeom,
show_invalid_search_error,
tooltip,
tr,
)
def find_and_replace(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
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(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
parent=parent,
),
failure=lambda exc: show_invalid_search_error(exc, 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(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
parent=parent,
),
failure=lambda exc: show_invalid_search_error(exc, parent=parent),
)
class FindAndReplaceDialog(QDialog):
COMBO_NAME = "BrowserFindAndReplace"
def __init__(self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[int]) -> None:
super().__init__(parent)
self.mw = mw
self.note_ids = note_ids
self.field_names: List[str] = []
# fetch field names and then show
mw.query_op(
lambda: mw.col.field_names_for_note_ids(note_ids),
success=self._show,
)
def _show(self, field_names: Sequence[str]) -> None:
# add "all fields" and "tags" to the top of the list
self.field_names = [
tr(TR.BROWSING_ALL_FIELDS),
tr(TR.EDITING_TAGS),
] + list(field_names)
disable_help_button(self)
self.form = aqt.forms.findreplace.Ui_Dialog()
self.form.setupUi(self)
self.setWindowModality(Qt.WindowModal)
self._find_history = restore_combo_history(
self.form.find, self.COMBO_NAME + "Find"
)
self.form.find.completer().setCaseSensitivity(True)
self._replace_history = restore_combo_history(
self.form.replace, self.COMBO_NAME + "Replace"
)
self.form.replace.completer().setCaseSensitivity(True)
restore_is_checked(self.form.re, self.COMBO_NAME + "Regex")
restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
self.form.field.addItems(self.field_names)
restore_combo_index_for_session(
self.form.field, self.field_names, self.COMBO_NAME + "Field"
)
qconnect(self.form.buttonBox.helpRequested, self.show_help)
restoreGeom(self, "findreplace")
self.show()
self.form.find.setFocus()
def accept(self) -> None:
saveGeom(self, "findreplace")
save_combo_index_for_session(self.form.field, self.COMBO_NAME + "Field")
search = save_combo_history(
self.form.find, self._find_history, self.COMBO_NAME + "Find"
)
replace = save_combo_history(
self.form.replace, self._replace_history, self.COMBO_NAME + "Replace"
)
regex = self.form.re.isChecked()
match_case = not self.form.ignoreCase.isChecked()
save_is_checked(self.form.re, self.COMBO_NAME + "Regex")
save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
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]
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,
)
super().accept()
def show_help(self) -> None:
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)

View file

@ -14,7 +14,20 @@ import zipfile
from argparse import Namespace from argparse import Namespace
from concurrent.futures import Future from concurrent.futures import Future
from threading import Thread from threading import Thread
from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple, cast from typing import (
Any,
Callable,
Dict,
List,
Literal,
Optional,
Protocol,
Sequence,
TextIO,
Tuple,
TypeVar,
cast,
)
import anki import anki
import aqt import aqt
@ -32,8 +45,11 @@ from anki.collection import (
Checkpoint, Checkpoint,
Collection, Collection,
Config, Config,
OpChanges,
OpChangesWithCount,
ReviewUndo, ReviewUndo,
UndoResult, UndoResult,
UndoStatus,
) )
from anki.decks import Deck from anki.decks import Deck
from anki.hooks import runHook from anki.hooks import runHook
@ -56,8 +72,10 @@ from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
HelpPage, HelpPage,
KeyboardModifiersPressed,
askUser, askUser,
checkInvalidFilename, checkInvalidFilename,
current_top_level_widget,
disable_help_button, disable_help_button,
getFile, getFile,
getOnlyText, getOnlyText,
@ -71,31 +89,29 @@ from aqt.utils import (
showInfo, showInfo,
showWarning, showWarning,
tooltip, tooltip,
top_level_widget,
tr, tr,
) )
class HasChangesProperty(Protocol):
changes: OpChanges
# either an OpChanges object, or an object with .changes on it. This bound
# doesn't actually work for protobuf objects, so new protobuf objects will
# either need to be added here, or cast at call time
ResultWithChanges = TypeVar(
"ResultWithChanges", bound=Union[OpChanges, OpChangesWithCount, HasChangesProperty]
)
PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]]
install_pylib_legacy() install_pylib_legacy()
MainWindowState = Literal[
class ResetReason(enum.Enum): "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager"
Unknown = "unknown" ]
AddCardsAddNote = "addCardsAddNote"
EditCurrentInit = "editCurrentInit"
EditorBridgeCmd = "editorBridgeCmd"
BrowserSetDeck = "browserSetDeck"
BrowserAddTags = "browserAddTags"
BrowserRemoveTags = "browserRemoveTags"
BrowserSuspend = "browserSuspend"
BrowserReposition = "browserReposition"
BrowserReschedule = "browserReschedule"
BrowserFindReplace = "browserFindReplace"
BrowserTagDupes = "browserTagDupes"
BrowserDeleteDeck = "browserDeleteDeck"
class ResetRequired:
def __init__(self, mw: AnkiQt) -> None:
self.mw = mw
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
@ -106,7 +122,7 @@ class AnkiQt(QMainWindow):
def __init__( def __init__(
self, self,
app: QApplication, app: aqt.AnkiApp,
profileManager: ProfileManagerType, profileManager: ProfileManagerType,
backend: _RustBackend, backend: _RustBackend,
opts: Namespace, opts: Namespace,
@ -114,7 +130,7 @@ class AnkiQt(QMainWindow):
) -> None: ) -> None:
QMainWindow.__init__(self) QMainWindow.__init__(self)
self.backend = backend self.backend = backend
self.state = "startup" self.state: MainWindowState = "startup"
self.opts = opts self.opts = opts
self.col: Optional[Collection] = None self.col: Optional[Collection] = None
self.taskman = TaskManager(self) self.taskman = TaskManager(self)
@ -123,9 +139,7 @@ class AnkiQt(QMainWindow):
self.app = app self.app = app
self.pm = profileManager self.pm = profileManager
# init rest of app # init rest of app
self.safeMode = ( self.safeMode = (KeyboardModifiersPressed().shift) or self.opts.safemode
self.app.queryKeyboardModifiers() & Qt.ShiftModifier
) or self.opts.safemode
try: try:
self.setupUI() self.setupUI()
self.setupAddons(args) self.setupAddons(args)
@ -173,6 +187,7 @@ class AnkiQt(QMainWindow):
self.setupHooks() self.setupHooks()
self.setup_timers() self.setup_timers()
self.updateTitleBar() self.updateTitleBar()
self.setup_focus()
# screens # screens
self.setupDeckBrowser() self.setupDeckBrowser()
self.setupOverview() self.setupOverview()
@ -201,6 +216,12 @@ class AnkiQt(QMainWindow):
"Shortcut to create a weak reference that doesn't break code completion." "Shortcut to create a weak reference that doesn't break code completion."
return weakref.proxy(self) # type: ignore return weakref.proxy(self) # type: ignore
def setup_focus(self) -> None:
qconnect(self.app.focusChanged, self.on_focus_changed)
def on_focus_changed(self, old: QWidget, new: QWidget) -> None:
gui_hooks.focus_did_change(new, old)
# Profiles # Profiles
########################################################################## ##########################################################################
@ -650,12 +671,12 @@ class AnkiQt(QMainWindow):
self.pm.save() self.pm.save()
self.progress.finish() self.progress.finish()
# State machine # Tracking main window state (deck browser, reviewer, etc)
########################################################################## ##########################################################################
def moveToState(self, state: str, *args: Any) -> None: def moveToState(self, state: MainWindowState, *args: Any) -> None:
# print("-> move from", self.state, "to", state) # print("-> move from", self.state, "to", state)
oldState = self.state or "dummy" oldState = self.state
cleanup = getattr(self, f"_{oldState}Cleanup", None) cleanup = getattr(self, f"_{oldState}Cleanup", None)
if cleanup: if cleanup:
# pylint: disable=not-callable # pylint: disable=not-callable
@ -694,66 +715,214 @@ class AnkiQt(QMainWindow):
# Resetting state # Resetting state
########################################################################## ##########################################################################
def reset(self, guiOnly: bool = False) -> None: def query_op(
"Called for non-trivial edits. Rebuilds queue and updates UI." self,
op: Callable[[], Any],
*,
success: Callable[[Any], Any] = None,
failure: Optional[Callable[[Exception], Any]] = None,
) -> 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.
`operations_will|did_execute` will still fire, so the UI can defer
updates during a background task.
"""
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()
if success:
success(result)
self._increase_background_ops()
self.taskman.run_in_background(op, wrapped_done)
# Resetting state
##########################################################################
def perform_op(
self,
op: Callable[[], ResultWithChanges],
*,
success: PerformOpOptionalSuccessCallback = None,
failure: Optional[Callable[[Exception], Any]] = None,
after_hooks: Optional[Callable[[], None]] = None,
) -> 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)
self.taskman.with_progress(op, wrapped_done)
def _increase_background_ops(self) -> None:
if not self._background_op_count:
gui_hooks.backend_will_block()
self._background_op_count += 1
def _decrease_background_ops(self) -> None:
self._background_op_count -= 1
if not self._background_op_count:
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]]
) -> 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)
# 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()"""
op = OpChanges()
for field in op.DESCRIPTOR.fields:
if field.name != "kind":
setattr(op, field.name, True)
gui_hooks.operation_did_execute(op)
def on_operation_did_execute(self, changes: OpChanges) -> None:
"Notify current screen of changes."
focused = current_top_level_widget() == self
if self.state == "review":
dirty = self.reviewer.op_executed(changes, focused)
elif self.state == "overview":
dirty = self.overview.op_executed(changes, focused)
elif self.state == "deckBrowser":
dirty = self.deckBrowser.op_executed(changes, focused)
else:
dirty = False
if not focused and dirty:
self.fade_out_webview()
def on_focus_did_change(
self, new_focus: Optional[QWidget], _old: Optional[QWidget]
) -> None:
"If main window has received focus, ensure current UI state is updated."
if new_focus and top_level_widget(new_focus) == self:
if self.state == "review":
self.reviewer.refresh_if_needed()
elif self.state == "overview":
self.overview.refresh_if_needed()
elif self.state == "deckBrowser":
self.deckBrowser.refresh_if_needed()
def fade_out_webview(self) -> None:
self.web.eval("document.body.style.opacity = 0.3")
def fade_in_webview(self) -> None:
self.web.eval("document.body.style.opacity = 1")
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."""
if self.col: if self.col:
if not guiOnly: # fire new `operation_did_execute` hook first. If the overview
self.col.reset() # or review screen are currently open, they will rebuild the study
# queues (via mw.col.reset())
self._synthesize_op_did_execute_from_reset()
# fire the old reset hook
gui_hooks.state_did_reset() gui_hooks.state_did_reset()
self.update_undo_actions() self.update_undo_actions()
self.moveToState(self.state)
# legacy
def requireReset( def requireReset(
self, self,
modal: bool = False, modal: bool = False,
reason: ResetReason = ResetReason.Unknown, reason: Any = None,
context: Any = None, context: Any = None,
) -> None: ) -> None:
"Signal queue needs to be rebuilt when edits are finished or by user."
self.autosave()
self.resetModal = modal
if gui_hooks.main_window_should_require_reset(
self.interactiveState(), reason, context
):
self.moveToState("resetRequired")
def interactiveState(self) -> bool:
"True if not in profile manager, syncing, etc."
return self.state in ("overview", "review", "deckBrowser")
def maybeReset(self) -> None:
self.autosave()
if self.state == "resetRequired":
self.state = self.returnState
self.reset() self.reset()
def delayedMaybeReset(self) -> None: def maybeReset(self) -> None:
# if we redraw the page in a button click event it will often crash on pass
# windows
self.progress.timer(100, self.maybeReset, False)
def _resetRequiredState(self, oldState: str) -> None: def delayedMaybeReset(self) -> None:
if oldState != "resetRequired": pass
self.returnState = oldState
if self.resetModal: def _resetRequiredState(self, oldState: MainWindowState) -> None:
# we don't have to change the webview, as we have a covering window pass
return
web_context = ResetRequired(self)
self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context)
i = tr(TR.QT_MISC_WAITING_FOR_EDITING_TO_FINISH)
b = self.button("refresh", tr(TR.QT_MISC_RESUME_NOW), id="resume")
self.web.stdHtml(
f"""
<center><div style="height: 100%">
<div style="position:relative; vertical-align: middle;">
{i}<br><br>
{b}</div></div></center>
<script>$('#resume').focus()</script>
""",
context=web_context,
)
self.bottomWeb.hide()
self.web.setFocus()
# HTML helpers # HTML helpers
########################################################################## ##########################################################################
@ -812,10 +981,8 @@ title="%s" %s>%s</button>""" % (
# force webengine processes to load before cwd is changed # force webengine processes to load before cwd is changed
if isWin: if isWin:
for o in self.web, self.bottomWeb: for webview in self.web, self.bottomWeb:
o.requiresCol = False webview.force_load_hack()
o._domReady = False
o._page.setContent(bytes("", "ascii"))
def closeAllWindows(self, onsuccess: Callable) -> None: def closeAllWindows(self, onsuccess: Callable) -> None:
aqt.dialogs.closeAll(onsuccess) aqt.dialogs.closeAll(onsuccess)
@ -879,6 +1046,7 @@ title="%s" %s>%s</button>""" % (
def setupThreads(self) -> None: def setupThreads(self) -> None:
self._mainThread = QThread.currentThread() self._mainThread = QThread.currentThread()
self._background_op_count = 0
def inMainThread(self) -> bool: def inMainThread(self) -> bool:
return self._mainThread == QThread.currentThread() return self._mainThread == QThread.currentThread()
@ -988,8 +1156,7 @@ title="%s" %s>%s</button>""" % (
("y", self.on_sync_button_clicked), ("y", self.on_sync_button_clicked),
] ]
self.applyShortcuts(globalShortcuts) self.applyShortcuts(globalShortcuts)
self.stateShortcuts: List[QShortcut] = []
self.stateShortcuts: Sequence[Tuple[str, Callable]] = []
def applyShortcuts( def applyShortcuts(
self, shortcuts: Sequence[Tuple[str, Callable]] self, shortcuts: Sequence[Tuple[str, Callable]]
@ -1087,6 +1254,8 @@ title="%s" %s>%s</button>""" % (
if on_done: if on_done:
on_done(result) on_done(result)
# fixme: perform_op? -> needs to save
# fixme: parent
self.taskman.with_progress(self.col.undo, on_done_outer) self.taskman.with_progress(self.col.undo, on_done_outer)
def update_undo_actions(self) -> None: def update_undo_actions(self) -> None:
@ -1108,6 +1277,23 @@ title="%s" %s>%s</button>""" % (
self.form.actionUndo.setEnabled(False) self.form.actionUndo.setEnabled(False)
gui_hooks.undo_state_did_change(False) gui_hooks.undo_state_did_change(False)
def _update_undo_actions_for_status_and_save(self, status: UndoStatus) -> None:
"""Update menu text and enable/disable menu item as appropriate.
Plural as this may handle redo in the future too."""
undo_action = status.undo
if undo_action:
undo_action = tr(TR.UNDO_UNDO_ACTION, val=undo_action)
self.form.actionUndo.setText(undo_action)
self.form.actionUndo.setEnabled(True)
gui_hooks.undo_state_did_change(True)
else:
self.form.actionUndo.setText(tr(TR.UNDO_UNDO))
self.form.actionUndo.setEnabled(False)
gui_hooks.undo_state_did_change(False)
self.col.autosave()
def checkpoint(self, name: str) -> None: def checkpoint(self, name: str) -> None:
self.col.save(name) self.col.save(name)
self.update_undo_actions() self.update_undo_actions()
@ -1149,7 +1335,7 @@ title="%s" %s>%s</button>""" % (
deck = self._selectedDeck() deck = self._selectedDeck()
if not deck: if not deck:
return return
want_old = self.app.queryKeyboardModifiers() & Qt.ShiftModifier want_old = KeyboardModifiersPressed().shift
if want_old: if want_old:
aqt.dialogs.open("DeckStats", self) aqt.dialogs.open("DeckStats", self)
else: else:
@ -1300,7 +1486,7 @@ title="%s" %s>%s</button>""" % (
if elap > minutes * 60: if elap > minutes * 60:
self.maybe_auto_sync_media() self.maybe_auto_sync_media()
# Permanent libanki hooks # Permanent hooks
########################################################################## ##########################################################################
def setupHooks(self) -> None: def setupHooks(self) -> None:
@ -1310,6 +1496,8 @@ title="%s" %s>%s</button>""" % (
gui_hooks.av_player_will_play.append(self.on_av_player_will_play) gui_hooks.av_player_will_play.append(self.on_av_player_will_play)
gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing) gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_did_change)
self._activeWindowOnPlay: Optional[QWidget] = None self._activeWindowOnPlay: Optional[QWidget] = None
@ -1404,13 +1592,14 @@ title="%s" %s>%s</button>""" % (
frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog() frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog()
class DebugDialog(QDialog): class DebugDialog(QDialog):
silentlyClose = True
def reject(self) -> None: def reject(self) -> None:
super().reject() super().reject()
saveSplitter(frm.splitter, "DebugConsoleWindow") saveSplitter(frm.splitter, "DebugConsoleWindow")
saveGeom(self, "DebugConsoleWindow") saveGeom(self, "DebugConsoleWindow")
d = self.debugDiag = DebugDialog() d = self.debugDiag = DebugDialog()
d.silentlyClose = True
disable_help_button(d) disable_help_button(d)
frm.setupUi(d) frm.setupUi(d)
restoreGeom(d, "DebugConsoleWindow") restoreGeom(d, "DebugConsoleWindow")
@ -1574,7 +1763,8 @@ title="%s" %s>%s</button>""" % (
if not self.hideMenuAccels: if not self.hideMenuAccels:
return return
tgt = tgt or self tgt = tgt or self
for action in tgt.findChildren(QAction): for action_ in tgt.findChildren(QAction):
action = cast(QAction, action_)
txt = str(action.text()) txt = str(action.text())
m = re.match(r"^(.+)\(&.+\)(.+)?", txt) m = re.match(r"^(.+)\(&.+\)(.+)?", txt)
if m: if m:
@ -1582,7 +1772,7 @@ title="%s" %s>%s</button>""" % (
def hideStatusTips(self) -> None: def hideStatusTips(self) -> None:
for action in self.findChildren(QAction): for action in self.findChildren(QAction):
action.setStatusTip("") cast(QAction, action).setStatusTip("")
def onMacMinimize(self) -> None: def onMacMinimize(self) -> None:
self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore
@ -1645,6 +1835,10 @@ title="%s" %s>%s</button>""" % (
def _isAddon(self, buf: str) -> bool: def _isAddon(self, buf: str) -> bool:
return buf.endswith(self.addonManager.ext) return buf.endswith(self.addonManager.ext)
def interactiveState(self) -> bool:
"True if not in profile manager, syncing, etc."
return self.state in ("overview", "review", "deckBrowser")
# GC # GC
########################################################################## ##########################################################################
# The default Python garbage collection can trigger on any thread. This can # The default Python garbage collection can trigger on any thread. This can
@ -1700,3 +1894,20 @@ title="%s" %s>%s</button>""" % (
def serverURL(self) -> str: def serverURL(self) -> str:
return "http://127.0.0.1:%d/" % self.mediaServer.getPort() return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
# legacy
class ResetReason(enum.Enum):
Unknown = "unknown"
AddCardsAddNote = "addCardsAddNote"
EditCurrentInit = "editCurrentInit"
EditorBridgeCmd = "editorBridgeCmd"
BrowserSetDeck = "browserSetDeck"
BrowserAddTags = "browserAddTags"
BrowserRemoveTags = "browserRemoveTags"
BrowserSuspend = "browserSuspend"
BrowserReposition = "browserReposition"
BrowserReschedule = "browserReschedule"
BrowserFindReplace = "browserFindReplace"
BrowserTagDupes = "browserTagDupes"
BrowserDeleteDeck = "browserDeleteDeck"

View file

@ -31,7 +31,7 @@ class Models(QDialog):
def __init__( def __init__(
self, self,
mw: AnkiQt, mw: AnkiQt,
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
fromMain: bool = False, fromMain: bool = False,
selected_notetype_id: Optional[int] = None, selected_notetype_id: Optional[int] = None,
): ):

36
qt/aqt/note_ops.py Normal file
View file

@ -0,0 +1,36 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Callable, Sequence
from anki.notes import Note
from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback
def add_note(
*,
mw: AnkiQt,
note: Note,
target_deck_id: int,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success)
def update_note(*, mw: AnkiQt, note: Note, after_hooks: Callable[[], None]) -> None:
mw.perform_op(
lambda: mw.col.update_note(note),
after_hooks=after_hooks,
)
def remove_notes(
*,
mw: AnkiQt,
note_ids: Sequence[int],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success)

View file

@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
import aqt import aqt
from anki.collection import OpChanges
from aqt import gui_hooks from aqt import gui_hooks
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
@ -42,6 +43,7 @@ class Overview:
self.mw = mw self.mw = mw
self.web = mw.web self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
self._refresh_needed = False
def show(self) -> None: def show(self) -> None:
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
@ -55,6 +57,20 @@ class Overview:
self._renderBottom() self._renderBottom()
self.mw.web.setFocus() self.mw.web.setFocus()
gui_hooks.overview_did_refresh(self) gui_hooks.overview_did_refresh(self)
self._refresh_needed = False
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.refresh()
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
if self.mw.col.op_affects_study_queue(changes):
self._refresh_needed = True
if focused:
self.refresh_if_needed()
return self._refresh_needed
# Handlers # Handlers
############################################################ ############################################################

View file

@ -1,11 +1,14 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# mypy: check-untyped-defs
from __future__ import annotations
import json import json
import re import re
import time import time
from typing import Any, Callable, Optional, Tuple, Union from typing import Any, Callable, Optional, Tuple, Union
import aqt.browser
from anki.cards import Card from anki.cards import Card
from anki.collection import Config from anki.collection import Config
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -300,6 +303,12 @@ class MultiCardPreviewer(Previewer):
class BrowserPreviewer(MultiCardPreviewer): class BrowserPreviewer(MultiCardPreviewer):
_last_card_id = 0 _last_card_id = 0
_parent: Optional[aqt.browser.Browser]
def __init__(
self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None]
) -> None:
super().__init__(parent=parent, mw=mw, on_close=on_close)
def card(self) -> Optional[Card]: def card(self) -> Optional[Card]:
if self._parent.singleCard: if self._parent.singleCard:
@ -317,12 +326,12 @@ class BrowserPreviewer(MultiCardPreviewer):
return changed return changed
def _on_prev_card(self) -> None: def _on_prev_card(self) -> None:
self._parent.editor.saveNow( self._parent.editor.call_after_note_saved(
lambda: self._parent._moveCur(QAbstractItemView.MoveUp) lambda: self._parent._moveCur(QAbstractItemView.MoveUp)
) )
def _on_next_card(self) -> None: def _on_next_card(self) -> None:
self._parent.editor.saveNow( self._parent.editor.call_after_note_saved(
lambda: self._parent._moveCur(QAbstractItemView.MoveDown) lambda: self._parent._moveCur(QAbstractItemView.MoveDown)
) )

View file

@ -16,10 +16,11 @@ from aqt.utils import TR, disable_help_button, tr
class ProgressManager: class ProgressManager:
def __init__(self, mw: aqt.AnkiQt) -> None: def __init__(self, mw: aqt.AnkiQt) -> None:
self.mw = mw self.mw = mw
self.app = QApplication.instance() self.app = mw.app
self.inDB = False self.inDB = False
self.blockUpdates = False self.blockUpdates = False
self._show_timer: Optional[QTimer] = None self._show_timer: Optional[QTimer] = None
self._busy_cursor_timer: Optional[QTimer] = None
self._win: Optional[ProgressDialog] = None self._win: Optional[ProgressDialog] = None
self._levels = 0 self._levels = 0
@ -74,7 +75,7 @@ class ProgressManager:
max: int = 0, max: int = 0,
min: int = 0, min: int = 0,
label: Optional[str] = None, label: Optional[str] = None,
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
immediate: bool = False, immediate: bool = False,
) -> Optional[ProgressDialog]: ) -> Optional[ProgressDialog]:
self._levels += 1 self._levels += 1
@ -94,14 +95,15 @@ class ProgressManager:
self._win.setWindowTitle("Anki") self._win.setWindowTitle("Anki")
self._win.setWindowModality(Qt.ApplicationModal) self._win.setWindowModality(Qt.ApplicationModal)
self._win.setMinimumWidth(300) self._win.setMinimumWidth(300)
self._setBusy() self._busy_cursor_timer = QTimer(self.mw)
self._busy_cursor_timer.setSingleShot(True)
self._busy_cursor_timer.start(300)
qconnect(self._busy_cursor_timer.timeout, self._set_busy_cursor)
self._shown: float = 0 self._shown: float = 0
self._counter = min self._counter = min
self._min = min self._min = min
self._max = max self._max = max
self._firstTime = time.time() self._firstTime = time.time()
self._lastUpdate = time.time()
self._updating = False
self._show_timer = QTimer(self.mw) self._show_timer = QTimer(self.mw)
self._show_timer.setSingleShot(True) self._show_timer.setSingleShot(True)
self._show_timer.start(immediate and 100 or 600) self._show_timer.start(immediate and 100 or 600)
@ -120,13 +122,10 @@ class ProgressManager:
if not self.mw.inMainThread(): if not self.mw.inMainThread():
print("progress.update() called on wrong thread") print("progress.update() called on wrong thread")
return return
if self._updating:
return
if maybeShow: if maybeShow:
self._maybeShow() self._maybeShow()
if not self._shown: if not self._shown:
return return
elapsed = time.time() - self._lastUpdate
if label: if label:
self._win.form.label.setText(label) self._win.form.label.setText(label)
@ -136,19 +135,16 @@ class ProgressManager:
self._counter = value or (self._counter + 1) self._counter = value or (self._counter + 1)
self._win.form.progressBar.setValue(self._counter) self._win.form.progressBar.setValue(self._counter)
if process and elapsed >= 0.2:
self._updating = True
self.app.processEvents() # type: ignore #possibly related to https://github.com/python/mypy/issues/6910
self._updating = False
self._lastUpdate = time.time()
def finish(self) -> None: def finish(self) -> None:
self._levels -= 1 self._levels -= 1
self._levels = max(0, self._levels) self._levels = max(0, self._levels)
if self._levels == 0: if self._levels == 0:
if self._win: if self._win:
self._closeWin() self._closeWin()
self._unsetBusy() if self._busy_cursor_timer:
self._busy_cursor_timer.stop()
self._busy_cursor_timer = None
self._restore_cursor()
if self._show_timer: if self._show_timer:
self._show_timer.stop() self._show_timer.stop()
self._show_timer = None self._show_timer = None
@ -183,14 +179,17 @@ class ProgressManager:
if elap >= 0.5: if elap >= 0.5:
break break
self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # type: ignore #possibly related to https://github.com/python/mypy/issues/6910 self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # type: ignore #possibly related to https://github.com/python/mypy/issues/6910
# if the parent window has been deleted, the progress dialog may have
# already been dropped; delete it if it hasn't been
if not sip.isdeleted(self._win):
self._win.cancel() self._win.cancel()
self._win = None self._win = None
self._shown = 0 self._shown = 0
def _setBusy(self) -> None: def _set_busy_cursor(self) -> None:
self.mw.app.setOverrideCursor(QCursor(Qt.WaitCursor)) self.mw.app.setOverrideCursor(QCursor(Qt.WaitCursor))
def _unsetBusy(self) -> None: def _restore_cursor(self) -> None:
self.app.restoreOverrideCursor() self.app.restoreOverrideCursor()
def busy(self) -> int: def busy(self) -> int:

View file

@ -7,19 +7,30 @@ import html
import json import json
import re import re
import unicodedata as ucd import unicodedata as ucd
from enum import Enum, auto
from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.collection import Config from anki.collection import Config, OpChanges
from anki.tags import MARKED_TAG
from anki.utils import stripHTML from anki.utils import stripHTML
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.card_ops import set_card_flag
from aqt.note_ops import remove_notes
from aqt.profiles import VideoDriver from aqt.profiles import VideoDriver
from aqt.qt import * from aqt.qt import *
from aqt.scheduling import set_due_date_dialog from aqt.scheduling_ops import (
bury_cards,
bury_note,
set_due_date_dialog,
suspend_cards,
suspend_note,
)
from aqt.sound import av_player, play_clicked_audio, record_audio from aqt.sound import av_player, play_clicked_audio, record_audio
from aqt.tag_ops import add_tags, remove_tags_for_notes
from aqt.theme import theme_manager from aqt.theme import theme_manager
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
from aqt.utils import ( from aqt.utils import (
@ -33,6 +44,12 @@ from aqt.utils import (
from aqt.webview import AnkiWebView from aqt.webview import AnkiWebView
class RefreshNeeded(Enum):
NO = auto()
NOTE_TEXT = auto()
QUEUES = auto()
class ReviewerBottomBar: class ReviewerBottomBar:
def __init__(self, reviewer: Reviewer) -> None: def __init__(self, reviewer: Reviewer) -> None:
self.reviewer = reviewer self.reviewer = reviewer
@ -61,16 +78,17 @@ class Reviewer:
self._recordedAudio: Optional[str] = None self._recordedAudio: Optional[str] = None
self.typeCorrect: str = None # web init happens before this is set self.typeCorrect: str = None # web init happens before this is set
self.state: Optional[str] = None self.state: Optional[str] = None
self._refresh_needed = RefreshNeeded.NO
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech) hooks.card_did_leech.append(self.onLeech)
def show(self) -> None: def show(self) -> None:
self.mw.col.reset()
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self) self.web.set_bridge_command(self._linkHandler, self)
self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self))
self._reps: int = None self._reps: int = None
self.nextCard() self._refresh_needed = RefreshNeeded.QUEUES
self.refresh_if_needed()
def lastCard(self) -> Optional[Card]: def lastCard(self) -> Optional[Card]:
if self._answeredIds: if self._answeredIds:
@ -86,6 +104,42 @@ class Reviewer:
gui_hooks.reviewer_will_end() gui_hooks.reviewer_will_end()
self.card = None self.card = None
def refresh_if_needed(self) -> None:
if self._refresh_needed is RefreshNeeded.QUEUES:
self.mw.col.reset()
self.nextCard()
self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO
elif self._refresh_needed is RefreshNeeded.NOTE_TEXT:
self._redraw_current_card()
self.mw.fade_in_webview()
self._refresh_needed = RefreshNeeded.NO
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
if focused and self._refresh_needed is not RefreshNeeded.NO:
self.refresh_if_needed()
return self._refresh_needed is not RefreshNeeded.NO
def _redraw_current_card(self) -> None:
self.card.load()
if self.state == "answer":
self._showAnswer()
else:
self._showQuestion()
# Fetching a card # Fetching a card
########################################################################## ##########################################################################
@ -219,7 +273,7 @@ class Reviewer:
self.web.eval(f"_drawFlag({self.card.user_flag()});") self.web.eval(f"_drawFlag({self.card.user_flag()});")
def _update_mark_icon(self) -> None: def _update_mark_icon(self) -> None:
self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag('marked'))});") self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag(MARKED_TAG))});")
_drawMark = _update_mark_icon _drawMark = _update_mark_icon
_drawFlag = _update_flag_icon _drawFlag = _update_flag_icon
@ -782,24 +836,23 @@ time = %(time)d;
def onOptions(self) -> None: def onOptions(self) -> None:
self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did)) self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did))
def set_flag_on_current_card(self, flag: int) -> None: def set_flag_on_current_card(self, desired_flag: int) -> None:
# need to toggle off? # need to toggle off?
if self.card.user_flag() == flag: if self.card.user_flag() == desired_flag:
flag = 0 flag = 0
self.card.set_user_flag(flag) else:
self.mw.col.update_card(self.card) flag = desired_flag
self.mw.update_undo_actions()
self._update_flag_icon() set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag)
def toggle_mark_on_current_note(self) -> None: def toggle_mark_on_current_note(self) -> None:
note = self.card.note() note = self.card.note()
if note.has_tag("marked"): if note.has_tag(MARKED_TAG):
note.remove_tag("marked") remove_tags_for_notes(
mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG
)
else: else:
note.add_tag("marked") add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG)
self.mw.col.update_note(note)
self.mw.update_undo_actions()
self._update_mark_icon()
def on_set_due(self) -> None: def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
@ -810,38 +863,52 @@ time = %(time)d;
parent=self.mw, parent=self.mw,
card_ids=[self.card.id], card_ids=[self.card.id],
config_key=Config.String.SET_DUE_REVIEWER, config_key=Config.String.SET_DUE_REVIEWER,
on_done=self.mw.reset,
) )
def suspend_current_note(self) -> None: def suspend_current_note(self) -> None:
self.mw.col.sched.suspend_cards([c.id for c in self.card.note().cards()]) suspend_note(
self.mw.reset() mw=self.mw,
tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)) note_id=self.card.nid,
success=lambda _: tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)),
)
def suspend_current_card(self) -> None: def suspend_current_card(self) -> None:
self.mw.col.sched.suspend_cards([self.card.id]) suspend_cards(
self.mw.reset() mw=self.mw,
tooltip(tr(TR.STUDYING_CARD_SUSPENDED)) card_ids=[self.card.id],
success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)),
def bury_current_card(self) -> None: )
self.mw.col.sched.bury_cards([self.card.id])
self.mw.reset()
tooltip(tr(TR.STUDYING_CARD_BURIED))
def bury_current_note(self) -> None: def bury_current_note(self) -> None:
self.mw.col.sched.bury_note(self.card.note()) bury_note(
self.mw.reset() mw=self.mw,
tooltip(tr(TR.STUDYING_NOTE_BURIED)) note_id=self.card.nid,
success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)),
)
def bury_current_card(self) -> None:
bury_cards(
mw=self.mw,
card_ids=[self.card.id],
success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)),
)
def delete_current_note(self) -> None: def delete_current_note(self) -> None:
# need to check state because the shortcut is global to the main # need to check state because the shortcut is global to the main
# window # window
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
return return
# fixme: pass this back from the backend method instead
cnt = len(self.card.note().cards()) cnt = len(self.card.note().cards())
self.mw.col.remove_notes([self.card.note().id])
self.mw.reset() remove_notes(
tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)) mw=self.mw,
note_ids=[self.card.nid],
success=lambda _: tooltip(
tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)
),
)
def onRecordVoice(self) -> None: def onRecordVoice(self) -> None:
def after_record(path: str) -> None: def after_record(path: str) -> None:

View file

@ -1,82 +0,0 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from concurrent.futures import Future
from typing import List, Optional
import aqt
from anki.collection import Config
from anki.lang import TR
from aqt.qt import *
from aqt.utils import getText, showWarning, tooltip, tr
def set_due_date_dialog(
*,
mw: aqt.AnkiQt,
parent: QDialog,
card_ids: List[int],
config_key: Optional[Config.String.Key.V],
on_done: Callable[[], None],
) -> None:
if not card_ids:
return
default = mw.col.get_config_string(config_key) if config_key is not None else ""
prompt = "\n".join(
[
tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)),
tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT),
]
)
(days, success) = getText(
prompt=prompt,
parent=parent,
default=default,
title=tr(TR.ACTIONS_SET_DUE_DATE),
)
if not success or not days.strip():
return
def set_due() -> None:
mw.col.sched.set_due_date(card_ids, days, config_key)
def after_set(fut: Future) -> None:
try:
fut.result()
except Exception as e:
showWarning(str(e))
on_done()
return
tooltip(
tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)),
parent=parent,
)
on_done()
mw.taskman.with_progress(set_due, after_set)
def forget_cards(
*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int], on_done: Callable[[], None]
) -> None:
if not card_ids:
return
def on_done_wrapper(fut: Future) -> None:
try:
fut.result()
except Exception as e:
showWarning(str(e))
else:
tooltip(tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent)
on_done()
mw.taskman.with_progress(
lambda: mw.col.sched.schedule_cards_as_new(card_ids), on_done_wrapper
)

175
qt/aqt/scheduling_ops.py Normal file
View file

@ -0,0 +1,175 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import List, Optional, Sequence
import aqt
from anki.collection import CARD_TYPE_NEW, Config
from anki.lang import TR
from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback
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: List[int],
config_key: Optional[Config.String.Key.V],
) -> None:
if not card_ids:
return
default_text = (
mw.col.get_config_string(config_key) if config_key is not None else ""
)
prompt = "\n".join(
[
tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)),
tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT),
]
)
(days, success) = getText(
prompt=prompt,
parent=parent,
default=default_text,
title=tr(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(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)),
parent=parent,
),
)
def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> None:
if not card_ids:
return
mw.perform_op(
lambda: mw.col.sched.schedule_cards_as_new(card_ids),
success=lambda _: tooltip(
tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent
),
)
def reposition_new_cards_dialog(
*, mw: AnkiQt, parent: QWidget, card_ids: Sequence[int]
) -> None:
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"
)
assert row
(min_position, max_position) = row
min_position = max(min_position or 0, 0)
max_position = max_position or 0
d = QDialog(parent)
disable_help_button(d)
d.setWindowModality(Qt.WindowModal)
frm = aqt.forms.reposition.Ui_Dialog()
frm.setupUi(d)
txt = tr(TR.BROWSING_QUEUE_TOP, val=min_position)
txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=max_position)
frm.label.setText(txt)
frm.start.selectAll()
if not d.exec_():
return
start = frm.start.value()
step = frm.step.value()
randomize = frm.randomize.isChecked()
shift = frm.shift.isChecked()
reposition_new_cards(
mw=mw,
parent=parent,
card_ids=card_ids,
starting_from=start,
step_size=step,
randomize=randomize,
shift_existing=shift,
)
def reposition_new_cards(
*,
mw: AnkiQt,
parent: QWidget,
card_ids: Sequence[int],
starting_from: int,
step_size: int,
randomize: bool,
shift_existing: bool,
) -> None:
mw.perform_op(
lambda: mw.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(
tr(TR.BROWSING_CHANGED_NEW_POSITION, count=out.count), parent=parent
),
)
def suspend_cards(
*,
mw: AnkiQt,
card_ids: Sequence[int],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success)
def suspend_note(
*,
mw: AnkiQt,
note_id: int,
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),
)
def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[int]) -> None:
mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids))
def bury_cards(
*,
mw: AnkiQt,
card_ids: Sequence[int],
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success)
def bury_note(
*,
mw: AnkiQt,
note_id: int,
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),
)

View file

@ -8,7 +8,7 @@ from enum import Enum, auto
from typing import Dict, Iterable, List, Optional, Tuple, cast from typing import Dict, Iterable, List, Optional, Tuple, cast
import aqt import aqt
from anki.collection import Config, SearchJoiner, SearchNode from anki.collection import Config, OpChanges, SearchJoiner, SearchNode
from anki.decks import DeckTreeNode from anki.decks import DeckTreeNode
from anki.errors import DeckIsFilteredError, InvalidInput from anki.errors import DeckIsFilteredError, InvalidInput
from anki.notes import Note from anki.notes import Note
@ -16,18 +16,18 @@ from anki.tags import TagTreeNode
from anki.types import assert_exhaustive from anki.types import assert_exhaustive
from aqt import colors, gui_hooks from aqt import colors, gui_hooks
from aqt.clayout import CardLayout from aqt.clayout import CardLayout
from aqt.main import ResetReason from aqt.deck_ops import remove_decks
from aqt.models import Models from aqt.models import Models
from aqt.qt import * from aqt.qt import *
from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags
from aqt.theme import ColoredIcon, theme_manager from aqt.theme import ColoredIcon, theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
KeyboardModifiersPressed,
askUser, askUser,
getOnlyText, getOnlyText,
show_invalid_search_error, show_invalid_search_error,
showInfo,
showWarning, showWarning,
tooltip,
tr, tr,
) )
@ -253,9 +253,7 @@ class SidebarModel(QAbstractItemModel):
return QVariant(item.tooltip) return QVariant(item.tooltip)
return QVariant(theme_manager.icon_from_resources(item.icon)) return QVariant(theme_manager.icon_from_resources(item.icon))
def setData( def setData(self, index: QModelIndex, text: str, _role: int = Qt.EditRole) -> bool:
self, index: QModelIndex, text: QVariant, _role: int = Qt.EditRole
) -> bool:
return self.sidebar._on_rename(index.internalPointer(), text) return self.sidebar._on_rename(index.internalPointer(), text)
def supportedDropActions(self) -> Qt.DropActions: def supportedDropActions(self) -> Qt.DropActions:
@ -353,6 +351,10 @@ def _want_right_border() -> bool:
return not isMac or theme_manager.night_mode return not isMac or theme_manager.night_mode
# fixme: we should have a top-level Sidebar class inheriting from QWidget that
# handles the treeview, search bar and so on. Currently the treeview embeds the
# search bar which is wrong, and the layout code is handled in browser.py instead
# of here
class SidebarTreeView(QTreeView): class SidebarTreeView(QTreeView):
def __init__(self, browser: aqt.browser.Browser) -> None: def __init__(self, browser: aqt.browser.Browser) -> None:
super().__init__() super().__init__()
@ -361,6 +363,7 @@ class SidebarTreeView(QTreeView):
self.col = self.mw.col self.col = self.mw.col
self.current_search: Optional[str] = None self.current_search: Optional[str] = None
self.valid_drop_types: Tuple[SidebarItemType, ...] = () self.valid_drop_types: Tuple[SidebarItemType, ...] = ()
self._refresh_needed = False
self.setContextMenuPolicy(Qt.CustomContextMenu) self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore
@ -388,6 +391,10 @@ class SidebarTreeView(QTreeView):
self.setStyleSheet("QTreeView { %s }" % ";".join(styles)) self.setStyleSheet("QTreeView { %s }" % ";".join(styles))
# these do not really belong here, they should be in a higher-level class
self.toolbar = SidebarToolbar(self)
self.searchBar = SidebarSearchBar(self)
@property @property
def tool(self) -> SidebarTool: def tool(self) -> SidebarTool:
return self._tool return self._tool
@ -408,7 +415,21 @@ class SidebarTreeView(QTreeView):
self.setExpandsOnDoubleClick(double_click_expands) self.setExpandsOnDoubleClick(double_click_expands)
def model(self) -> SidebarModel: def model(self) -> SidebarModel:
return super().model() return cast(SidebarModel, super().model())
# Refreshing
###########################
def op_executed(self, op: OpChanges, focused: bool) -> None:
if op.tag or op.notetype or op.deck:
self._refresh_needed = True
if focused:
self.refresh_if_needed()
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.refresh()
self._refresh_needed = False
def refresh( def refresh(
self, is_current: Optional[Callable[[SidebarItem], bool]] = None self, is_current: Optional[Callable[[SidebarItem], bool]] = None
@ -417,16 +438,17 @@ class SidebarTreeView(QTreeView):
if not self.isVisible(): if not self.isVisible():
return return
def on_done(fut: Future) -> None: def on_done(root: SidebarItem) -> None:
self.setUpdatesEnabled(True) # user may have closed browser
root = fut.result() if sip.isdeleted(self):
return
# block repainting during refreshing to avoid flickering
self.setUpdatesEnabled(False)
model = SidebarModel(self, root) model = SidebarModel(self, root)
# from PyQt5.QtTest import QAbstractItemModelTester
# tester = QAbstractItemModelTester(model)
self.setModel(model) self.setModel(model)
qconnect(self.selectionModel().selectionChanged, self._on_selection_changed)
if self.current_search: if self.current_search:
self.search_for(self.current_search) self.search_for(self.current_search)
else: else:
@ -434,9 +456,12 @@ class SidebarTreeView(QTreeView):
if is_current: if is_current:
self.restore_current(is_current) self.restore_current(is_current)
# block repainting during refreshing to avoid flickering self.setUpdatesEnabled(True)
self.setUpdatesEnabled(False)
self.mw.taskman.run_in_background(self._root_tree, on_done) # needs to be set after changing model
qconnect(self.selectionModel().selectionChanged, self._on_selection_changed)
self.mw.query_op(self._root_tree, success=on_done)
def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None: def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None:
if current := self.find_item(is_current): if current := self.find_item(is_current):
@ -496,22 +521,22 @@ class SidebarTreeView(QTreeView):
joiner: SearchJoiner = "AND", joiner: SearchJoiner = "AND",
) -> None: ) -> None:
"""Modify the current search string based on modifier keys, then refresh.""" """Modify the current search string based on modifier keys, then refresh."""
mods = self.mw.app.keyboardModifiers() mods = KeyboardModifiersPressed()
previous = SearchNode(parsable_text=self.browser.current_search()) previous = SearchNode(parsable_text=self.browser.current_search())
current = self.mw.col.group_searches(*terms, joiner=joiner) current = self.mw.col.group_searches(*terms, joiner=joiner)
# if Alt pressed, invert # if Alt pressed, invert
if mods & Qt.AltModifier: if mods.alt:
current = SearchNode(negated=current) current = SearchNode(negated=current)
try: try:
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: if mods.control and mods.shift:
# If Ctrl+Shift, replace searches nodes of the same type. # If Ctrl+Shift, replace searches nodes of the same type.
search = self.col.replace_in_search_node(previous, current) search = self.col.replace_in_search_node(previous, current)
elif mods & Qt.ControlModifier: elif mods.control:
# If Ctrl, AND with previous # If Ctrl, AND with previous
search = self.col.join_searches(previous, current, "AND") search = self.col.join_searches(previous, current, "AND")
elif mods & Qt.ShiftModifier: elif mods.shift:
# If Shift, OR with previous # If Shift, OR with previous
search = self.col.join_searches(previous, current, "OR") search = self.col.join_searches(previous, current, "OR")
else: else:
@ -602,39 +627,27 @@ class SidebarTreeView(QTreeView):
lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done
) )
self.browser.editor.saveNow(on_save) self.browser.editor.call_after_note_saved(on_save)
return True return True
def _handle_drag_drop_tags( def _handle_drag_drop_tags(
self, sources: List[SidebarItem], target: SidebarItem self, sources: List[SidebarItem], target: SidebarItem
) -> bool: ) -> bool:
source_ids = [ tags = [
source.full_name source.full_name
for source in sources for source in sources
if source.item_type == SidebarItemType.TAG if source.item_type == SidebarItemType.TAG
] ]
if not source_ids: if not tags:
return False return False
def on_done(fut: Future) -> None:
self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self)
self.browser.model.endReset()
fut.result()
self.refresh()
if target.item_type == SidebarItemType.TAG_ROOT: if target.item_type == SidebarItemType.TAG_ROOT:
target_name = "" new_parent = ""
else: else:
target_name = target.full_name new_parent = target.full_name
def on_save() -> None: reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent)
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
self.browser.model.beginReset()
self.mw.taskman.with_progress(
lambda: self.col.tags.drag_drop(source_ids, target_name), on_done
)
self.browser.editor.saveNow(on_save)
return True return True
def _on_search(self, index: QModelIndex) -> None: def _on_search(self, index: QModelIndex) -> None:
@ -693,7 +706,9 @@ class SidebarTreeView(QTreeView):
for stage in SidebarStage: for stage in SidebarStage:
if stage == SidebarStage.ROOT: if stage == SidebarStage.ROOT:
root = SidebarItem("", "", item_type=SidebarItemType.ROOT) root = SidebarItem("", "", item_type=SidebarItemType.ROOT)
handled = gui_hooks.browser_will_build_tree(False, root, stage, self) handled = gui_hooks.browser_will_build_tree(
False, root, stage, self.browser
)
if not handled: if not handled:
self._build_stage(root, stage) self._build_stage(root, stage)
@ -1166,79 +1181,41 @@ class SidebarTreeView(QTreeView):
self.mw.update_undo_actions() self.mw.update_undo_actions()
def delete_decks(self, _item: SidebarItem) -> None: def delete_decks(self, _item: SidebarItem) -> None:
self.browser.editor.saveNow(self._delete_decks) remove_decks(mw=self.mw, parent=self.browser, deck_ids=self._selected_decks())
def _delete_decks(self) -> None:
def do_delete() -> int:
return self.mw.col.decks.remove(dids)
def on_done(fut: Future) -> None:
self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self)
self.browser.search()
self.browser.model.endReset()
tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()), parent=self)
self.refresh()
dids = self._selected_decks()
self.browser.model.beginReset()
self.mw.taskman.with_progress(do_delete, on_done)
# Tags # Tags
########################### ###########################
def remove_tags(self, item: SidebarItem) -> None: def remove_tags(self, item: SidebarItem) -> None:
self.browser.editor.saveNow(lambda: self._remove_tags(item)) tags = self.mw.col.tags.join(self._selected_tags())
item.name = "..."
def _remove_tags(self, _item: SidebarItem) -> None: remove_tags_for_all_notes(
tags = self._selected_tags() mw=self.mw, parent=self.browser, space_separated_tags=tags
)
def do_remove() -> int:
return self.col._backend.expunge_tags(" ".join(tags))
def on_done(fut: Future) -> None:
self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self)
self.browser.model.endReset()
tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=fut.result()), parent=self)
self.refresh()
self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG))
self.browser.model.beginReset()
self.mw.taskman.with_progress(do_remove, on_done)
def rename_tag(self, item: SidebarItem, new_name: str) -> None: def rename_tag(self, item: SidebarItem, new_name: str) -> None:
new_name = new_name.replace(" ", "") if not new_name or new_name == item.name:
if new_name and new_name != item.name: return
# block repainting until collection is updated
self.setUpdatesEnabled(False) new_name_base = new_name
self.browser.editor.saveNow(lambda: self._rename_tag(item, new_name))
def _rename_tag(self, item: SidebarItem, new_name: str) -> None:
old_name = item.full_name old_name = item.full_name
new_name = item.name_prefix + new_name new_name = item.name_prefix + new_name
def do_rename() -> int: item.name = new_name_base
self.mw.col.tags.remove(old_name)
return self.col.tags.rename(old_name, new_name)
def on_done(fut: Future) -> None: rename_tag(
self.setUpdatesEnabled(True) mw=self.mw,
self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) parent=self.browser,
self.browser.model.endReset() current_name=old_name,
new_name=new_name,
count = fut.result() after_rename=lambda: self.refresh(
if not count:
showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
else:
tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=count), parent=self)
self.refresh(
lambda item: item.item_type == SidebarItemType.TAG lambda item: item.item_type == SidebarItemType.TAG
and item.full_name == new_name and item.full_name == new_name
),
) )
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
self.browser.model.beginReset()
self.mw.taskman.with_progress(do_rename, on_done)
# Saved searches # Saved searches
#################################### ####################################

View file

@ -15,7 +15,7 @@ import wave
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from concurrent.futures import Future from concurrent.futures import Future
from operator import itemgetter from operator import itemgetter
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast
import aqt import aqt
from anki import hooks from anki import hooks
@ -568,7 +568,7 @@ class QtAudioInputRecorder(Recorder):
super().start(on_done) super().start(on_done)
def _on_read_ready(self) -> None: def _on_read_ready(self) -> None:
self._buffer += self._iodevice.readAll() self._buffer += cast(bytes, self._iodevice.readAll())
def stop(self, on_done: Callable[[str], None]) -> None: def stop(self, on_done: Callable[[str], None]) -> None:
def on_stop_timer() -> None: def on_stop_timer() -> None:

View file

@ -33,9 +33,9 @@ class StudyDeck(QDialog):
help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS, help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS,
current: Optional[str] = None, current: Optional[str] = None,
cancel: bool = True, cancel: bool = True,
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
dyn: bool = False, dyn: bool = False,
buttons: Optional[List[str]] = None, buttons: Optional[List[Union[str, QPushButton]]] = None,
geomKey: str = "default", geomKey: str = "default",
) -> None: ) -> None:
QDialog.__init__(self, parent or mw) QDialog.__init__(self, parent or mw)
@ -53,8 +53,10 @@ class StudyDeck(QDialog):
self.form.buttonBox.button(QDialogButtonBox.Cancel) self.form.buttonBox.button(QDialogButtonBox.Cancel)
) )
if buttons is not None: if buttons is not None:
for b in buttons: for button_or_label in buttons:
self.form.buttonBox.addButton(b, QDialogButtonBox.ActionRole) self.form.buttonBox.addButton(
button_or_label, QDialogButtonBox.ActionRole
)
else: else:
b = QPushButton(tr(TR.ACTIONS_ADD)) b = QPushButton(tr(TR.ACTIONS_ADD))
b.setShortcut(QKeySequence("Ctrl+N")) b.setShortcut(QKeySequence("Ctrl+N"))
@ -89,7 +91,7 @@ class StudyDeck(QDialog):
self.exec_() self.exec_()
def eventFilter(self, obj: QObject, evt: QEvent) -> bool: def eventFilter(self, obj: QObject, evt: QEvent) -> bool:
if evt.type() == QEvent.KeyPress: if isinstance(evt, QKeyEvent) and evt.type() == QEvent.KeyPress:
new_row = current_row = self.form.list.currentRow() new_row = current_row = self.form.list.currentRow()
rows_count = self.form.list.count() rows_count = self.form.list.count()
key = evt.key() key = evt.key()
@ -98,7 +100,10 @@ class StudyDeck(QDialog):
new_row = current_row - 1 new_row = current_row - 1
elif key == Qt.Key_Down: elif key == Qt.Key_Down:
new_row = current_row + 1 new_row = current_row + 1
elif evt.modifiers() & Qt.ControlModifier and Qt.Key_1 <= key <= Qt.Key_9: elif (
int(evt.modifiers()) & Qt.ControlModifier
and Qt.Key_1 <= key <= Qt.Key_9
):
row_index = key - Qt.Key_1 row_index = key - Qt.Key_1
if row_index < rows_count: if row_index < rows_count:
new_row = row_index new_row = row_index

88
qt/aqt/tag_ops.py Normal file
View file

@ -0,0 +1,88 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Callable, Sequence
from anki.collection import OpChangesWithCount
from anki.lang import TR
from aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.utils import showInfo, tooltip, tr
def add_tags(
*,
mw: AnkiQt,
note_ids: Sequence[int],
space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(
lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success
)
def remove_tags_for_notes(
*,
mw: AnkiQt,
note_ids: Sequence[int],
space_separated_tags: str,
success: PerformOpOptionalSuccessCallback = None,
) -> None:
mw.perform_op(
lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success
)
def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None:
mw.perform_op(
mw.col.tags.clear_unused_tags,
success=lambda out: tooltip(
tr(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:
def success(out: OpChangesWithCount) -> None:
if out.count:
tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent)
else:
showInfo(tr(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,
)
def remove_tags_for_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(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(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent
),
)

View file

@ -12,24 +12,24 @@ from aqt.qt import *
class TagEdit(QLineEdit): class TagEdit(QLineEdit):
completer: Union[QCompleter, TagCompleter] _completer: Union[QCompleter, TagCompleter]
lostFocus = pyqtSignal() lostFocus = pyqtSignal()
# 0 = tags, 1 = decks # 0 = tags, 1 = decks
def __init__(self, parent: QDialog, type: int = 0) -> None: def __init__(self, parent: QWidget, type: int = 0) -> None:
QLineEdit.__init__(self, parent) QLineEdit.__init__(self, parent)
self.col: Optional[Collection] = None self.col: Optional[Collection] = None
self.model = QStringListModel() self.model = QStringListModel()
self.type = type self.type = type
if type == 0: if type == 0:
self.completer = TagCompleter(self.model, parent, self) self._completer = TagCompleter(self.model, parent, self)
else: else:
self.completer = QCompleter(self.model, parent) self._completer = QCompleter(self.model, parent)
self.completer.setCompletionMode(QCompleter.PopupCompletion) self._completer.setCompletionMode(QCompleter.PopupCompletion)
self.completer.setCaseSensitivity(Qt.CaseInsensitive) self._completer.setCaseSensitivity(Qt.CaseInsensitive)
self.completer.setFilterMode(Qt.MatchContains) self._completer.setFilterMode(Qt.MatchContains)
self.setCompleter(self.completer) self.setCompleter(self._completer)
def setCol(self, col: Collection) -> None: def setCol(self, col: Collection) -> None:
"Set the current col, updating list of available tags." "Set the current col, updating list of available tags."
@ -47,29 +47,29 @@ class TagEdit(QLineEdit):
def keyPressEvent(self, evt: QKeyEvent) -> None: def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() in (Qt.Key_Up, Qt.Key_Down): if evt.key() in (Qt.Key_Up, Qt.Key_Down):
# show completer on arrow key up/down # show completer on arrow key up/down
if not self.completer.popup().isVisible(): if not self._completer.popup().isVisible():
self.showCompleter() self.showCompleter()
return return
if evt.key() == Qt.Key_Tab and evt.modifiers() & Qt.ControlModifier: if evt.key() == Qt.Key_Tab and int(evt.modifiers()) & Qt.ControlModifier:
# select next completion # select next completion
if not self.completer.popup().isVisible(): if not self._completer.popup().isVisible():
self.showCompleter() self.showCompleter()
index = self.completer.currentIndex() index = self._completer.currentIndex()
self.completer.popup().setCurrentIndex(index) self._completer.popup().setCurrentIndex(index)
cur_row = index.row() cur_row = index.row()
if not self.completer.setCurrentRow(cur_row + 1): if not self._completer.setCurrentRow(cur_row + 1):
self.completer.setCurrentRow(0) self._completer.setCurrentRow(0)
return return
if ( if (
evt.key() in (Qt.Key_Enter, Qt.Key_Return) evt.key() in (Qt.Key_Enter, Qt.Key_Return)
and self.completer.popup().isVisible() and self._completer.popup().isVisible()
): ):
# apply first completion if no suggestion selected # apply first completion if no suggestion selected
selected_row = self.completer.popup().currentIndex().row() selected_row = self._completer.popup().currentIndex().row()
if selected_row == -1: if selected_row == -1:
self.completer.setCurrentRow(0) self._completer.setCurrentRow(0)
index = self.completer.currentIndex() index = self._completer.currentIndex()
self.completer.popup().setCurrentIndex(index) self._completer.popup().setCurrentIndex(index)
self.hideCompleter() self.hideCompleter()
QWidget.keyPressEvent(self, evt) QWidget.keyPressEvent(self, evt)
return return
@ -90,18 +90,18 @@ class TagEdit(QLineEdit):
gui_hooks.tag_editor_did_process_key(self, evt) gui_hooks.tag_editor_did_process_key(self, evt)
def showCompleter(self) -> None: def showCompleter(self) -> None:
self.completer.setCompletionPrefix(self.text()) self._completer.setCompletionPrefix(self.text())
self.completer.complete() self._completer.complete()
def focusOutEvent(self, evt: QFocusEvent) -> None: def focusOutEvent(self, evt: QFocusEvent) -> None:
QLineEdit.focusOutEvent(self, evt) QLineEdit.focusOutEvent(self, evt)
self.lostFocus.emit() # type: ignore self.lostFocus.emit() # type: ignore
self.completer.popup().hide() self._completer.popup().hide()
def hideCompleter(self) -> None: def hideCompleter(self) -> None:
if sip.isdeleted(self.completer): if sip.isdeleted(self._completer):
return return
self.completer.popup().hide() self._completer.popup().hide()
class TagCompleter(QCompleter): class TagCompleter(QCompleter):

View file

@ -15,8 +15,8 @@ class TagLimit(QDialog):
self.tags: str = "" self.tags: str = ""
self.tags_list: List[str] = [] self.tags_list: List[str] = []
self.mw = mw self.mw = mw
self.parent: Optional[QWidget] = parent self.parent_: Optional[CustomStudy] = parent
self.deck = self.parent.deck self.deck = self.parent_.deck
self.dialog = aqt.forms.taglimit.Ui_Dialog() self.dialog = aqt.forms.taglimit.Ui_Dialog()
self.dialog.setupUi(self) self.dialog.setupUi(self)
disable_help_button(self) disable_help_button(self)

View file

@ -3,6 +3,8 @@
""" """
Helper for running tasks on background threads. Helper for running tasks on background threads.
See mw.query_op() and mw.perform_op() for slightly higher-level routines.
""" """
from __future__ import annotations from __future__ import annotations
@ -49,6 +51,14 @@ class TaskManager(QObject):
the completed future. the completed future.
Args if provided will be passed on as keyword arguments to the task callable.""" Args if provided will be passed on as keyword arguments to the task callable."""
# Before we launch a background task, ensure any pending on_done closure are run on
# main. Qt's signal/slot system will have posted a notification, but it may
# not have been processed yet. The on_done() closures may make small queries
# to the database that we want to run first - if we delay them until after the
# background task starts, and it takes out a long-running lock on the database,
# the UI thread will hang until the end of the op.
self._on_closures_pending()
if args is None: if args is None:
args = {} args = {}

View file

@ -86,12 +86,12 @@ class ThemeManager:
else: else:
# specified colours # specified colours
icon = QIcon(path.path) icon = QIcon(path.path)
img = icon.pixmap(16) pixmap = icon.pixmap(16)
painter = QPainter(img) painter = QPainter(pixmap)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn) painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
painter.fillRect(img.rect(), QColor(path.current_color(self.night_mode))) painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode)))
painter.end() painter.end()
icon = QIcon(img) icon = QIcon(pixmap)
return icon return icon
return cache.setdefault(path, icon) return cache.setdefault(path, icon)

View file

@ -7,6 +7,7 @@ import re
import subprocess import subprocess
import sys import sys
from enum import Enum from enum import Enum
from functools import wraps
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
@ -110,7 +111,7 @@ def openHelp(section: HelpPageArgument) -> None:
openLink(link) openLink(link)
def openLink(link: str) -> None: def openLink(link: Union[str, QUrl]) -> None:
tooltip(tr(TR.QT_MISC_LOADING), period=1000) tooltip(tr(TR.QT_MISC_LOADING), period=1000)
with noBundledLibs(): with noBundledLibs():
QDesktopServices.openUrl(QUrl(link)) QDesktopServices.openUrl(QUrl(link))
@ -118,7 +119,7 @@ def openLink(link: str) -> None:
def showWarning( def showWarning(
text: str, text: str,
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
help: HelpPageArgument = "", help: HelpPageArgument = "",
title: str = "Anki", title: str = "Anki",
textFormat: Optional[TextFormat] = None, textFormat: Optional[TextFormat] = None,
@ -138,17 +139,17 @@ def showCritical(
return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat) return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat)
def show_invalid_search_error(err: Exception) -> None: def show_invalid_search_error(err: Exception, parent: Optional[QWidget] = None) -> None:
"Render search errors in markdown, then display a warning." "Render search errors in markdown, then display a warning."
text = str(err) text = str(err)
if isinstance(err, InvalidInput): if isinstance(err, InvalidInput):
text = markdown(text) text = markdown(text)
showWarning(text) showWarning(text, parent=parent)
def showInfo( def showInfo(
text: str, text: str,
parent: Union[Literal[False], QDialog] = False, parent: Optional[QWidget] = None,
help: HelpPageArgument = "", help: HelpPageArgument = "",
type: str = "info", type: str = "info",
title: str = "Anki", title: str = "Anki",
@ -157,7 +158,7 @@ def showInfo(
) -> int: ) -> int:
"Show a small info window with an OK button." "Show a small info window with an OK button."
parent_widget: QWidget parent_widget: QWidget
if parent is False: if parent is None:
parent_widget = aqt.mw.app.activeWindow() or aqt.mw parent_widget = aqt.mw.app.activeWindow() or aqt.mw
else: else:
parent_widget = parent parent_widget = parent
@ -213,6 +214,7 @@ def showText(
disable_help_button(diag) disable_help_button(diag)
layout = QVBoxLayout(diag) layout = QVBoxLayout(diag)
diag.setLayout(layout) diag.setLayout(layout)
text: Union[QPlainTextEdit, QTextBrowser]
if plain_text_edit: if plain_text_edit:
# used by the importer # used by the importer
text = QPlainTextEdit() text = QPlainTextEdit()
@ -262,7 +264,7 @@ def showText(
def askUser( def askUser(
text: str, text: str,
parent: QDialog = None, parent: QWidget = None,
help: HelpPageArgument = None, help: HelpPageArgument = None,
defaultno: bool = False, defaultno: bool = False,
msgfunc: Optional[Callable] = None, msgfunc: Optional[Callable] = None,
@ -295,7 +297,7 @@ class ButtonedDialog(QMessageBox):
self, self,
text: str, text: str,
buttons: List[str], buttons: List[str],
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
help: HelpPageArgument = None, help: HelpPageArgument = None,
title: str = "Anki", title: str = "Anki",
): ):
@ -328,7 +330,7 @@ class ButtonedDialog(QMessageBox):
def askUserDialog( def askUserDialog(
text: str, text: str,
buttons: List[str], buttons: List[str],
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
help: HelpPageArgument = None, help: HelpPageArgument = None,
title: str = "Anki", title: str = "Anki",
) -> ButtonedDialog: ) -> ButtonedDialog:
@ -341,7 +343,7 @@ def askUserDialog(
class GetTextDialog(QDialog): class GetTextDialog(QDialog):
def __init__( def __init__(
self, self,
parent: Optional[QDialog], parent: Optional[QWidget],
question: str, question: str,
help: HelpPageArgument = None, help: HelpPageArgument = None,
edit: Optional[QLineEdit] = None, edit: Optional[QLineEdit] = None,
@ -388,7 +390,7 @@ class GetTextDialog(QDialog):
def getText( def getText(
prompt: str, prompt: str,
parent: Optional[QDialog] = None, parent: Optional[QWidget] = None,
help: HelpPageArgument = None, help: HelpPageArgument = None,
edit: Optional[QLineEdit] = None, edit: Optional[QLineEdit] = None,
default: str = "", default: str = "",
@ -445,7 +447,7 @@ def chooseList(
def getTag( def getTag(
parent: QDialog, deck: Collection, question: str, **kwargs: Any parent: QWidget, deck: Collection, question: str, **kwargs: Any
) -> Tuple[str, int]: ) -> Tuple[str, int]:
from aqt.tagedit import TagEdit from aqt.tagedit import TagEdit
@ -458,7 +460,8 @@ def getTag(
def disable_help_button(widget: QWidget) -> None: def disable_help_button(widget: QWidget) -> None:
"Disable the help button in the window titlebar." "Disable the help button in the window titlebar."
flags = cast(Qt.WindowType, widget.windowFlags() & ~Qt.WindowContextHelpButtonHint) flags_int = int(widget.windowFlags()) & ~Qt.WindowContextHelpButtonHint
flags = Qt.WindowFlags(flags_int) # type: ignore
widget.setWindowFlags(flags) widget.setWindowFlags(flags)
@ -467,7 +470,7 @@ def disable_help_button(widget: QWidget) -> None:
def getFile( def getFile(
parent: QDialog, parent: QWidget,
title: str, title: str,
# single file returned unless multi=True # single file returned unless multi=True
cb: Optional[Callable[[Union[str, Sequence[str]]], None]], cb: Optional[Callable[[Union[str, Sequence[str]]], None]],
@ -547,9 +550,9 @@ def getSaveFile(
return file return file
def saveGeom(widget: QDialog, key: str) -> None: def saveGeom(widget: QWidget, key: str) -> None:
key += "Geom" key += "Geom"
if isMac and widget.windowState() & Qt.WindowFullScreen: if isMac and int(widget.windowState()) & Qt.WindowFullScreen:
geom = None geom = None
else: else:
geom = widget.saveGeometry() geom = widget.saveGeometry()
@ -599,12 +602,12 @@ def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
widget.move(x, y) widget.move(x, y)
def saveState(widget: QFileDialog, key: str) -> None: def saveState(widget: Union[QFileDialog, QMainWindow], key: str) -> None:
key += "State" key += "State"
aqt.mw.pm.profile[key] = widget.saveState() aqt.mw.pm.profile[key] = widget.saveState()
def restoreState(widget: Union[aqt.AnkiQt, QFileDialog], key: str) -> None: def restoreState(widget: Union[QFileDialog, QMainWindow], key: str) -> None:
key += "State" key += "State"
if aqt.mw.pm.profile.get(key): if aqt.mw.pm.profile.get(key):
widget.restoreState(aqt.mw.pm.profile[key]) widget.restoreState(aqt.mw.pm.profile[key])
@ -632,12 +635,12 @@ def restoreHeader(widget: QHeaderView, key: str) -> None:
widget.restoreState(aqt.mw.pm.profile[key]) widget.restoreState(aqt.mw.pm.profile[key])
def save_is_checked(widget: QWidget, key: str) -> None: def save_is_checked(widget: QCheckBox, key: str) -> None:
key += "IsChecked" key += "IsChecked"
aqt.mw.pm.profile[key] = widget.isChecked() aqt.mw.pm.profile[key] = widget.isChecked()
def restore_is_checked(widget: QWidget, key: str) -> None: def restore_is_checked(widget: QCheckBox, key: str) -> None:
key += "IsChecked" key += "IsChecked"
if aqt.mw.pm.profile.get(key) is not None: if aqt.mw.pm.profile.get(key) is not None:
widget.setChecked(aqt.mw.pm.profile[key]) widget.setChecked(aqt.mw.pm.profile[key])
@ -718,8 +721,9 @@ def maybeHideClose(bbox: QDialogButtonBox) -> None:
def addCloseShortcut(widg: QDialog) -> None: def addCloseShortcut(widg: QDialog) -> None:
if not isMac: if not isMac:
return return
widg._closeShortcut = QShortcut(QKeySequence("Ctrl+W"), widg) shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
qconnect(widg._closeShortcut.activated, widg.reject) qconnect(shortcut.activated, widg.reject)
setattr(widg, "_closeShortcut", shortcut)
def downArrow() -> str: def downArrow() -> str:
@ -729,6 +733,20 @@ def downArrow() -> str:
return "" return ""
def top_level_widget(widget: QWidget) -> QWidget:
window = None
while widget := widget.parentWidget():
window = widget
return window
def current_top_level_widget() -> Optional[QWidget]:
if widget := QApplication.focusWidget():
return top_level_widget(widget)
else:
return None
# Tooltips # Tooltips
###################################################################### ######################################################################
@ -739,7 +757,7 @@ _tooltipLabel: Optional[QLabel] = None
def tooltip( def tooltip(
msg: str, msg: str,
period: int = 3000, period: int = 3000,
parent: Optional[aqt.AnkiQt] = None, parent: Optional[QWidget] = None,
x_offset: int = 0, x_offset: int = 0,
y_offset: int = 100, y_offset: int = 100,
) -> None: ) -> None:
@ -974,3 +992,50 @@ def startup_info() -> Any:
si = subprocess.STARTUPINFO() # pytype: disable=module-attr si = subprocess.STARTUPINFO() # pytype: disable=module-attr
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr
return si return si
def ensure_editor_saved(func: Callable) -> Callable:
"""Ensure the current editor's note is saved before running the wrapped function.
Must be used on functions that may be invoked from a shortcut key while the
editor has focus. For functions that can't be activated while the editor has
focus, you don't need this.
Will look for the editor as self.editor.
"""
@wraps(func)
def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs))
return decorated
def ensure_editor_saved_on_trigger(func: Callable) -> Callable:
"""Like ensure_editor_saved(), but tells Qt this function takes no args.
This ensures PyQt doesn't attempt to pass a `toggled` arg
into functions connected to a `triggered` signal.
"""
return pyqtSlot()(ensure_editor_saved(func)) # type: ignore
class KeyboardModifiersPressed:
"Util for type-safe checks of currently-pressed modifier keys."
def __init__(self) -> None:
from aqt import mw
self._modifiers = int(mw.app.keyboardModifiers())
@property
def shift(self) -> bool:
return bool(self._modifiers & Qt.ShiftModifier)
@property
def control(self) -> bool:
return bool(self._modifiers & Qt.ControlModifier)
@property
def alt(self) -> bool:
return bool(self._modifiers & Qt.AltModifier)

View file

@ -1,5 +1,6 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import dataclasses import dataclasses
import json import json
import re import re
@ -31,12 +32,15 @@ class AnkiWebPage(QWebEnginePage):
def _setupBridge(self) -> None: def _setupBridge(self) -> None:
class Bridge(QObject): class Bridge(QObject):
def __init__(self, bridge_handler: Callable[[str], Any]) -> None:
super().__init__()
self.onCmd = bridge_handler
@pyqtSlot(str, result=str) # type: ignore @pyqtSlot(str, result=str) # type: ignore
def cmd(self, str: str) -> Any: def cmd(self, str: str) -> Any:
return json.dumps(self.onCmd(str)) return json.dumps(self.onCmd(str))
self._bridge = Bridge() self._bridge = Bridge(self._onCmd)
self._bridge.onCmd = self._onCmd
self._channel = QWebChannel(self) self._channel = QWebChannel(self)
self._channel.registerObject("py", self._bridge) self._channel.registerObject("py", self._bridge)
@ -46,7 +50,7 @@ class AnkiWebPage(QWebEnginePage):
jsfile = QFile(qwebchannel) jsfile = QFile(qwebchannel)
if not jsfile.open(QIODevice.ReadOnly): if not jsfile.open(QIODevice.ReadOnly):
print(f"Error opening '{qwebchannel}': {jsfile.error()}", file=sys.stderr) print(f"Error opening '{qwebchannel}': {jsfile.error()}", file=sys.stderr)
jstext = bytes(jsfile.readAll()).decode("utf-8") jstext = bytes(cast(bytes, jsfile.readAll())).decode("utf-8")
jsfile.close() jsfile.close()
script = QWebEngineScript() script = QWebEngineScript()
@ -131,7 +135,7 @@ class AnkiWebPage(QWebEnginePage):
openLink(url) openLink(url)
return False return False
def _onCmd(self, str: str) -> None: def _onCmd(self, str: str) -> Any:
return self._onBridgeCmd(str) return self._onBridgeCmd(str)
def javaScriptAlert(self, url: QUrl, text: str) -> None: def javaScriptAlert(self, url: QUrl, text: str) -> None:
@ -252,7 +256,7 @@ class AnkiWebView(QWebEngineView):
# disable pinch to zoom gesture # disable pinch to zoom gesture
if isinstance(evt, QNativeGestureEvent): if isinstance(evt, QNativeGestureEvent):
return True return True
elif evt.type() == QEvent.MouseButtonRelease: elif isinstance(evt, QMouseEvent) and evt.type() == QEvent.MouseButtonRelease:
if evt.button() == Qt.MidButton and isLin: if evt.button() == Qt.MidButton and isLin:
self.onMiddleClickPaste() self.onMiddleClickPaste()
return True return True
@ -273,7 +277,9 @@ class AnkiWebView(QWebEngineView):
w.close() w.close()
else: else:
# in the main window, removes focus from type in area # in the main window, removes focus from type in area
self.parent().setFocus() parent = self.parent()
assert isinstance(parent, QWidget)
parent.setFocus()
break break
w = w.parent() w = w.parent()
@ -315,15 +321,16 @@ class AnkiWebView(QWebEngineView):
self.set_open_links_externally(True) self.set_open_links_externally(True)
def _setHtml(self, html: str) -> None: def _setHtml(self, html: str) -> None:
app = QApplication.instance() from aqt import mw
oldFocus = app.focusWidget()
oldFocus = mw.app.focusWidget()
self._domDone = False self._domDone = False
self._page.setHtml(html) self._page.setHtml(html)
# work around webengine stealing focus on setHtml() # work around webengine stealing focus on setHtml()
if oldFocus: if oldFocus:
oldFocus.setFocus() oldFocus.setFocus()
def load(self, url: QUrl) -> None: def load_url(self, url: QUrl) -> None:
# allow queuing actions when loading url directly # allow queuing actions when loading url directly
self._domDone = False self._domDone = False
super().load(url) super().load(url)
@ -641,5 +648,12 @@ document.head.appendChild(style);
else: else:
extra = "" extra = ""
self.hide_while_preserving_layout() self.hide_while_preserving_layout()
self.load(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}")) self.load_url(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}"))
self.inject_dynamic_style_and_show() self.inject_dynamic_style_and_show()
def force_load_hack(self) -> None:
"""Force process to initialize.
Must be done on Windows prior to changing current working directory."""
self.requiresCol = False
self._domReady = False
self._page.setContent(bytes("", "ascii"))

View file

@ -9,6 +9,19 @@ check_untyped_defs = true
disallow_untyped_defs = True disallow_untyped_defs = True
strict_equality = 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]
no_strict_optional = false
[mypy-aqt.winpaths] [mypy-aqt.winpaths]
disallow_untyped_defs=false disallow_untyped_defs=false
[mypy-aqt.mpv] [mypy-aqt.mpv]

View file

@ -24,7 +24,7 @@ from anki.cards import Card
from anki.decks import Deck, DeckConfig from anki.decks import Deck, DeckConfig
from anki.hooks import runFilter, runHook from anki.hooks import runFilter, runHook
from anki.models import NoteType from anki.models import NoteType
from aqt.qt import QDialog, QEvent, QMenu from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit from aqt.tagedit import TagEdit
""" """
@ -365,8 +365,9 @@ hooks = [
args=["context: aqt.browser.SearchContext"], args=["context: aqt.browser.SearchContext"],
doc="""Allows you to modify the list of returned card ids from a search.""", doc="""Allows you to modify the list of returned card ids from a search.""",
), ),
# States # Main window states
################### ###################
# these refer to things like deckbrowser, overview and reviewer state,
Hook( Hook(
name="state_will_change", name="state_will_change",
args=["new_state: str", "old_state: str"], args=["new_state: str", "old_state: str"],
@ -382,6 +383,8 @@ hooks = [
name="state_shortcuts_will_change", name="state_shortcuts_will_change",
args=["state: str", "shortcuts: List[Tuple[str, Callable]]"], args=["state: str", "shortcuts: List[Tuple[str, Callable]]"],
), ),
# UI state/refreshing
###################
Hook( Hook(
name="state_did_revert", name="state_did_revert",
args=["action: str"], args=["action: str"],
@ -391,7 +394,46 @@ hooks = [
Hook( Hook(
name="state_did_reset", name="state_did_reset",
legacy_hook="reset", legacy_hook="reset",
doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI.
New code should use `operation_did_execute` instead.
""",
),
Hook(
name="operation_did_execute",
args=[
"changes: anki.collection.OpChanges",
],
doc="""Called after an operation completes.
Changes can be inspected to determine whether the UI needs updating.
This will also be called when the legacy mw.reset() is used.
""",
),
Hook(
name="focus_did_change",
args=[
"new: Optional[QWidget]",
"old: Optional[QWidget]",
],
doc="""Called each time the focus changes. Can be used to defer updates from
`operation_did_execute` until a window is brought to the front.""",
),
Hook(
name="backend_will_block",
doc="""Called before one or more operations are executed with mw.perform_op().
Subscribers can use this to set a flag to avoid DB queries until the operation
completes, as doing so will freeze the UI until the long-running operation
completes.
""",
),
Hook(
name="backend_did_block",
doc="""Called after one or more operations are executed with mw.perform_op().
Called regardless of the success of individual operations, and only called when
there are no outstanding ops.
""",
), ),
# Webview # Webview
################### ###################

View file

@ -45,6 +45,11 @@ message StringList {
repeated string vals = 1; repeated string vals = 1;
} }
message OpChangesWithCount {
uint32 count = 1;
OpChanges changes = 2;
}
// IDs used in RPC calls // IDs used in RPC calls
/////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////
@ -108,19 +113,19 @@ service SchedulingService {
rpc ExtendLimits(ExtendLimitsIn) returns (Empty); rpc ExtendLimits(ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday(DeckID) returns (CountsForDeckTodayOut); rpc CountsForDeckToday(DeckID) returns (CountsForDeckTodayOut);
rpc CongratsInfo(Empty) returns (CongratsInfoOut); rpc CongratsInfo(Empty) returns (CongratsInfoOut);
rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (Empty); rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges);
rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty);
rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (Empty); rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges);
rpc EmptyFilteredDeck(DeckID) returns (Empty); rpc EmptyFilteredDeck(DeckID) returns (Empty);
rpc RebuildFilteredDeck(DeckID) returns (UInt32); rpc RebuildFilteredDeck(DeckID) returns (UInt32);
rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (Empty); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
rpc SetDueDate(SetDueDateIn) returns (Empty); rpc SetDueDate(SetDueDateIn) returns (OpChanges);
rpc SortCards(SortCardsIn) returns (Empty); rpc SortCards(SortCardsIn) returns (OpChangesWithCount);
rpc SortDeck(SortDeckIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (OpChangesWithCount);
rpc GetNextCardStates(CardID) returns (NextCardStates); rpc GetNextCardStates(CardID) returns (NextCardStates);
rpc DescribeNextStates(NextCardStates) returns (StringList); rpc DescribeNextStates(NextCardStates) returns (StringList);
rpc StateIsLeech(SchedulingState) returns (Bool); rpc StateIsLeech(SchedulingState) returns (Bool);
rpc AnswerCard(AnswerCardIn) returns (Empty); rpc AnswerCard(AnswerCardIn) returns (OpChanges);
rpc UpgradeScheduler(Empty) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty);
rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut);
} }
@ -134,23 +139,21 @@ service DecksService {
rpc GetDeckLegacy(DeckID) returns (Json); rpc GetDeckLegacy(DeckID) returns (Json);
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames); rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
rpc NewDeckLegacy(Bool) returns (Json); rpc NewDeckLegacy(Bool) returns (Json);
rpc RemoveDecks(DeckIDs) returns (UInt32); rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount);
rpc DragDropDecks(DragDropDecksIn) returns (Empty); rpc DragDropDecks(DragDropDecksIn) returns (OpChanges);
rpc RenameDeck(RenameDeckIn) returns (Empty); rpc RenameDeck(RenameDeckIn) returns (OpChanges);
} }
service NotesService { service NotesService {
rpc NewNote(NoteTypeID) returns (Note); rpc NewNote(NoteTypeID) returns (Note);
rpc AddNote(AddNoteIn) returns (NoteID); rpc AddNote(AddNoteIn) returns (AddNoteOut);
rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype); rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype);
rpc DefaultDeckForNotetype(NoteTypeID) returns (DeckID); rpc DefaultDeckForNotetype(NoteTypeID) returns (DeckID);
rpc UpdateNote(UpdateNoteIn) returns (Empty); rpc UpdateNote(UpdateNoteIn) returns (OpChanges);
rpc GetNote(NoteID) returns (Note); rpc GetNote(NoteID) returns (Note);
rpc RemoveNotes(RemoveNotesIn) returns (Empty); rpc RemoveNotes(RemoveNotesIn) returns (OpChanges);
rpc AddNoteTags(AddNoteTagsIn) returns (UInt32);
rpc UpdateNoteTags(UpdateNoteTagsIn) returns (UInt32);
rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut);
rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (Empty); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges);
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote(NoteID) returns (CardIDs); rpc CardsOfNote(NoteID) returns (CardIDs);
@ -179,7 +182,7 @@ service ConfigService {
rpc GetConfigString(Config.String) returns (String); rpc GetConfigString(Config.String) returns (String);
rpc SetConfigString(SetConfigStringIn) returns (Empty); rpc SetConfigString(SetConfigStringIn) returns (Empty);
rpc GetPreferences(Empty) returns (Preferences); rpc GetPreferences(Empty) returns (Preferences);
rpc SetPreferences(Preferences) returns (Empty); rpc SetPreferences(Preferences) returns (OpChanges);
} }
service NoteTypesService { service NoteTypesService {
@ -212,13 +215,16 @@ service DeckConfigService {
} }
service TagsService { service TagsService {
rpc ClearUnusedTags(Empty) returns (Empty); rpc ClearUnusedTags(Empty) returns (OpChangesWithCount);
rpc AllTags(Empty) returns (StringList); rpc AllTags(Empty) returns (StringList);
rpc ExpungeTags(String) returns (UInt32); rpc RemoveTags(String) returns (OpChangesWithCount);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode); rpc TagTree(Empty) returns (TagTreeNode);
rpc DragDropTags(DragDropTagsIn) returns (Empty); rpc ReparentTags(ReparentTagsIn) returns (OpChangesWithCount);
rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount);
rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount);
} }
service SearchService { service SearchService {
@ -227,7 +233,7 @@ service SearchService {
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount);
} }
service StatsService { service StatsService {
@ -264,9 +270,10 @@ service CollectionService {
service CardsService { service CardsService {
rpc GetCard(CardID) returns (Card); rpc GetCard(CardID) returns (Card);
rpc UpdateCard(UpdateCardIn) returns (Empty); rpc UpdateCard(UpdateCardIn) returns (OpChanges);
rpc RemoveCards(RemoveCardsIn) returns (Empty); rpc RemoveCards(RemoveCardsIn) returns (Empty);
rpc SetDeck(SetDeckIn) returns (Empty); rpc SetDeck(SetDeckIn) returns (OpChanges);
rpc SetFlag(SetFlagIn) returns (OpChanges);
} }
// Protobuf stored in .anki2 files // Protobuf stored in .anki2 files
@ -919,9 +926,14 @@ message TagTreeNode {
bool expanded = 4; bool expanded = 4;
} }
message DragDropTagsIn { message ReparentTagsIn {
repeated string source_tags = 1; repeated string tags = 1;
string target_tag = 2; string new_parent = 2;
}
message RenameTagsIn {
string current_prefix = 1;
string new_prefix = 2;
} }
message SetConfigJsonIn { message SetConfigJsonIn {
@ -970,6 +982,11 @@ message AddNoteIn {
int64 deck_id = 2; int64 deck_id = 2;
} }
message AddNoteOut {
int64 note_id = 1;
OpChanges changes = 2;
}
message UpdateNoteIn { message UpdateNoteIn {
Note note = 1; Note note = 1;
bool skip_undo_entry = 2; bool skip_undo_entry = 2;
@ -1027,16 +1044,17 @@ message AfterNoteUpdatesIn {
bool generate_cards = 3; bool generate_cards = 3;
} }
message AddNoteTagsIn { message NoteIDsAndTagsIn {
repeated int64 nids = 1; repeated int64 note_ids = 1;
string tags = 2; string tags = 2;
} }
message UpdateNoteTagsIn { message FindAndReplaceTagIn {
repeated int64 nids = 1; repeated int64 note_ids = 1;
string tags = 2; string search = 2;
string replacement = 3; string replacement = 3;
bool regex = 4; bool regex = 4;
bool match_case = 5;
} }
message CheckDatabaseOut { message CheckDatabaseOut {
@ -1442,6 +1460,24 @@ 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;
}
Kind kind = 1;
bool card = 2;
bool note = 3;
bool deck = 4;
bool tag = 5;
bool notetype = 6;
bool preference = 7;
}
message UndoStatus { message UndoStatus {
string undo = 1; string undo = 1;
string redo = 2; string redo = 2;
@ -1460,3 +1496,8 @@ message RenameDeckIn {
int64 deck_id = 1; int64 deck_id = 1;
string new_name = 2; string new_name = 2;
} }
message SetFlagIn {
repeated int64 card_ids = 1;
uint32 flag = 2;
}

View file

@ -21,22 +21,17 @@ impl CardsService for Backend {
}) })
} }
fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::Empty> { fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let op = if input.skip_undo_entry {
None
} else {
Some(UndoableOpKind::UpdateCard)
};
let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?; let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?;
col.update_card_with_op(&mut card, op) col.update_card_maybe_undoable(&mut card, !input.skip_undo_entry)
}) })
.map(Into::into) .map(Into::into)
} }
fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result<pb::Empty> { fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
col.remove_cards_and_orphaned_notes( col.remove_cards_and_orphaned_notes(
&input &input
.card_ids .card_ids
@ -49,11 +44,18 @@ impl CardsService for Backend {
}) })
} }
fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::Empty> { fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::OpChanges> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let deck_id = input.deck_id.into(); let deck_id = input.deck_id.into();
self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into)) self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into))
} }
fn set_flag(&self, input: pb::SetFlagIn) -> Result<pb::OpChanges> {
self.with_col(|col| {
col.set_card_flag(&to_card_ids(input.card_ids), input.flag)
.map(Into::into)
})
}
} }
impl TryFrom<pb::Card> for Card { impl TryFrom<pb::Card> for Card {
@ -111,3 +113,7 @@ impl From<Card> for pb::Card {
} }
} }
} }
fn to_card_ids(v: Vec<i64>) -> Vec<CardID> {
v.into_iter().map(CardID).collect()
}

View file

@ -85,20 +85,20 @@ impl CollectionService for Backend {
} }
fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| Ok(col.undo_status())) self.with_col(|col| Ok(col.undo_status().into_protobuf(&col.i18n)))
} }
fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| { self.with_col(|col| {
col.undo()?; col.undo()?;
Ok(col.undo_status()) Ok(col.undo_status().into_protobuf(&col.i18n))
}) })
} }
fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| { self.with_col(|col| {
col.redo()?; col.redo()?;
Ok(col.undo_status()) Ok(col.undo_status().into_protobuf(&col.i18n))
}) })
} }
} }

View file

@ -61,7 +61,7 @@ impl ConfigService for Backend {
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::Empty> { fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
// ensure it's a well-formed object // ensure it's a well-formed object
let val: Value = serde_json::from_slice(&input.value_json)?; let val: Value = serde_json::from_slice(&input.value_json)?;
col.set_config(input.key.as_str(), &val) col.set_config(input.key.as_str(), &val)
@ -71,7 +71,7 @@ impl ConfigService for Backend {
} }
fn remove_config(&self, input: pb::String) -> Result<pb::Empty> { fn remove_config(&self, input: pb::String) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.remove_config(input.val.as_str()))) self.with_col(|col| col.transact_no_undo(|col| col.remove_config(input.val.as_str())))
.map(Into::into) .map(Into::into)
} }
@ -92,7 +92,9 @@ impl ConfigService for Backend {
} }
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::Empty> { fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.set_bool(input.key().into(), input.value))) self.with_col(|col| {
col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value))
})
.map(Into::into) .map(Into::into)
} }
@ -106,7 +108,7 @@ impl ConfigService for Backend {
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::Empty> { fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| col.set_string(input.key().into(), &input.value)) col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value))
}) })
.map(Into::into) .map(Into::into)
} }
@ -115,7 +117,7 @@ impl ConfigService for Backend {
self.with_col(|col| col.get_preferences()) self.with_col(|col| col.get_preferences())
} }
fn set_preferences(&self, input: pb::Preferences) -> Result<pb::Empty> { fn set_preferences(&self, input: pb::Preferences) -> Result<pb::OpChanges> {
self.with_col(|col| col.set_preferences(input)) self.with_col(|col| col.set_preferences(input))
.map(Into::into) .map(Into::into)
} }

View file

@ -75,7 +75,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
args, args,
first_row_only, first_row_only,
} => { } => {
maybe_clear_undo(col, &sql); update_state_after_modification(col, &sql);
if first_row_only { if first_row_only {
db_query_row(&col.storage, &sql, &args)? db_query_row(&col.storage, &sql, &args)?
} else { } else {
@ -87,6 +87,10 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
DBResult::None DBResult::None
} }
DBRequest::Commit => { DBRequest::Commit => {
if col.state.modified_by_dbproxy {
col.storage.set_modified()?;
col.state.modified_by_dbproxy = false;
}
col.storage.commit_trx()?; col.storage.commit_trx()?;
DBResult::None DBResult::None
} }
@ -96,17 +100,17 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
DBResult::None DBResult::None
} }
DBRequest::ExecuteMany { sql, args } => { DBRequest::ExecuteMany { sql, args } => {
maybe_clear_undo(col, &sql); update_state_after_modification(col, &sql);
db_execute_many(&col.storage, &sql, &args)? db_execute_many(&col.storage, &sql, &args)?
} }
}; };
Ok(serde_json::to_vec(&resp)?) Ok(serde_json::to_vec(&resp)?)
} }
fn maybe_clear_undo(col: &mut Collection, sql: &str) { fn update_state_after_modification(col: &mut Collection, sql: &str) {
if !is_dql(sql) { if !is_dql(sql) {
println!("clearing undo+study due to {}", sql); println!("clearing undo+study due to {}", sql);
col.discard_undo_and_study_queues(); col.update_state_after_dbproxy_modification();
} }
} }

View file

@ -17,7 +17,7 @@ impl DeckConfigService for Backend {
let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?; let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?;
let mut conf: DeckConf = conf.into(); let mut conf: DeckConf = conf.into();
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?; col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?;
Ok(pb::DeckConfigId { dcid: conf.id.0 }) Ok(pb::DeckConfigId { dcid: conf.id.0 })
}) })
@ -54,7 +54,7 @@ impl DeckConfigService for Backend {
} }
fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result<pb::Empty> { fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into()))) self.with_col(|col| col.transact_no_undo(|col| col.remove_deck_config(input.into())))
.map(Into::into) .map(Into::into)
} }
} }

View file

@ -15,7 +15,7 @@ impl DecksService for Backend {
let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?;
let mut deck: Deck = schema11.into(); let mut deck: Deck = schema11.into();
if input.preserve_usn_and_mtime { if input.preserve_usn_and_mtime {
col.transact(None, |col| { col.transact_no_undo(|col| {
let usn = col.usn()?; let usn = col.usn()?;
col.add_or_update_single_deck_with_existing_id(&mut deck, usn) col.add_or_update_single_deck_with_existing_id(&mut deck, usn)
})?; })?;
@ -109,12 +109,12 @@ impl DecksService for Backend {
.map(Into::into) .map(Into::into)
} }
fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::UInt32> { fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input))) self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
.map(Into::into) .map(Into::into)
} }
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::Empty> { fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::OpChanges> {
let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect(); let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect();
let target_did = if input.target_deck_id == 0 { let target_did = if input.target_deck_id == 0 {
None None
@ -125,7 +125,7 @@ impl DecksService for Backend {
.map(Into::into) .map(Into::into)
} }
fn rename_deck(&self, input: pb::RenameDeckIn) -> Result<pb::Empty> { fn rename_deck(&self, input: pb::RenameDeckIn) -> Result<pb::OpChanges> {
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name)) self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
.map(Into::into) .map(Into::into)
} }

View file

@ -80,3 +80,12 @@ impl From<Vec<String>> for pb::StringList {
pb::StringList { vals } pb::StringList { vals }
} }
} }
impl From<OpOutput<usize>> for pb::OpChangesWithCount {
fn from(out: OpOutput<usize>) -> Self {
pb::OpChangesWithCount {
count: out.output as u32,
changes: Some(out.changes.into()),
}
}
}

View file

@ -19,7 +19,7 @@ impl MediaService for Backend {
move |progress| handler.update(Progress::MediaCheck(progress as u32), true); move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
self.with_col(|col| { self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
let mut output = checker.check()?; let mut output = checker.check()?;
@ -62,7 +62,7 @@ impl MediaService for Backend {
self.with_col(|col| { self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.empty_trash() checker.empty_trash()
@ -78,7 +78,7 @@ impl MediaService for Backend {
self.with_col(|col| { self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.restore_trash() checker.restore_trash()

View file

@ -15,6 +15,7 @@ mod i18n;
mod media; mod media;
mod notes; mod notes;
mod notetypes; mod notetypes;
mod ops;
mod progress; mod progress;
mod scheduler; mod scheduler;
mod search; mod search;

View file

@ -12,9 +12,6 @@ use crate::{
pub(super) use pb::notes_service::Service as NotesService; pub(super) use pb::notes_service::Service as NotesService;
impl NotesService for Backend { impl NotesService for Backend {
// notes
//-------------------------------------------------------------------
fn new_note(&self, input: pb::NoteTypeId) -> Result<pb::Note> { fn new_note(&self, input: pb::NoteTypeId) -> Result<pb::Note> {
self.with_col(|col| { self.with_col(|col| {
let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?; let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?;
@ -22,11 +19,14 @@ impl NotesService for Backend {
}) })
} }
fn add_note(&self, input: pb::AddNoteIn) -> Result<pb::NoteId> { fn add_note(&self, input: pb::AddNoteIn) -> Result<pb::AddNoteOut> {
self.with_col(|col| { self.with_col(|col| {
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into(); let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
col.add_note(&mut note, DeckID(input.deck_id)) let changes = col.add_note(&mut note, DeckID(input.deck_id))?;
.map(|_| pb::NoteId { nid: note.id.0 }) Ok(pb::AddNoteOut {
note_id: note.id.0,
changes: Some(changes.into()),
})
}) })
} }
@ -46,15 +46,10 @@ impl NotesService for Backend {
}) })
} }
fn update_note(&self, input: pb::UpdateNoteIn) -> Result<pb::Empty> { fn update_note(&self, input: pb::UpdateNoteIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let op = if input.skip_undo_entry {
None
} else {
Some(UndoableOpKind::UpdateNote)
};
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into(); let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
col.update_note_with_op(&mut note, op) col.update_note_maybe_undoable(&mut note, !input.skip_undo_entry)
}) })
.map(Into::into) .map(Into::into)
} }
@ -68,7 +63,7 @@ impl NotesService for Backend {
}) })
} }
fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::Empty> { fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
if !input.note_ids.is_empty() { if !input.note_ids.is_empty() {
col.remove_notes( col.remove_notes(
@ -77,9 +72,8 @@ impl NotesService for Backend {
.into_iter() .into_iter()
.map(Into::into) .map(Into::into)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
)?; )
} } else {
if !input.card_ids.is_empty() {
let nids = col.storage.note_ids_of_cards( let nids = col.storage.note_ids_of_cards(
&input &input
.card_ids .card_ids
@ -87,29 +81,9 @@ impl NotesService for Backend {
.map(Into::into) .map(Into::into)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
)?; )?;
col.remove_notes(&nids.into_iter().collect::<Vec<_>>())? col.remove_notes(&nids.into_iter().collect::<Vec<_>>())
} }
Ok(().into())
})
}
fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::UInt32> {
self.with_col(|col| {
col.add_tags_to_notes(&to_nids(input.nids), &input.tags)
.map(|n| n as u32)
})
.map(Into::into) .map(Into::into)
}
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::UInt32> {
self.with_col(|col| {
col.replace_tags_for_notes(
&to_nids(input.nids),
&input.tags,
&input.replacement,
input.regex,
)
.map(|n| (n as u32).into())
}) })
} }
@ -123,16 +97,14 @@ impl NotesService for Backend {
}) })
} }
fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result<pb::Empty> { fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| {
col.after_note_updates( col.after_note_updates(
&to_nids(input.nids), &to_note_ids(input.nids),
input.generate_cards, input.generate_cards,
input.mark_notes_modified, input.mark_notes_modified,
)?; )
Ok(pb::Empty {}) .map(Into::into)
})
}) })
} }
@ -167,6 +139,6 @@ impl NotesService for Backend {
} }
} }
fn to_nids(ids: Vec<i64>) -> Vec<NoteID> { pub(super) fn to_note_ids(ids: Vec<i64>) -> Vec<NoteID> {
ids.into_iter().map(NoteID).collect() ids.into_iter().map(NoteID).collect()
} }

46
rslib/src/backend/ops.rs Normal file
View file

@ -0,0 +1,46 @@
// 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, prelude::*, undo::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,
}
}
}
impl UndoStatus {
pub(crate) fn into_protobuf(self, i18n: &I18n) -> pb::UndoStatus {
pb::UndoStatus {
undo: self.undo.map(|op| op.describe(i18n)).unwrap_or_default(),
redo: self.redo.map(|op| op.describe(i18n)).unwrap_or_default(),
}
}
}
impl From<OpOutput<()>> for pb::OpChanges {
fn from(o: OpOutput<()>) -> Self {
o.changes.into()
}
}

View file

@ -39,7 +39,7 @@ impl SchedulingService for Backend {
fn update_stats(&self, input: pb::UpdateStatsIn) -> Result<pb::Empty> { fn update_stats(&self, input: pb::UpdateStatsIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
let today = col.current_due_day(0)?; let today = col.current_due_day(0)?;
let usn = col.usn()?; let usn = col.usn()?;
col.update_deck_stats(today, usn, input).map(Into::into) col.update_deck_stats(today, usn, input).map(Into::into)
@ -49,7 +49,7 @@ impl SchedulingService for Backend {
fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result<pb::Empty> { fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
let today = col.current_due_day(0)?; let today = col.current_due_day(0)?;
let usn = col.usn()?; let usn = col.usn()?;
col.extend_limits( col.extend_limits(
@ -72,7 +72,7 @@ impl SchedulingService for Backend {
self.with_col(|col| col.congrats_info()) self.with_col(|col| col.congrats_info())
} }
fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result<pb::Empty> { fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result<pb::OpChanges> {
let cids: Vec<_> = input.into(); let cids: Vec<_> = input.into();
self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into)) self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into))
} }
@ -87,7 +87,7 @@ impl SchedulingService for Backend {
}) })
} }
fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::Empty> { fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let mode = input.mode(); let mode = input.mode();
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
@ -103,7 +103,7 @@ impl SchedulingService for Backend {
self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into))
} }
fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::Empty> { fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let log = input.log; let log = input.log;
@ -111,14 +111,14 @@ impl SchedulingService for Backend {
}) })
} }
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::Empty> { fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::OpChanges> {
let config = input.config_key.map(Into::into); let config = input.config_key.map(Into::into);
let days = input.days; let days = input.days;
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into)) self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into))
} }
fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::Empty> { fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::OpChangesWithCount> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let (start, step, random, shift) = ( let (start, step, random, shift) = (
input.starting_from, input.starting_from,
@ -137,7 +137,7 @@ impl SchedulingService for Backend {
}) })
} }
fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::Empty> { fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| { self.with_col(|col| {
col.sort_deck(input.deck_id.into(), input.randomize) col.sort_deck(input.deck_id.into(), input.randomize)
.map(Into::into) .map(Into::into)
@ -161,13 +161,13 @@ impl SchedulingService for Backend {
Ok(state.leeched().into()) Ok(state.leeched().into())
} }
fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::Empty> { fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::OpChanges> {
self.with_col(|col| col.answer_card(&input.into())) self.with_col(|col| col.answer_card(&input.into()))
.map(Into::into) .map(Into::into)
} }
fn upgrade_scheduler(&self, _input: pb::Empty) -> Result<pb::Empty> { fn upgrade_scheduler(&self, _input: pb::Empty) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler())) self.with_col(|col| col.transact_no_undo(|col| col.upgrade_to_v2_scheduler()))
.map(Into::into) .map(Into::into)
} }

View file

@ -68,7 +68,7 @@ impl SearchService for Backend {
Ok(replace_search_node(existing, replacement).into()) Ok(replace_search_node(existing, replacement).into())
} }
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::UInt32> { fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::OpChangesWithCount> {
let mut search = if input.regex { let mut search = if input.regex {
input.search input.search
} else { } else {
@ -86,7 +86,7 @@ impl SearchService for Backend {
let repl = input.replacement; let repl = input.replacement;
self.with_col(|col| { self.with_col(|col| {
col.find_and_replace(nids, &search, &repl, field_name) col.find_and_replace(nids, &search, &repl, field_name)
.map(|cnt| pb::UInt32 { val: cnt as u32 }) .map(Into::into)
}) })
} }
} }

View file

@ -1,13 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend; use super::{notes::to_note_ids, Backend};
use crate::{backend_proto as pb, prelude::*}; use crate::{backend_proto as pb, prelude::*};
pub(super) use pb::tags_service::Service as TagsService; pub(super) use pb::tags_service::Service as TagsService;
impl TagsService for Backend { impl TagsService for Backend {
fn clear_unused_tags(&self, _input: pb::Empty) -> Result<pb::Empty> { fn clear_unused_tags(&self, _input: pb::Empty) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) self.with_col(|col| col.transact_no_undo(|col| col.clear_unused_tags().map(Into::into)))
} }
fn all_tags(&self, _input: pb::Empty) -> Result<pb::StringList> { fn all_tags(&self, _input: pb::Empty) -> Result<pb::StringList> {
@ -23,40 +23,66 @@ impl TagsService for Backend {
}) })
} }
fn expunge_tags(&self, tags: pb::String) -> Result<pb::UInt32> { fn remove_tags(&self, tags: pb::String) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into)) self.with_col(|col| col.remove_tags(tags.val.as_str()).map(Into::into))
} }
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> { fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact_no_undo(|col| {
col.set_tag_expanded(&input.name, input.expanded)?; col.set_tag_expanded(&input.name, input.expanded)?;
Ok(().into()) Ok(().into())
}) })
}) })
} }
fn clear_tag(&self, tag: pb::String) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
col.storage.clear_tag_and_children(tag.val.as_str())?;
Ok(().into())
})
})
}
fn tag_tree(&self, _input: pb::Empty) -> Result<pb::TagTreeNode> { fn tag_tree(&self, _input: pb::Empty) -> Result<pb::TagTreeNode> {
self.with_col(|col| col.tag_tree()) self.with_col(|col| col.tag_tree())
} }
fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result<pb::Empty> { fn reparent_tags(&self, input: pb::ReparentTagsIn) -> Result<pb::OpChangesWithCount> {
let source_tags = input.source_tags; let source_tags = input.tags;
let target_tag = if input.target_tag.is_empty() { let target_tag = if input.new_parent.is_empty() {
None None
} else { } else {
Some(input.target_tag) Some(input.new_parent)
}; };
self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag)) self.with_col(|col| col.reparent_tags(&source_tags, target_tag))
.map(Into::into) .map(Into::into)
} }
fn rename_tags(&self, input: pb::RenameTagsIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| col.rename_tag(&input.current_prefix, &input.new_prefix))
.map(Into::into)
}
fn add_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| {
col.add_tags_to_notes(&to_note_ids(input.note_ids), &input.tags)
.map(Into::into)
})
}
fn remove_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| {
col.remove_tags_from_notes(&to_note_ids(input.note_ids), &input.tags)
.map(Into::into)
})
}
fn find_and_replace_tag(
&self,
input: pb::FindAndReplaceTagIn,
) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| {
col.find_and_replace_tag(
&to_note_ids(input.note_ids),
&input.search,
&input.replacement,
input.regex,
input.match_case,
)
.map(Into::into)
})
}
} }

View file

@ -6,9 +6,10 @@ pub(crate) mod undo;
use crate::err::{AnkiError, Result}; use crate::err::{AnkiError, Result};
use crate::notes::NoteID; use crate::notes::NoteID;
use crate::{ use crate::{
collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn, collection::Collection, config::SchedulerVersion, prelude::*, timestamp::TimestampSecs,
types::Usn,
}; };
use crate::{define_newtype, undo::UndoableOpKind}; use crate::{define_newtype, ops::StateChanges};
use crate::{deckconf::DeckConf, decks::DeckID}; use crate::{deckconf::DeckConf, decks::DeckID};
use num_enum::TryFromPrimitive; use num_enum::TryFromPrimitive;
@ -110,6 +111,15 @@ impl Card {
self.deck_id = deck; self.deck_id = deck;
} }
fn set_flag(&mut self, flag: u8) {
// we currently only allow 4 flags
assert!(flag < 5);
// but reserve space for 7, preserving the rest of
// the flags (up to a byte)
self.flags = (self.flags & !0b111) | flag
}
/// Return the total number of steps left to do, ignoring the /// Return the total number of steps left to do, ignoring the
/// "steps today" number packed into the DB representation. /// "steps today" number packed into the DB representation.
pub fn remaining_steps(&self) -> u32 { pub fn remaining_steps(&self) -> u32 {
@ -139,13 +149,31 @@ impl Card {
} }
impl Collection { impl Collection {
pub(crate) fn update_card_with_op( pub(crate) fn update_card_maybe_undoable(
&mut self, &mut self,
card: &mut Card, card: &mut Card,
op: Option<UndoableOpKind>, undoable: bool,
) -> Result<()> { ) -> Result<OpOutput<()>> {
let existing = self.storage.get_card(card.id)?.ok_or(AnkiError::NotFound)?; let existing = self.storage.get_card(card.id)?.ok_or(AnkiError::NotFound)?;
self.transact(op, |col| col.update_card_inner(card, existing, col.usn()?)) if undoable {
self.transact(Op::UpdateCard, |col| {
col.update_card_inner(card, existing, col.usn()?)
})
} else {
self.transact_no_undo(|col| {
col.update_card_inner(card, existing, col.usn()?)?;
Ok(OpOutput {
output: (),
changes: OpChanges {
op: Op::UpdateCard,
changes: StateChanges {
card: true,
..Default::default()
},
},
})
})
}
} }
#[cfg(test)] #[cfg(test)]
@ -203,7 +231,7 @@ impl Collection {
Ok(()) Ok(())
} }
pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result<()> { pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result<OpOutput<()>> {
let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?;
if deck.is_filtered() { if deck.is_filtered() {
return Err(AnkiError::DeckIsFiltered); return Err(AnkiError::DeckIsFiltered);
@ -211,7 +239,7 @@ impl Collection {
self.storage.set_search_table_to_card_ids(cards, false)?; self.storage.set_search_table_to_card_ids(cards, false)?;
let sched = self.scheduler_version(); let sched = self.scheduler_version();
let usn = self.usn()?; let usn = self.usn()?;
self.transact(Some(UndoableOpKind::SetDeck), |col| { self.transact(Op::SetDeck, |col| {
for mut card in col.storage.all_searched_cards()? { for mut card in col.storage.all_searched_cards()? {
if card.deck_id == deck_id { if card.deck_id == deck_id {
continue; continue;
@ -224,6 +252,24 @@ impl Collection {
}) })
} }
pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<OpOutput<()>> {
if flag > 4 {
return Err(AnkiError::invalid_input("invalid flag"));
}
let flag = flag as u8;
self.storage.set_search_table_to_card_ids(cards, false)?;
let usn = self.usn()?;
self.transact(Op::SetFlag, |col| {
for mut card in col.storage.all_searched_cards()? {
let original = card.clone();
card.set_flag(flag);
col.update_card_inner(&mut card, original, usn)?;
}
Ok(())
})
}
/// Get deck config for the given card. If missing, return default values. /// Get deck config for the given card. If missing, return default values.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result<DeckConf> { pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result<DeckConf> {

View file

@ -1,7 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::i18n::I18n;
use crate::log::Logger; use crate::log::Logger;
use crate::types::Usn; use crate::types::Usn;
use crate::{ use crate::{
@ -12,6 +11,7 @@ use crate::{
undo::UndoManager, undo::UndoManager,
}; };
use crate::{err::Result, scheduler::queue::CardQueues}; use crate::{err::Result, scheduler::queue::CardQueues};
use crate::{i18n::I18n, ops::StateChanges};
use std::{collections::HashMap, path::PathBuf, sync::Arc}; use std::{collections::HashMap, path::PathBuf, sync::Arc};
pub fn open_collection<P: Into<PathBuf>>( pub fn open_collection<P: Into<PathBuf>>(
@ -65,6 +65,9 @@ pub struct CollectionState {
pub(crate) notetype_cache: HashMap<NoteTypeID, Arc<NoteType>>, pub(crate) notetype_cache: HashMap<NoteTypeID, Arc<NoteType>>,
pub(crate) deck_cache: HashMap<DeckID, Arc<Deck>>, pub(crate) deck_cache: HashMap<DeckID, Arc<Deck>>,
pub(crate) card_queues: Option<CardQueues>, pub(crate) card_queues: Option<CardQueues>,
/// True if legacy Python code has executed SQL that has modified the
/// database, requiring modification time to be bumped.
pub(crate) modified_by_dbproxy: bool,
} }
pub struct Collection { pub struct Collection {
@ -80,9 +83,7 @@ pub struct Collection {
} }
impl Collection { impl Collection {
/// Execute the provided closure in a transaction, rolling back if fn transact_inner<F, R>(&mut self, op: Option<Op>, func: F) -> Result<OpOutput<R>>
/// an error is returned.
pub(crate) fn transact<F, R>(&mut self, op: Option<UndoableOpKind>, func: F) -> Result<R>
where where
F: FnOnce(&mut Collection) -> Result<R>, F: FnOnce(&mut Collection) -> Result<R>,
{ {
@ -92,21 +93,56 @@ impl Collection {
let mut res = func(self); let mut res = func(self);
if res.is_ok() { if res.is_ok() {
if let Err(e) = self.storage.mark_modified() { if let Err(e) = self.storage.set_modified() {
res = Err(e); res = Err(e);
} else if let Err(e) = self.storage.commit_rust_trx() { } else if let Err(e) = self.storage.commit_rust_trx() {
res = Err(e); res = Err(e);
} }
} }
if res.is_err() { match res {
Ok(output) => {
let changes = if op.is_some() {
let changes = self.op_changes()?;
self.maybe_clear_study_queues_after_op(changes);
self.maybe_coalesce_note_undo_entry(changes);
changes
} else {
self.clear_study_queues();
// dummy value, not used by transact_no_undo(). only required
// until we can migrate all the code to undoable ops
OpChanges {
op: Op::SetFlag,
changes: StateChanges::default(),
}
};
self.end_undoable_operation();
Ok(OpOutput { output, changes })
}
Err(err) => {
self.discard_undo_and_study_queues(); self.discard_undo_and_study_queues();
self.storage.rollback_rust_trx()?; self.storage.rollback_rust_trx()?;
} else { Err(err)
self.end_undoable_operation(); }
}
} }
res /// Execute the provided closure in a transaction, rolling back if
/// an error is returned. Records undo state, and returns changes.
pub(crate) fn transact<F, R>(&mut self, op: Op, func: F) -> Result<OpOutput<R>>
where
F: FnOnce(&mut Collection) -> Result<R>,
{
self.transact_inner(Some(op), func)
}
/// Execute the provided closure in a transaction, rolling back if
/// an error is returned.
pub(crate) fn transact_no_undo<F, R>(&mut self, func: F) -> Result<R>
where
F: FnOnce(&mut Collection) -> Result<R>,
{
self.transact_inner(None, func).map(|out| out.output)
} }
pub(crate) fn close(self, downgrade: bool) -> Result<()> { pub(crate) fn close(self, downgrade: bool) -> Result<()> {
@ -120,7 +156,7 @@ impl Collection {
/// Prepare for upload. Caller should not create transaction. /// Prepare for upload. Caller should not create transaction.
pub(crate) fn before_upload(&mut self) -> Result<()> { pub(crate) fn before_upload(&mut self) -> Result<()> {
self.transact(None, |col| { self.transact_no_undo(|col| {
col.storage.clear_all_graves()?; col.storage.clear_all_graves()?;
col.storage.clear_pending_note_usns()?; col.storage.clear_pending_note_usns()?;
col.storage.clear_pending_card_usns()?; col.storage.clear_pending_card_usns()?;

View file

@ -71,7 +71,7 @@ mod test {
fn undo() -> Result<()> { fn undo() -> Result<()> {
let mut col = open_test_collection(); let mut col = open_test_collection();
// the op kind doesn't matter, we just need undo enabled // the op kind doesn't matter, we just need undo enabled
let op = Some(UndoableOpKind::Bury); let op = Op::Bury;
// test key // test key
let key = BoolKey::NormalizeNoteText; let key = BoolKey::NormalizeNoteText;

View file

@ -129,7 +129,7 @@ impl Collection {
debug!(self.log, "optimize"); debug!(self.log, "optimize");
self.storage.optimize()?; self.storage.optimize()?;
self.transact(None, |col| col.check_database_inner(progress_fn)) self.transact_no_undo(|col| col.check_database_inner(progress_fn))
} }
fn check_database_inner<F>(&mut self, mut progress_fn: F) -> Result<CheckDatabaseOutput> fn check_database_inner<F>(&mut self, mut progress_fn: F) -> Result<CheckDatabaseOutput>

View file

@ -10,16 +10,14 @@ pub use crate::backend_proto::{
deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto, deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto,
DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck, DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck,
}; };
use crate::{ use crate::{backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images};
backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images,
undo::UndoableOpKind,
};
use crate::{ use crate::{
collection::Collection, collection::Collection,
deckconf::DeckConfID, deckconf::DeckConfID,
define_newtype, define_newtype,
err::{AnkiError, Result}, err::{AnkiError, Result},
i18n::TR, i18n::TR,
prelude::*,
text::normalize_to_nfc, text::normalize_to_nfc,
timestamp::TimestampSecs, timestamp::TimestampSecs,
types::Usn, types::Usn,
@ -269,7 +267,7 @@ impl Collection {
/// or rename children as required. Prefer add_deck() or update_deck() to /// or rename children as required. Prefer add_deck() or update_deck() to
/// be explicit about your intentions; this function mainly exists so we /// be explicit about your intentions; this function mainly exists so we
/// can integrate with older Python code that behaved this way. /// can integrate with older Python code that behaved this way.
pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<()> { pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {
if deck.id.0 == 0 { if deck.id.0 == 0 {
self.add_deck(deck) self.add_deck(deck)
} else { } else {
@ -278,12 +276,12 @@ impl Collection {
} }
/// Add a new deck. The id must be 0, as it will be automatically assigned. /// Add a new deck. The id must be 0, as it will be automatically assigned.
pub fn add_deck(&mut self, deck: &mut Deck) -> Result<()> { pub fn add_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {
if deck.id.0 != 0 { if deck.id.0 != 0 {
return Err(AnkiError::invalid_input("deck to add must have id 0")); return Err(AnkiError::invalid_input("deck to add must have id 0"));
} }
self.transact(Some(UndoableOpKind::AddDeck), |col| { self.transact(Op::AddDeck, |col| {
let usn = col.usn()?; let usn = col.usn()?;
col.prepare_deck_for_update(deck, usn)?; col.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn); deck.set_modified(usn);
@ -292,15 +290,15 @@ impl Collection {
}) })
} }
pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> { pub fn update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {
self.transact(Some(UndoableOpKind::UpdateDeck), |col| { self.transact(Op::UpdateDeck, |col| {
let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?; let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?;
col.update_deck_inner(deck, existing_deck, col.usn()?) col.update_deck_inner(deck, existing_deck, col.usn()?)
}) })
} }
pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> { pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<OpOutput<()>> {
self.transact(Some(UndoableOpKind::RenameDeck), |col| { self.transact(Op::RenameDeck, |col| {
let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?;
let mut deck = existing_deck.clone(); let mut deck = existing_deck.clone();
deck.name = human_deck_name_to_native(new_human_name); deck.name = human_deck_name_to_native(new_human_name);
@ -466,9 +464,9 @@ impl Collection {
self.storage.get_deck_id(&machine_name) self.storage.get_deck_id(&machine_name)
} }
pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<usize> { pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<OpOutput<usize>> {
self.transact(Op::RemoveDeck, |col| {
let mut card_count = 0; let mut card_count = 0;
self.transact(Some(UndoableOpKind::RemoveDeck), |col| {
let usn = col.usn()?; let usn = col.usn()?;
for did in dids { for did in dids {
if let Some(deck) = col.storage.get_deck(*did)? { if let Some(deck) = col.storage.get_deck(*did)? {
@ -483,9 +481,8 @@ impl Collection {
} }
} }
} }
Ok(())
})?;
Ok(card_count) Ok(card_count)
})
} }
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> { pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
@ -625,9 +622,9 @@ impl Collection {
&mut self, &mut self,
source_decks: &[DeckID], source_decks: &[DeckID],
target: Option<DeckID>, target: Option<DeckID>,
) -> Result<()> { ) -> Result<OpOutput<()>> {
let usn = self.usn()?; let usn = self.usn()?;
self.transact(Some(UndoableOpKind::RenameDeck), |col| { self.transact(Op::RenameDeck, |col| {
let target_deck; let target_deck;
let mut target_name = None; let mut target_name = None;
if let Some(target) = target { if let Some(target) = target {

View file

@ -169,7 +169,7 @@ pub(crate) struct DeckFilterContext<'a> {
impl Collection { impl Collection {
pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<()> { pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<()> {
self.transact(None, |col| col.return_all_cards_in_filtered_deck(did)) self.transact_no_undo(|col| col.return_all_cards_in_filtered_deck(did))
} }
pub(super) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> { pub(super) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> {
let cids = self.storage.all_cards_in_single_deck(did)?; let cids = self.storage.all_cards_in_single_deck(did)?;
@ -206,7 +206,7 @@ impl Collection {
today: self.timing_today()?.days_elapsed, today: self.timing_today()?.days_elapsed,
}; };
self.transact(None, |col| { self.transact_no_undo(|col| {
col.return_all_cards_in_filtered_deck(did)?; col.return_all_cards_in_filtered_deck(did)?;
col.build_filtered_deck(ctx) col.build_filtered_deck(ctx)
}) })

View file

@ -45,8 +45,8 @@ impl Collection {
search_re: &str, search_re: &str,
repl: &str, repl: &str,
field_name: Option<String>, field_name: Option<String>,
) -> Result<usize> { ) -> Result<OpOutput<usize>> {
self.transact(None, |col| { self.transact(Op::FindAndReplace, |col| {
let norm = col.get_bool(BoolKey::NormalizeNoteText); let norm = col.get_bool(BoolKey::NormalizeNoteText);
let search = if norm { let search = if norm {
normalize_to_nfc(search_re) normalize_to_nfc(search_re)
@ -119,8 +119,8 @@ mod test {
col.add_note(&mut note2, DeckID(1))?; col.add_note(&mut note2, DeckID(1))?;
let nids = col.search_notes("")?; let nids = col.search_notes("")?;
let cnt = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?; let out = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?;
assert_eq!(cnt, 2); assert_eq!(out.output, 2);
let note = col.storage.get_note(note.id)?.unwrap(); let note = col.storage.get_note(note.id)?.unwrap();
// but the update should be limited to the specified field when it was available // but the update should be limited to the specified field when it was available
@ -138,10 +138,10 @@ mod test {
"Text".into() "Text".into()
] ]
); );
let cnt = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?; let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?;
// still 2, as the caller is expected to provide only note ids that have // still 2, as the caller is expected to provide only note ids that have
// that field, and if we can't find the field we fall back on all fields // that field, and if we can't find the field we fall back on all fields
assert_eq!(cnt, 2); assert_eq!(out.output, 2);
let note = col.storage.get_note(note.id)?.unwrap(); let note = col.storage.get_note(note.id)?.unwrap();
// but the update should be limited to the specified field when it was available // but the update should be limited to the specified field when it was available

View file

@ -24,6 +24,7 @@ mod markdown;
pub mod media; pub mod media;
pub mod notes; pub mod notes;
pub mod notetype; pub mod notetype;
pub mod ops;
mod preferences; mod preferences;
pub mod prelude; pub mod prelude;
pub mod revlog; pub mod revlog;

View file

@ -572,7 +572,7 @@ pub(crate) mod test {
let progress = |_n| true; let progress = |_n| true;
let (output, report) = col.transact(None, |ctx| { let (output, report) = col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress); let mut checker = MediaChecker::new(ctx, &mgr, progress);
let output = checker.check()?; let output = checker.check()?;
let summary = checker.summarize_output(&mut output.clone()); let summary = checker.summarize_output(&mut output.clone());
@ -642,7 +642,7 @@ Unused: unused.jpg
let progress = |_n| true; let progress = |_n| true;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress); let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash() checker.restore_trash()
})?; })?;
@ -656,7 +656,7 @@ Unused: unused.jpg
// if we repeat the process, restoring should do the same thing if the contents are equal // if we repeat the process, restoring should do the same thing if the contents are equal
fs::write(trash_folder.join("test.jpg"), "test")?; fs::write(trash_folder.join("test.jpg"), "test")?;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress); let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash() checker.restore_trash()
})?; })?;
@ -668,7 +668,7 @@ Unused: unused.jpg
// but rename if required // but rename if required
fs::write(trash_folder.join("test.jpg"), "test2")?; fs::write(trash_folder.join("test.jpg"), "test2")?;
col.transact(None, |ctx| { col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress); let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash() checker.restore_trash()
})?; })?;
@ -692,7 +692,7 @@ Unused: unused.jpg
let progress = |_n| true; let progress = |_n| true;
let mut output = col.transact(None, |ctx| { let mut output = col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress); let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.check() checker.check()
})?; })?;

View file

@ -3,7 +3,6 @@
pub(crate) mod undo; pub(crate) mod undo;
use crate::backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
decks::DeckID, decks::DeckID,
@ -16,9 +15,11 @@ use crate::{
timestamp::TimestampSecs, timestamp::TimestampSecs,
types::Usn, types::Usn,
}; };
use crate::{
backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState, ops::StateChanges,
};
use itertools::Itertools; use itertools::Itertools;
use num_integer::Integer; use num_integer::Integer;
use regex::{Regex, Replacer};
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@ -47,6 +48,23 @@ pub struct Note {
pub(crate) checksum: Option<u32>, pub(crate) checksum: Option<u32>,
} }
/// Information required for updating tags while leaving note content alone.
/// Tags are stored in their DB form, separated by spaces.
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct NoteTags {
pub id: NoteID,
pub mtime: TimestampSecs,
pub usn: Usn,
pub tags: String,
}
impl NoteTags {
pub(crate) fn set_modified(&mut self, usn: Usn) {
self.mtime = TimestampSecs::now();
self.usn = usn;
}
}
impl Note { impl Note {
pub(crate) fn new(notetype: &NoteType) -> Self { pub(crate) fn new(notetype: &NoteType) -> Self {
Note { Note {
@ -191,38 +209,6 @@ impl Note {
.collect() .collect()
} }
pub(crate) fn remove_tags(&mut self, re: &Regex) -> bool {
let old_len = self.tags.len();
self.tags.retain(|tag| !re.is_match(tag));
old_len > self.tags.len()
}
pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false;
for tag in &mut self.tags {
if let Cow::Owned(rep) = re.replace_all(tag, |caps: &regex::Captures| {
if let Some(expanded) = repl.by_ref().no_expansion() {
if expanded.trim().is_empty() {
"".to_string()
} else {
// include "::" if it was matched
format!(
"{}{}",
expanded,
caps.get(caps.len() - 1).map_or("", |m| m.as_str())
)
}
} else {
tag.to_string()
}
}) {
*tag = rep;
changed = true;
}
}
changed
}
/// Pad or merge fields to match note type. /// Pad or merge fields to match note type.
pub(crate) fn fix_field_count(&mut self, nt: &NoteType) { pub(crate) fn fix_field_count(&mut self, nt: &NoteType) {
while self.fields.len() < nt.fields.len() { while self.fields.len() < nt.fields.len() {
@ -305,8 +291,8 @@ impl Collection {
Ok(()) Ok(())
} }
pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> { pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<OpOutput<()>> {
self.transact(Some(UndoableOpKind::AddNote), |col| { self.transact(Op::AddNote, |col| {
let nt = col let nt = col
.get_notetype(note.notetype_id)? .get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?; .ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
@ -334,29 +320,49 @@ impl Collection {
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> { pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<OpOutput<()>> {
self.update_note_with_op(note, Some(UndoableOpKind::UpdateNote)) self.update_note_maybe_undoable(note, true)
} }
pub(crate) fn update_note_with_op( pub(crate) fn update_note_maybe_undoable(
&mut self, &mut self,
note: &mut Note, note: &mut Note,
op: Option<UndoableOpKind>, undoable: bool,
) -> Result<()> { ) -> Result<OpOutput<()>> {
if undoable {
self.transact(Op::UpdateNote, |col| col.update_note_inner(note))
} else {
self.transact_no_undo(|col| {
col.update_note_inner(note)?;
Ok(OpOutput {
output: (),
changes: OpChanges {
op: Op::UpdateNote,
changes: StateChanges {
note: true,
tag: true,
card: true,
..Default::default()
},
},
})
})
}
}
pub(crate) fn update_note_inner(&mut self, note: &mut Note) -> Result<()> {
let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?; let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?;
if !note_differs_from_db(&mut existing_note, note) { if !note_differs_from_db(&mut existing_note, note) {
// nothing to do // nothing to do
return Ok(()); return Ok(());
} }
let nt = self
self.transact(op, |col| {
let nt = col
.get_notetype(note.notetype_id)? .get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?; .ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
let ctx = CardGenContext::new(&nt, col.usn()?); let ctx = CardGenContext::new(&nt, self.usn()?);
let norm = col.get_bool(BoolKey::NormalizeNoteText); let norm = self.get_bool(BoolKey::NormalizeNoteText);
col.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm) self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)?;
}) Ok(())
} }
pub(crate) fn update_note_inner_generating_cards( pub(crate) fn update_note_inner_generating_cards(
@ -392,13 +398,13 @@ impl Collection {
if mark_note_modified { if mark_note_modified {
note.set_modified(usn); note.set_modified(usn);
} }
self.update_note_undoable(note, original, true) self.update_note_undoable(note, original)
} }
/// Remove provided notes, and any cards that use them. /// Remove provided notes, and any cards that use them.
pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result<()> { pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result<OpOutput<()>> {
let usn = self.usn()?; let usn = self.usn()?;
self.transact(Some(UndoableOpKind::RemoveNote), |col| { self.transact(Op::RemoveNote, |col| {
for nid in nids { for nid in nids {
let nid = *nid; let nid = *nid;
if let Some(_existing_note) = col.storage.get_note(nid)? { if let Some(_existing_note) = col.storage.get_note(nid)? {
@ -408,20 +414,20 @@ impl Collection {
col.remove_note_only_undoable(nid, usn)?; col.remove_note_only_undoable(nid, usn)?;
} }
} }
Ok(()) Ok(())
}) })
} }
/// Update cards and field cache after notes modified externally. /// Update cards and field cache after notes modified externally.
/// If gencards is false, skip card generation. /// If gencards is false, skip card generation.
pub(crate) fn after_note_updates( pub fn after_note_updates(
&mut self, &mut self,
nids: &[NoteID], nids: &[NoteID],
generate_cards: bool, generate_cards: bool,
mark_notes_modified: bool, mark_notes_modified: bool,
) -> Result<()> { ) -> Result<OpOutput<()>> {
self.transform_notes(nids, |_note, _nt| { self.transact(Op::UpdateNote, |col| {
col.transform_notes(nids, |_note, _nt| {
Ok(TransformNoteOutput { Ok(TransformNoteOutput {
changed: true, changed: true,
generate_cards, generate_cards,
@ -429,6 +435,7 @@ impl Collection {
}) })
}) })
.map(|_| ()) .map(|_| ())
})
} }
pub(crate) fn transform_notes<F>( pub(crate) fn transform_notes<F>(

View file

@ -3,6 +3,8 @@
use crate::{prelude::*, undo::UndoableChange}; use crate::{prelude::*, undo::UndoableChange};
use super::NoteTags;
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum UndoableNoteChange { pub(crate) enum UndoableNoteChange {
Added(Box<Note>), Added(Box<Note>),
@ -10,6 +12,7 @@ pub(crate) enum UndoableNoteChange {
Removed(Box<Note>), Removed(Box<Note>),
GraveAdded(Box<(NoteID, Usn)>), GraveAdded(Box<(NoteID, Usn)>),
GraveRemoved(Box<(NoteID, Usn)>), GraveRemoved(Box<(NoteID, Usn)>),
TagsUpdated(Box<NoteTags>),
} }
impl Collection { impl Collection {
@ -21,27 +24,25 @@ impl Collection {
.storage .storage
.get_note(note.id)? .get_note(note.id)?
.ok_or_else(|| AnkiError::invalid_input("note disappeared"))?; .ok_or_else(|| AnkiError::invalid_input("note disappeared"))?;
self.update_note_undoable(&note, &current, false) self.update_note_undoable(&note, &current)
} }
UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note), UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note),
UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1), UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1),
UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1), UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1),
UndoableNoteChange::TagsUpdated(note_tags) => {
let current = self
.storage
.get_note_tags_by_id(note_tags.id)?
.ok_or_else(|| AnkiError::invalid_input("note disappeared"))?;
self.update_note_tags_undoable(&note_tags, current)
}
} }
} }
/// Saves in the undo queue, and commits to DB. /// Saves in the undo queue, and commits to DB.
/// No validation, card generation or normalization is done. /// No validation, card generation or normalization is done.
/// If `coalesce_updates` is true, successive updates within a 1 minute pub(super) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> {
/// period will not result in further undo entries.
pub(super) fn update_note_undoable(
&mut self,
note: &Note,
original: &Note,
coalesce_updates: bool,
) -> Result<()> {
if !coalesce_updates || !self.note_was_just_updated(note) {
self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone()))); self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));
}
self.storage.update_note(note)?; self.storage.update_note(note)?;
Ok(()) Ok(())
@ -57,6 +58,31 @@ impl Collection {
Ok(()) Ok(())
} }
/// If note is edited multiple times in quick succession, avoid creating extra undo entries.
pub(crate) fn maybe_coalesce_note_undo_entry(&mut self, changes: OpChanges) {
if changes.op != Op::UpdateNote {
return;
}
if let Some(previous_op) = self.previous_undo_op() {
if previous_op.kind != Op::UpdateNote {
return;
}
if let (
Some(UndoableChange::Note(UndoableNoteChange::Updated(previous))),
Some(UndoableChange::Note(UndoableNoteChange::Updated(current))),
) = (
previous_op.changes.last(),
self.current_undo_op().and_then(|op| op.changes.last()),
) {
if previous.id == current.id && previous_op.timestamp.elapsed_secs() < 60 {
self.pop_last_change();
}
}
}
}
/// Add a note, not adding any cards. /// Add a note, not adding any cards.
pub(super) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> { pub(super) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> {
self.storage.add_note(note)?; self.storage.add_note(note)?;
@ -65,6 +91,15 @@ impl Collection {
Ok(()) Ok(())
} }
pub(crate) fn update_note_tags_undoable(
&mut self,
tags: &NoteTags,
original: NoteTags,
) -> Result<()> {
self.save_undo(UndoableNoteChange::TagsUpdated(Box::new(original)));
self.storage.update_note_tags(tags)
}
fn remove_note_without_grave(&mut self, note: Note) -> Result<()> { fn remove_note_without_grave(&mut self, note: Note) -> Result<()> {
self.storage.remove_note(note.id)?; self.storage.remove_note(note.id)?;
self.save_undo(UndoableNoteChange::Removed(Box::new(note))); self.save_undo(UndoableNoteChange::Removed(Box::new(note)));
@ -86,22 +121,4 @@ impl Collection {
self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn)))); self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn))));
self.storage.remove_note_grave(nid) self.storage.remove_note_grave(nid)
} }
/// True only if the last operation was UpdateNote, and the same note was just updated less than
/// a minute ago.
fn note_was_just_updated(&self, before_change: &Note) -> bool {
self.previous_undo_op()
.map(|op| {
if let Some(UndoableChange::Note(UndoableNoteChange::Updated(note))) =
op.changes.last()
{
note.id == before_change.id
&& op.kind == UndoableOpKind::UpdateNote
&& op.timestamp.elapsed_secs() < 60
} else {
false
}
})
.unwrap_or(false)
}
} }

View file

@ -376,7 +376,7 @@ impl From<NoteType> for NoteTypeProto {
impl Collection { impl Collection {
/// Add a new notetype, and allocate it an ID. /// Add a new notetype, and allocate it an ID.
pub fn add_notetype(&mut self, nt: &mut NoteType) -> Result<()> { pub fn add_notetype(&mut self, nt: &mut NoteType) -> Result<()> {
self.transact(None, |col| { self.transact_no_undo(|col| {
let usn = col.usn()?; let usn = col.usn()?;
nt.set_modified(usn); nt.set_modified(usn);
col.add_notetype_inner(nt, usn) col.add_notetype_inner(nt, usn)
@ -415,7 +415,7 @@ impl Collection {
let existing = self.get_notetype(nt.id)?; let existing = self.get_notetype(nt.id)?;
let norm = self.get_bool(BoolKey::NormalizeNoteText); let norm = self.get_bool(BoolKey::NormalizeNoteText);
nt.prepare_for_update(existing.as_ref().map(AsRef::as_ref))?; nt.prepare_for_update(existing.as_ref().map(AsRef::as_ref))?;
self.transact(None, |col| { self.transact_no_undo(|col| {
if let Some(existing_notetype) = existing { if let Some(existing_notetype) = existing {
if existing_notetype.mtime_secs > nt.mtime_secs { if existing_notetype.mtime_secs > nt.mtime_secs {
return Err(AnkiError::invalid_input("attempt to save stale notetype")); return Err(AnkiError::invalid_input("attempt to save stale notetype"));
@ -484,7 +484,7 @@ impl Collection {
pub fn remove_notetype(&mut self, ntid: NoteTypeID) -> Result<()> { pub fn remove_notetype(&mut self, ntid: NoteTypeID) -> Result<()> {
// fixme: currently the storage layer is taking care of removing the notes and cards, // fixme: currently the storage layer is taking care of removing the notes and cards,
// but we need to do it in this layer in the future for undo handling // but we need to do it in this layer in the future for undo handling
self.transact(None, |col| { self.transact_no_undo(|col| {
col.storage.set_schema_modified()?; col.storage.set_schema_modified()?;
col.state.notetype_cache.remove(&ntid); col.state.notetype_cache.remove(&ntid);
col.clear_aux_config_for_notetype(ntid)?; col.clear_aux_config_for_notetype(ntid)?;

86
rslib/src/ops.rs Normal file
View file

@ -0,0 +1,86 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Op {
AddDeck,
AddNote,
AnswerCard,
Bury,
ClearUnusedTags,
FindAndReplace,
RemoveDeck,
RemoveNote,
RemoveTag,
RenameDeck,
RenameTag,
ReparentTag,
ScheduleAsNew,
SetDeck,
SetDueDate,
SetFlag,
SortCards,
Suspend,
UnburyUnsuspend,
UpdateCard,
UpdateDeck,
UpdateNote,
UpdatePreferences,
UpdateTag,
}
impl Op {
pub fn describe(self, i18n: &I18n) -> String {
let key = match self {
Op::AddDeck => TR::UndoAddDeck,
Op::AddNote => TR::UndoAddNote,
Op::AnswerCard => TR::UndoAnswerCard,
Op::Bury => TR::StudyingBury,
Op::RemoveDeck => TR::DecksDeleteDeck,
Op::RemoveNote => TR::StudyingDeleteNote,
Op::RenameDeck => TR::ActionsRenameDeck,
Op::ScheduleAsNew => TR::UndoForgetCard,
Op::SetDueDate => TR::ActionsSetDueDate,
Op::Suspend => TR::StudyingSuspend,
Op::UnburyUnsuspend => TR::UndoUnburyUnsuspend,
Op::UpdateCard => TR::UndoUpdateCard,
Op::UpdateDeck => TR::UndoUpdateDeck,
Op::UpdateNote => TR::UndoUpdateNote,
Op::UpdatePreferences => TR::PreferencesPreferences,
Op::UpdateTag => TR::UndoUpdateTag,
Op::SetDeck => TR::BrowsingChangeDeck,
Op::SetFlag => TR::UndoSetFlag,
Op::FindAndReplace => TR::BrowsingFindAndReplace,
Op::ClearUnusedTags => TR::BrowsingClearUnusedTags,
Op::SortCards => TR::BrowsingReschedule,
Op::RenameTag => TR::ActionsRenameTag,
Op::RemoveTag => TR::ActionsRemoveTag,
Op::ReparentTag => TR::UndoReparent,
};
i18n.tr(key).to_string()
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct StateChanges {
pub card: bool,
pub note: bool,
pub deck: bool,
pub tag: bool,
pub notetype: bool,
pub preference: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct OpChanges {
pub op: Op,
pub changes: StateChanges,
}
pub struct OpOutput<T> {
pub output: T,
pub changes: OpChanges,
}

View file

@ -10,6 +10,7 @@ use crate::{
collection::Collection, collection::Collection,
config::BoolKey, config::BoolKey,
err::Result, err::Result,
prelude::*,
scheduler::timing::local_minutes_west_for_stamp, scheduler::timing::local_minutes_west_for_stamp,
}; };
@ -22,17 +23,13 @@ impl Collection {
}) })
} }
pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> { pub fn set_preferences(&mut self, prefs: Preferences) -> Result<OpOutput<()>> {
self.transact( self.transact(Op::UpdatePreferences, |col| {
Some(crate::undo::UndoableOpKind::UpdatePreferences), col.set_preferences_inner(prefs)
|col| col.set_preferences_inner(prefs), })
)
} }
fn set_preferences_inner( fn set_preferences_inner(&mut self, prefs: Preferences) -> Result<()> {
&mut self,
prefs: Preferences,
) -> Result<(), crate::prelude::AnkiError> {
if let Some(sched) = prefs.scheduling { if let Some(sched) = prefs.scheduling {
self.set_scheduling_preferences(sched)?; self.set_scheduling_preferences(sched)?;
} }

View file

@ -11,9 +11,9 @@ pub use crate::{
i18n::{tr_args, tr_strs, I18n, TR}, i18n::{tr_args, tr_strs, I18n, TR},
notes::{Note, NoteID}, notes::{Note, NoteID},
notetype::{NoteType, NoteTypeID}, notetype::{NoteType, NoteTypeID},
ops::{Op, OpChanges, OpOutput},
revlog::RevlogID, revlog::RevlogID,
timestamp::{TimestampMillis, TimestampSecs}, timestamp::{TimestampMillis, TimestampSecs},
types::Usn, types::Usn,
undo::UndoableOpKind,
}; };
pub use slog::{debug, Logger}; pub use slog::{debug, Logger};

View file

@ -240,10 +240,8 @@ impl Collection {
} }
/// Answer card, writing its new state to the database. /// Answer card, writing its new state to the database.
pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<()> { pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<OpOutput<()>> {
self.transact(Some(UndoableOpKind::AnswerCard), |col| { self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
col.answer_card_inner(answer)
})
} }
fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> { fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> {
@ -274,9 +272,7 @@ impl Collection {
self.add_leech_tag(card.note_id)?; self.add_leech_tag(card.note_id)?;
} }
self.update_queues_after_answering_card(&card, timing)?; self.update_queues_after_answering_card(&card, timing)
Ok(())
} }
fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConf) -> Result<()> { fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConf) -> Result<()> {

View file

@ -68,8 +68,8 @@ impl Collection {
self.storage.clear_searched_cards_table() self.storage.clear_searched_cards_table()
} }
pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<()> { pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<OpOutput<()>> {
self.transact(Some(UndoableOpKind::UnburyUnsuspend), |col| { self.transact(Op::UnburyUnsuspend, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?; col.storage.set_search_table_to_card_ids(cids, false)?;
col.unsuspend_or_unbury_searched_cards() col.unsuspend_or_unbury_searched_cards()
}) })
@ -81,7 +81,7 @@ impl Collection {
UnburyDeckMode::UserOnly => "is:buried-manually", UnburyDeckMode::UserOnly => "is:buried-manually",
UnburyDeckMode::SchedOnly => "is:buried-sibling", UnburyDeckMode::SchedOnly => "is:buried-sibling",
}; };
self.transact(None, |col| { self.transact_no_undo(|col| {
col.search_cards_into_table(&format!("deck:current {}", search), SortMode::NoOrder)?; col.search_cards_into_table(&format!("deck:current {}", search), SortMode::NoOrder)?;
col.unsuspend_or_unbury_searched_cards() col.unsuspend_or_unbury_searched_cards()
}) })
@ -124,12 +124,12 @@ impl Collection {
&mut self, &mut self,
cids: &[CardID], cids: &[CardID],
mode: BuryOrSuspendMode, mode: BuryOrSuspendMode,
) -> Result<()> { ) -> Result<OpOutput<()>> {
let op = match mode { let op = match mode {
BuryOrSuspendMode::Suspend => UndoableOpKind::Suspend, BuryOrSuspendMode::Suspend => Op::Suspend,
BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => UndoableOpKind::Bury, BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury,
}; };
self.transact(Some(op), |col| { self.transact(op, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?; col.storage.set_search_table_to_card_ids(cids, false)?;
col.bury_or_suspend_searched_cards(mode) col.bury_or_suspend_searched_cards(mode)
}) })

View file

@ -7,9 +7,9 @@ use crate::{
decks::DeckID, decks::DeckID,
err::Result, err::Result,
notes::NoteID, notes::NoteID,
prelude::*,
search::SortMode, search::SortMode,
types::Usn, types::Usn,
undo::UndoableOpKind,
}; };
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -24,12 +24,14 @@ impl Card {
self.ease_factor = 0; self.ease_factor = 0;
} }
/// If the card is new, change its position. /// If the card is new, change its position, and return true.
fn set_new_position(&mut self, position: u32) { fn set_new_position(&mut self, position: u32) -> bool {
if self.queue != CardQueue::New || self.ctype != CardType::New { if self.queue != CardQueue::New || self.ctype != CardType::New {
return; false
} } else {
self.due = position as i32; self.due = position as i32;
true
}
} }
} }
pub(crate) struct NewCardSorter { pub(crate) struct NewCardSorter {
@ -103,10 +105,10 @@ fn nids_in_preserved_order(cards: &[Card]) -> Vec<NoteID> {
} }
impl Collection { impl Collection {
pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result<()> { pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result<OpOutput<()>> {
let usn = self.usn()?; let usn = self.usn()?;
let mut position = self.get_next_card_position(); let mut position = self.get_next_card_position();
self.transact(Some(UndoableOpKind::ScheduleAsNew), |col| { self.transact(Op::ScheduleAsNew, |col| {
col.storage.set_search_table_to_card_ids(cids, true)?; col.storage.set_search_table_to_card_ids(cids, true)?;
let cards = col.storage.all_searched_cards_in_search_order()?; let cards = col.storage.all_searched_cards_in_search_order()?;
for mut card in cards { for mut card in cards {
@ -119,8 +121,7 @@ impl Collection {
position += 1; position += 1;
} }
col.set_next_card_position(position)?; col.set_next_card_position(position)?;
col.storage.clear_searched_cards_table()?; col.storage.clear_searched_cards_table()
Ok(())
}) })
} }
@ -131,9 +132,9 @@ impl Collection {
step: u32, step: u32,
order: NewCardSortOrder, order: NewCardSortOrder,
shift: bool, shift: bool,
) -> Result<()> { ) -> Result<OpOutput<usize>> {
let usn = self.usn()?; let usn = self.usn()?;
self.transact(None, |col| { self.transact(Op::SortCards, |col| {
col.sort_cards_inner(cids, starting_from, step, order, shift, usn) col.sort_cards_inner(cids, starting_from, step, order, shift, usn)
}) })
} }
@ -146,24 +147,28 @@ impl Collection {
order: NewCardSortOrder, order: NewCardSortOrder,
shift: bool, shift: bool,
usn: Usn, usn: Usn,
) -> Result<()> { ) -> Result<usize> {
if shift { if shift {
self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?; self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?;
} }
self.storage.set_search_table_to_card_ids(cids, true)?; self.storage.set_search_table_to_card_ids(cids, true)?;
let cards = self.storage.all_searched_cards_in_search_order()?; let cards = self.storage.all_searched_cards_in_search_order()?;
let sorter = NewCardSorter::new(&cards, starting_from, step, order); let sorter = NewCardSorter::new(&cards, starting_from, step, order);
let mut count = 0;
for mut card in cards { for mut card in cards {
let original = card.clone(); let original = card.clone();
card.set_new_position(sorter.position(&card)); if card.set_new_position(sorter.position(&card)) {
count += 1;
self.update_card_inner(&mut card, original, usn)?; self.update_card_inner(&mut card, original, usn)?;
} }
self.storage.clear_searched_cards_table() }
self.storage.clear_searched_cards_table()?;
Ok(count)
} }
/// This creates a transaction - we probably want to split it out /// This creates a transaction - we probably want to split it out
/// in the future if calling it as part of a deck options update. /// in the future if calling it as part of a deck options update.
pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<()> { pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<OpOutput<usize>> {
let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?; let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?;
let order = if random { let order = if random {
NewCardSortOrder::Random NewCardSortOrder::Random

View file

@ -139,6 +139,13 @@ impl Collection {
self.state.card_queues = None; self.state.card_queues = None;
} }
pub(crate) fn maybe_clear_study_queues_after_op(&mut self, op: OpChanges) {
if op.op != Op::AnswerCard && (op.changes.card || op.changes.deck || op.changes.preference)
{
self.state.card_queues = None;
}
}
pub(crate) fn update_queues_after_answering_card( pub(crate) fn update_queues_after_answering_card(
&mut self, &mut self,
card: &Card, card: &Card,

View file

@ -7,8 +7,7 @@ use crate::{
config::StringKey, config::StringKey,
deckconf::INITIAL_EASE_FACTOR_THOUSANDS, deckconf::INITIAL_EASE_FACTOR_THOUSANDS,
err::Result, err::Result,
prelude::AnkiError, prelude::*,
undo::UndoableOpKind,
}; };
use lazy_static::lazy_static; use lazy_static::lazy_static;
use rand::distributions::{Distribution, Uniform}; use rand::distributions::{Distribution, Uniform};
@ -94,13 +93,13 @@ impl Collection {
cids: &[CardID], cids: &[CardID],
days: &str, days: &str,
context: Option<StringKey>, context: Option<StringKey>,
) -> Result<()> { ) -> Result<OpOutput<()>> {
let spec = parse_due_date_str(days)?; let spec = parse_due_date_str(days)?;
let usn = self.usn()?; let usn = self.usn()?;
let today = self.timing_today()?.days_elapsed; let today = self.timing_today()?.days_elapsed;
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let distribution = Uniform::from(spec.min..=spec.max); let distribution = Uniform::from(spec.min..=spec.max);
self.transact(Some(UndoableOpKind::SetDueDate), |col| { self.transact(Op::SetDueDate, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?; col.storage.set_search_table_to_card_ids(cids, false)?;
for mut card in col.storage.all_searched_cards()? { for mut card in col.storage.all_searched_cards()? {
let original = card.clone(); let original = card.clone();

View file

@ -445,7 +445,7 @@ impl super::SqliteStorage {
/// Injects the provided card IDs into the search_cids table, for /// Injects the provided card IDs into the search_cids table, for
/// when ids have arrived outside of a search. /// when ids have arrived outside of a search.
/// Clear with clear_searched_cards(). /// Clear with clear_searched_cards_table().
pub(crate) fn set_search_table_to_card_ids( pub(crate) fn set_search_table_to_card_ids(
&mut self, &mut self,
cards: &[CardID], cards: &[CardID],

View file

@ -0,0 +1,5 @@
SELECT id,
mod,
usn,
tags
FROM notes

View file

@ -5,7 +5,7 @@ use std::collections::HashSet;
use crate::{ use crate::{
err::Result, err::Result,
notes::{Note, NoteID}, notes::{Note, NoteID, NoteTags},
notetype::NoteTypeID, notetype::NoteTypeID,
tags::{join_tags, split_tags}, tags::{join_tags, split_tags},
timestamp::TimestampMillis, timestamp::TimestampMillis,
@ -20,22 +20,6 @@ pub(crate) fn join_fields(fields: &[String]) -> String {
fields.join("\x1f") fields.join("\x1f")
} }
fn row_to_note(row: &Row) -> Result<Note> {
Ok(Note::new_from_storage(
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
split_tags(row.get_raw(5).as_str()?)
.map(Into::into)
.collect(),
split_fields(row.get_raw(6).as_str()?),
Some(row.get(7)?),
Some(row.get(8).unwrap_or_default()),
))
}
impl super::SqliteStorage { impl super::SqliteStorage {
pub fn get_note(&self, nid: NoteID) -> Result<Option<Note>> { pub fn get_note(&self, nid: NoteID) -> Result<Option<Note>> {
self.db self.db
@ -175,18 +159,103 @@ impl super::SqliteStorage {
Ok(seen) Ok(seen)
} }
pub(crate) fn for_each_note_tags<F>(&self, mut func: F) -> Result<()> pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteID) -> Result<Option<NoteTags>> {
self.db
.prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))?
.query_and_then(&[note_id], row_to_note_tags)?
.next()
.transpose()
}
pub(crate) fn get_note_tags_by_id_list(
&mut self,
note_ids: &[NoteID],
) -> Result<Vec<NoteTags>> {
self.set_search_table_to_note_ids(note_ids)?;
let out = self
.db
.prepare_cached(&format!(
"{} where id in (select nid from search_nids)",
include_str!("get_tags.sql")
))?
.query_and_then(NO_PARAMS, row_to_note_tags)?
.collect::<Result<Vec<_>>>()?;
self.clear_searched_notes_table()?;
Ok(out)
}
pub(crate) fn get_note_tags_by_predicate<F>(&mut self, want: F) -> Result<Vec<NoteTags>>
where where
F: FnMut(NoteID, String) -> Result<()>, F: Fn(&str) -> bool,
{ {
let mut stmt = self.db.prepare_cached("select id, tags from notes")?; let mut query_stmt = self.db.prepare_cached(include_str!("get_tags.sql"))?;
let mut rows = stmt.query(NO_PARAMS)?; let mut rows = query_stmt.query(NO_PARAMS)?;
let mut output = vec![];
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
let id: NoteID = row.get(0)?; let tags = row.get_raw(3).as_str()?;
let tags: String = row.get(1)?; if want(tags) {
func(id, tags)? output.push(row_to_note_tags(row)?)
}
}
Ok(output)
}
pub(crate) fn update_note_tags(&mut self, note: &NoteTags) -> Result<()> {
self.db
.prepare_cached(include_str!("update_tags.sql"))?
.execute(params![note.mtime, note.usn, note.tags, note.id])?;
Ok(())
}
fn setup_searched_notes_table(&self) -> Result<()> {
self.db
.execute_batch(include_str!("search_nids_setup.sql"))?;
Ok(())
}
fn clear_searched_notes_table(&self) -> Result<()> {
self.db
.execute("drop table if exists search_nids", NO_PARAMS)?;
Ok(())
}
/// Injects the provided card IDs into the search_nids table, for
/// when ids have arrived outside of a search.
/// Clear with clear_searched_notes_table().
fn set_search_table_to_note_ids(&mut self, notes: &[NoteID]) -> Result<()> {
self.setup_searched_notes_table()?;
let mut stmt = self
.db
.prepare_cached("insert into search_nids values (?)")?;
for nid in notes {
stmt.execute(&[nid])?;
} }
Ok(()) Ok(())
} }
} }
fn row_to_note(row: &Row) -> Result<Note> {
Ok(Note::new_from_storage(
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
split_tags(row.get_raw(5).as_str()?)
.map(Into::into)
.collect(),
split_fields(row.get_raw(6).as_str()?),
Some(row.get(7)?),
Some(row.get(8).unwrap_or_default()),
))
}
fn row_to_note_tags(row: &Row) -> Result<NoteTags> {
Ok(NoteTags {
id: row.get(0)?,
mtime: row.get(1)?,
usn: row.get(2)?,
tags: row.get(3)?,
})
}

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS search_nids;
CREATE TEMPORARY TABLE search_nids (nid integer PRIMARY KEY NOT NULL);

View file

@ -0,0 +1,5 @@
UPDATE notes
SET mod = ?,
usn = ?,
tags = ?
WHERE id = ?

View file

@ -257,7 +257,7 @@ impl SqliteStorage {
////////////////////////////////////////// //////////////////////////////////////////
pub(crate) fn mark_modified(&self) -> Result<()> { pub(crate) fn set_modified(&self) -> Result<()> {
self.set_modified_time(TimestampMillis::now()) self.set_modified_time(TimestampMillis::now())
} }

View file

@ -0,0 +1,4 @@
SELECT tag,
usn,
collapsed
FROM tags

View file

@ -19,7 +19,7 @@ impl SqliteStorage {
/// All tags in the collection, in alphabetical order. /// All tags in the collection, in alphabetical order.
pub(crate) fn all_tags(&self) -> Result<Vec<Tag>> { pub(crate) fn all_tags(&self) -> Result<Vec<Tag>> {
self.db self.db
.prepare_cached("select tag, usn, collapsed from tags")? .prepare_cached(include_str!("get.sql"))?
.query_and_then(NO_PARAMS, row_to_tag)? .query_and_then(NO_PARAMS, row_to_tag)?
.collect() .collect()
} }
@ -43,7 +43,7 @@ impl SqliteStorage {
pub(crate) fn get_tag(&self, name: &str) -> Result<Option<Tag>> { pub(crate) fn get_tag(&self, name: &str) -> Result<Option<Tag>> {
self.db self.db
.prepare_cached("select tag, usn, collapsed from tags where tag = ?")? .prepare_cached(&format!("{} where tag = ?", include_str!("get.sql")))?
.query_and_then(&[name], row_to_tag)? .query_and_then(&[name], row_to_tag)?
.next() .next()
.transpose() .transpose()
@ -65,13 +65,24 @@ impl SqliteStorage {
.map_err(Into::into) .map_err(Into::into)
} }
// for undo in the future pub(crate) fn get_tags_by_predicate<F>(&self, want: F) -> Result<Vec<Tag>>
#[allow(dead_code)] where
pub(crate) fn get_tag_and_children(&self, name: &str) -> Result<Vec<Tag>> { F: Fn(&str) -> bool,
self.db {
.prepare_cached("select tag, usn, collapsed from tags where tag regexp ?")? let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
.query_and_then(&[format!("(?i)^{}($|::)", regex::escape(name))], row_to_tag)? let mut rows = query_stmt.query(NO_PARAMS)?;
.collect() let mut output = vec![];
while let Some(row) = rows.next()? {
let tag = row.get_raw(0).as_str()?;
if want(tag) {
output.push(Tag {
name: tag.to_owned(),
usn: row.get(1)?,
expanded: !row.get(2)?,
})
}
}
Ok(output)
} }
pub(crate) fn remove_single_tag(&self, tag: &str) -> Result<()> { pub(crate) fn remove_single_tag(&self, tag: &str) -> Result<()> {
@ -82,23 +93,6 @@ impl SqliteStorage {
Ok(()) Ok(())
} }
pub(crate) fn clear_tag_and_children(&self, tag: &str) -> Result<()> {
self.db
.prepare_cached("delete from tags where tag regexp ?")?
.execute(&[format!("(?i)^{}($|::)", regex::escape(tag))])?;
Ok(())
}
/// Clear all matching tags where tag_group is a regexp group that should not match whitespace.
pub(crate) fn clear_tag_group(&self, tag_group: &str) -> Result<()> {
self.db
.prepare_cached("delete from tags where tag regexp ?")?
.execute(&[format!("(?i)^{}($|::)", tag_group)])?;
Ok(())
}
pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> { pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> {
self.db self.db
.prepare_cached("update tags set collapsed = ? where tag = ?")? .prepare_cached("update tags set collapsed = ? where tag = ?")?

View file

@ -210,7 +210,8 @@ impl SyncServer for LocalServer {
_col_folder: Option<&Path>, _col_folder: Option<&Path>,
) -> Result<NamedTempFile> { ) -> Result<NamedTempFile> {
// bump usn/mod & close // bump usn/mod & close
self.col.transact(None, |col| col.storage.increment_usn())?; self.col
.transact_no_undo(|col| col.storage.increment_usn())?;
let col_path = self.col.col_path.clone(); let col_path = self.col.col_path.clone();
self.col.close(true)?; self.col.close(true)?;

88
rslib/src/tags/bulkadd.rs Normal file
View file

@ -0,0 +1,88 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
//! Adding tags to selected notes in the browse screen.
use std::collections::HashSet;
use unicase::UniCase;
use super::{join_tags, split_tags};
use crate::{notes::NoteTags, prelude::*};
impl Collection {
pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result<OpOutput<usize>> {
self.transact(Op::UpdateTag, |col| col.add_tags_to_notes_inner(nids, tags))
}
}
impl Collection {
fn add_tags_to_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result<usize> {
let usn = self.usn()?;
// will update tag list for any new tags, and match case
let tags_to_add = self.canonified_tags_as_vec(tags, usn)?;
// modify notes
let mut match_count = 0;
let notes = self.storage.get_note_tags_by_id_list(nids)?;
for original in notes {
if let Some(updated_tags) = add_missing_tags(&original.tags, &tags_to_add) {
match_count += 1;
let mut note = NoteTags {
tags: updated_tags,
..original
};
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
}
Ok(match_count)
}
}
/// Returns the sorted new tag string if any tags were added.
fn add_missing_tags(note_tags: &str, desired: &[UniCase<String>]) -> Option<String> {
let mut note_tags: HashSet<_> = split_tags(note_tags)
.map(ToOwned::to_owned)
.map(UniCase::new)
.collect();
let mut modified = false;
for tag in desired {
if !note_tags.contains(tag) {
note_tags.insert(tag.clone());
modified = true;
}
}
if !modified {
return None;
}
// sort
let mut tags: Vec<_> = note_tags.into_iter().collect::<Vec<_>>();
tags.sort_unstable();
// turn back into a string
let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect();
Some(join_tags(&tags))
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn add_missing() {
let desired: Vec<_> = ["xyz", "abc", "DEF"]
.iter()
.map(|s| UniCase::new(s.to_string()))
.collect();
let add_to = |text| add_missing_tags(text, &desired).unwrap();
assert_eq!(&add_to(""), " abc DEF xyz ");
assert_eq!(&add_to("XYZ deF aaa"), " aaa abc deF XYZ ");
assert_eq!(add_missing_tags("def xyz abc", &desired).is_none(), true);
}
}

View file

@ -0,0 +1,142 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::borrow::Cow;
use regex::{NoExpand, Regex, Replacer};
use super::{is_tag_separator, join_tags, split_tags};
use crate::{notes::NoteTags, prelude::*};
impl Collection {
/// Replace occurences of a search with a new value in tags.
pub fn find_and_replace_tag(
&mut self,
nids: &[NoteID],
search: &str,
replacement: &str,
regex: bool,
match_case: bool,
) -> Result<OpOutput<usize>> {
if replacement.contains(is_tag_separator) {
return Err(AnkiError::invalid_input(
"replacement name can not contain a space",
));
}
let mut search = if regex {
Cow::from(search)
} else {
Cow::from(regex::escape(search))
};
if !match_case {
search = format!("(?i){}", search).into();
}
self.transact(Op::UpdateTag, |col| {
if regex {
col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, replacement)
} else {
col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, NoExpand(replacement))
}
})
}
}
impl Collection {
fn replace_tags_for_notes_inner<R: Replacer>(
&mut self,
nids: &[NoteID],
regex: Regex,
mut repl: R,
) -> Result<usize> {
let usn = self.usn()?;
let mut match_count = 0;
let notes = self.storage.get_note_tags_by_id_list(nids)?;
for original in notes {
if let Some(updated_tags) = replace_tags(&original.tags, &regex, repl.by_ref()) {
let (tags, _) = self.canonify_tags(updated_tags, usn)?;
match_count += 1;
let mut note = NoteTags {
tags: join_tags(&tags),
..original
};
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
}
Ok(match_count)
}
}
/// If any tags are changed, return the new tags list.
/// The returned tags will need to be canonified.
fn replace_tags<R>(tags: &str, regex: &Regex, mut repl: R) -> Option<Vec<String>>
where
R: Replacer,
{
let maybe_replaced: Vec<_> = split_tags(tags)
.map(|tag| regex.replace_all(tag, repl.by_ref()))
.collect();
if maybe_replaced
.iter()
.any(|cow| matches!(cow, Cow::Owned(_)))
{
Some(maybe_replaced.into_iter().map(|s| s.to_string()).collect())
} else {
// nothing matched
None
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{collection::open_test_collection, decks::DeckID};
#[test]
fn find_replace() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("test".into());
col.add_note(&mut note, DeckID(1))?;
col.find_and_replace_tag(&[note.id], "foo|test", "bar", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "BAR", "baz", false, true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "b.r", "baz", false, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "b.r", "baz", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 1);
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["aye", "baz", "cee"]);
// if all tags already on note, it doesn't get updated
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 0);
// empty replacement deletes tag
col.find_and_replace_tag(&[note.id], "b.*|.*ye", "", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]);
Ok(())
}
}

160
rslib/src/tags/matcher.rs Normal file
View file

@ -0,0 +1,160 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use regex::{Captures, Regex};
use std::{borrow::Cow, collections::HashSet};
use super::{join_tags, split_tags};
use crate::prelude::*;
pub(crate) struct TagMatcher {
regex: Regex,
new_tags: HashSet<String>,
}
/// Helper to match any of the provided space-separated tags in a space-
/// separated list of tags, and replace the prefix.
///
/// Tracks seen tags during replacement, so the tag list can be updated as well.
impl TagMatcher {
pub fn new(space_separated_tags: &str) -> Result<Self> {
// convert "fo*o bar" into "fo\*o|bar"
let tags: Vec<_> = split_tags(space_separated_tags)
.map(regex::escape)
.collect();
let tags = tags.join("|");
let regex = Regex::new(&format!(
r#"(?ix)
# start of string, or a space
(?:^|\ )
# 1: the tag prefix
(
{}
)
(?:
# 2: an optional child separator
(::)
# or a space/end of string the end of the string
|\ |$
)
"#,
tags
))?;
Ok(Self {
regex,
new_tags: HashSet::new(),
})
}
pub fn is_match(&self, space_separated_tags: &str) -> bool {
self.regex.is_match(space_separated_tags)
}
pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String {
let tags: Vec<_> = split_tags(space_separated_tags)
.map(|tag| {
let out = self.regex.replace(tag, |caps: &Captures| {
// if we captured the child separator, add it to the replacement
if caps.get(2).is_some() {
Cow::Owned(format!("{}::", replacement))
} else {
Cow::Borrowed(replacement)
}
});
if let Cow::Owned(out) = out {
if !self.new_tags.contains(&out) {
self.new_tags.insert(out.clone());
}
out
} else {
out.to_string()
}
})
.collect();
join_tags(tags.as_slice())
}
/// The `replacement` function should return the text to use as a replacement.
pub fn replace_with_fn<F>(&mut self, space_separated_tags: &str, replacer: F) -> String
where
F: Fn(&str) -> String,
{
let tags: Vec<_> = split_tags(space_separated_tags)
.map(|tag| {
let out = self.regex.replace(tag, |caps: &Captures| {
let replacement = replacer(caps.get(1).unwrap().as_str());
// if we captured the child separator, add it to the replacement
if caps.get(2).is_some() {
format!("{}::", replacement)
} else {
replacement
}
});
if let Cow::Owned(out) = out {
if !self.new_tags.contains(&out) {
self.new_tags.insert(out.clone());
}
out
} else {
out.to_string()
}
})
.collect();
join_tags(tags.as_slice())
}
/// Remove any matching tags. Does not update seen_tags.
pub fn remove(&mut self, space_separated_tags: &str) -> String {
let tags: Vec<_> = split_tags(space_separated_tags)
.filter(|&tag| !self.is_match(tag))
.map(ToString::to_string)
.collect();
join_tags(tags.as_slice())
}
/// Returns all replaced values that were used, so they can be registered
/// into the tag list.
pub fn into_new_tags(self) -> HashSet<String> {
self.new_tags
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn regex() -> Result<()> {
let re = TagMatcher::new("one two")?;
assert_eq!(re.is_match(" foo "), false);
assert_eq!(re.is_match(" foo one "), true);
assert_eq!(re.is_match(" two foo "), true);
let mut re = TagMatcher::new("foo")?;
assert_eq!(re.is_match("foo"), true);
assert_eq!(re.is_match(" foo "), true);
assert_eq!(re.is_match(" bar foo baz "), true);
assert_eq!(re.is_match(" bar FOO baz "), true);
assert_eq!(re.is_match(" bar foof baz "), false);
assert_eq!(re.is_match(" barfoo "), false);
let mut as_xxx = |text| re.replace(text, "xxx");
assert_eq!(&as_xxx(" baz FOO "), " baz xxx ");
assert_eq!(&as_xxx(" x foo::bar x "), " x xxx::bar x ");
assert_eq!(
&as_xxx(" x foo::bar bar::foo x "),
" x xxx::bar bar::foo x "
);
assert_eq!(
&as_xxx(" x foo::bar foo::bar::baz x "),
" x xxx::bar xxx::bar::baz x "
);
Ok(())
}
}

View file

@ -1,22 +1,20 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod bulkadd;
mod findreplace;
mod matcher;
mod register;
mod remove;
mod rename;
mod reparent;
mod tree;
pub(crate) mod undo; pub(crate) mod undo;
use crate::{
backend_proto::TagTreeNode,
collection::Collection,
err::{AnkiError, Result},
notes::{NoteID, TransformNoteOutput},
prelude::*,
text::{normalize_to_nfc, to_re},
types::Usn,
};
use regex::{NoExpand, Regex, Replacer};
use std::{borrow::Cow, collections::HashSet, iter::Peekable};
use unicase::UniCase; use unicase::UniCase;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Tag { pub struct Tag {
pub name: String, pub name: String,
@ -50,41 +48,6 @@ fn is_tag_separator(c: char) -> bool {
c == ' ' || c == '\u{3000}' c == ' ' || c == '\u{3000}'
} }
fn invalid_char_for_tag(c: char) -> bool {
c.is_ascii_control() || is_tag_separator(c) || c == '"'
}
fn normalized_tag_name_component(comp: &str) -> Cow<str> {
let mut out = normalize_to_nfc(comp);
if out.contains(invalid_char_for_tag) {
out = out.replace(invalid_char_for_tag, "").into();
}
let trimmed = out.trim();
if trimmed.is_empty() {
"blank".to_string().into()
} else if trimmed.len() != out.len() {
trimmed.to_string().into()
} else {
out
}
}
fn normalize_tag_name(name: &str) -> Cow<str> {
if name
.split("::")
.any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_)))
{
let comps: Vec<_> = name
.split("::")
.map(normalized_tag_name_component)
.collect::<Vec<_>>();
comps.join("::").into()
} else {
// no changes required
name.into()
}
}
fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option<UniCase<&str>> { fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option<UniCase<&str>> {
tag_name.rsplitn(2, '\x1f').nth(1).map(UniCase::new) tag_name.rsplitn(2, '\x1f').nth(1).map(UniCase::new)
} }
@ -93,236 +56,7 @@ fn immediate_parent_name_str(tag_name: &str) -> Option<&str> {
tag_name.rsplitn(2, "::").nth(1) tag_name.rsplitn(2, "::").nth(1)
} }
/// Arguments are expected in 'human' form with an :: separator.
pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option<String> {
let dragged_base = dragged.rsplit("::").next().unwrap();
if let Some(dropped) = dropped {
if dropped.starts_with(dragged) {
// foo onto foo::bar, or foo onto itself -> no-op
None
} else {
// foo::bar onto baz -> baz::bar
Some(format!("{}::{}", dropped, dragged_base))
}
} else {
// foo::bar onto top level -> bar
Some(dragged_base.into())
}
}
/// For the given tag, check if immediate parent exists. If so, add
/// tag and return.
/// If the immediate parent is missing, check and add any missing parents.
/// This should ensure that if an immediate parent is found, all ancestors
/// are guaranteed to already exist.
fn add_tag_and_missing_parents<'a, 'b>(
all: &'a mut HashSet<UniCase<&'b str>>,
missing: &'a mut Vec<UniCase<&'b str>>,
tag_name: UniCase<&'b str>,
) {
if let Some(parent) = immediate_parent_name_unicase(tag_name) {
if !all.contains(&parent) {
missing.push(parent);
add_tag_and_missing_parents(all, missing, parent);
}
}
// finally, add provided tag
all.insert(tag_name);
}
/// Append any missing parents. Caller must sort afterwards.
fn add_missing_parents(tags: &mut Vec<Tag>) {
let mut all_names: HashSet<UniCase<&str>> = HashSet::new();
let mut missing = vec![];
for tag in &*tags {
add_tag_and_missing_parents(&mut all_names, &mut missing, UniCase::new(&tag.name))
}
let mut missing: Vec<_> = missing
.into_iter()
.map(|n| Tag::new(n.to_string(), Usn(0)))
.collect();
tags.append(&mut missing);
}
fn tags_to_tree(mut tags: Vec<Tag>) -> TagTreeNode {
for tag in &mut tags {
tag.name = tag.name.replace("::", "\x1f");
}
add_missing_parents(&mut tags);
tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name)));
let mut top = TagTreeNode::default();
let mut it = tags.into_iter().peekable();
add_child_nodes(&mut it, &mut top);
top
}
fn add_child_nodes(tags: &mut Peekable<impl Iterator<Item = Tag>>, parent: &mut TagTreeNode) {
while let Some(tag) = tags.peek() {
let split_name: Vec<_> = tag.name.split('\x1f').collect();
match split_name.len() as u32 {
l if l <= parent.level => {
// next item is at a higher level
return;
}
l if l == parent.level + 1 => {
// next item is an immediate descendent of parent
parent.children.push(TagTreeNode {
name: (*split_name.last().unwrap()).into(),
children: vec![],
level: parent.level + 1,
expanded: tag.expanded,
});
tags.next();
}
_ => {
// next item is at a lower level
if let Some(last_child) = parent.children.last_mut() {
add_child_nodes(tags, last_child)
} else {
// immediate parent is missing
tags.next();
}
}
}
}
}
impl Collection { impl Collection {
pub fn tag_tree(&mut self) -> Result<TagTreeNode> {
let tags = self.storage.all_tags()?;
let tree = tags_to_tree(tags);
Ok(tree)
}
/// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added.
pub(crate) fn canonify_tags(
&mut self,
tags: Vec<String>,
usn: Usn,
) -> Result<(Vec<String>, bool)> {
let mut seen = HashSet::new();
let mut added = false;
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
for tag in tags {
let mut tag = Tag::new(tag.to_string(), usn);
added |= self.register_tag(&mut tag)?;
seen.insert(UniCase::new(tag.name));
}
// exit early if no non-empty tags
if seen.is_empty() {
return Ok((vec![], added));
}
// return the sorted, canonified tags
let mut tags = seen.into_iter().collect::<Vec<_>>();
tags.sort_unstable();
let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect();
Ok((tags, added))
}
/// Adjust tag casing to match any existing parents, and register it if it's not already
/// in the tags list. True if the tag was added and not already in tag list.
/// In the case the tag is already registered, tag will be mutated to match the existing
/// name.
pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result<bool> {
let normalized_name = normalize_tag_name(&tag.name);
if normalized_name.is_empty() {
// this should not be possible
return Err(AnkiError::invalid_input("blank tag"));
}
if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? {
tag.name = existing_tag.name;
Ok(false)
} else {
if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? {
tag.name = new_name;
} else if let Cow::Owned(new_name) = normalized_name {
tag.name = new_name;
}
self.register_tag_undoable(&tag)?;
Ok(true)
}
}
/// If parent tag(s) exist and differ in case, return a rewritten tag.
fn adjusted_case_for_parents(&self, tag: &str) -> Result<Option<String>> {
if let Some(parent_tag) = self.first_existing_parent_tag(&tag)? {
let child_split: Vec<_> = tag.split("::").collect();
let parent_count = parent_tag.matches("::").count() + 1;
Ok(Some(format!(
"{}::{}",
parent_tag,
&child_split[parent_count..].join("::")
)))
} else {
Ok(None)
}
}
fn first_existing_parent_tag(&self, mut tag: &str) -> Result<Option<String>> {
while let Some(parent_name) = immediate_parent_name_str(tag) {
if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? {
return Ok(Some(parent_tag));
}
tag = parent_name;
}
Ok(None)
}
pub fn clear_unused_tags(&self) -> Result<()> {
let expanded: HashSet<_> = self.storage.expanded_tags()?.into_iter().collect();
self.storage.clear_all_tags()?;
let usn = self.usn()?;
for name in self.storage.all_tags_in_notes()? {
let name = normalize_tag_name(&name).into();
self.storage.register_tag(&Tag {
expanded: expanded.contains(&name),
name,
usn,
})?;
}
Ok(())
}
/// Take tags as a whitespace-separated string and remove them from all notes and the storage.
pub fn expunge_tags(&mut self, tags: &str) -> Result<usize> {
let tag_group = format!("({})", regex::escape(tags.trim()).replace(' ', "|"));
let nids = self.nids_for_tags(&tag_group)?;
let re = Regex::new(&format!("(?i)^{}(::.*)?$", tag_group))?;
self.transact(None, |col| {
col.storage.clear_tag_group(&tag_group)?;
col.transform_notes(&nids, |note, _nt| {
Ok(TransformNoteOutput {
changed: note.remove_tags(&re),
generate_cards: false,
mark_modified: true,
})
})
})
}
/// Take tags as a regexp group, i.e. separated with pipes and wrapped in brackets, and return
/// the ids of all notes with one of them.
fn nids_for_tags(&mut self, tag_group: &str) -> Result<Vec<NoteID>> {
let mut stmt = self
.storage
.db
.prepare("select id from notes where tags regexp ?")?;
let args = format!("(?i).* {}(::| ).*", tag_group);
let nids = stmt
.query_map(&[args], |row| row.get(0))?
.collect::<std::result::Result<_, _>>()?;
Ok(nids)
}
pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> { pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> {
let mut name = name; let mut name = name;
let tag; let tag;
@ -334,488 +68,4 @@ impl Collection {
} }
self.storage.set_tag_collapsed(name, !expanded) self.storage.set_tag_collapsed(name, !expanded)
} }
fn replace_tags_for_notes_inner<R: Replacer>(
&mut self,
nids: &[NoteID],
tags: &[Regex],
mut repl: R,
) -> Result<usize> {
self.transact(Some(UndoableOpKind::UpdateTag), |col| {
col.transform_notes(nids, |note, _nt| {
let mut changed = false;
for re in tags {
if note.replace_tags(re, repl.by_ref()) {
changed = true;
}
}
Ok(TransformNoteOutput {
changed,
generate_cards: false,
mark_modified: true,
})
})
})
}
/// Apply the provided list of regular expressions to note tags,
/// saving any modified notes.
pub fn replace_tags_for_notes(
&mut self,
nids: &[NoteID],
tags: &str,
repl: &str,
regex: bool,
) -> Result<usize> {
// generate regexps
let tags = split_tags(tags)
.map(|tag| {
let tag = if regex { tag.into() } else { to_re(tag) };
Regex::new(&format!("(?i)^{}(::.*)?$", tag))
.map_err(|_| AnkiError::invalid_input("invalid regex"))
})
.collect::<Result<Vec<Regex>>>()?;
if !regex {
self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl))
} else {
self.replace_tags_for_notes_inner(nids, &tags, repl)
}
}
pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result<usize> {
let tags: Vec<_> = split_tags(tags).collect();
let matcher = regex::RegexSet::new(
tags.iter()
.map(|s| regex::escape(s))
.map(|s| format!("(?i)^{}$", s)),
)
.map_err(|_| AnkiError::invalid_input("invalid regex"))?;
self.transact(Some(UndoableOpKind::UpdateTag), |col| {
col.transform_notes(nids, |note, _nt| {
let mut need_to_add = true;
let mut match_count = 0;
for tag in &note.tags {
if matcher.is_match(tag) {
match_count += 1;
}
if match_count == tags.len() {
need_to_add = false;
break;
}
}
if need_to_add {
note.tags.extend(tags.iter().map(|&s| s.to_string()))
}
Ok(TransformNoteOutput {
changed: need_to_add,
generate_cards: false,
mark_modified: true,
})
})
})
}
pub fn drag_drop_tags(
&mut self,
source_tags: &[String],
target_tag: Option<String>,
) -> Result<()> {
let source_tags_and_outputs: Vec<_> = source_tags
.iter()
// generate resulting names and filter out invalid ones
.flat_map(|source_tag| {
if let Some(output_name) = drag_drop_tag_name(source_tag, target_tag.as_deref()) {
Some((source_tag, output_name))
} else {
// invalid rename, ignore this tag
None
}
})
.collect();
let regexps_and_replacements = source_tags_and_outputs
.iter()
// convert the names into regexps/replacements
.map(|(tag, output)| {
Regex::new(&format!(
r#"(?ix)
^
{}
# optional children
(::.+)?
$
"#,
regex::escape(tag)
))
.map_err(|_| AnkiError::invalid_input("invalid regex"))
.map(|regex| (regex, output))
})
.collect::<Result<Vec<_>>>()?;
// locate notes that match them
let mut nids = vec![];
self.storage.for_each_note_tags(|nid, tags| {
for tag in split_tags(&tags) {
for (regex, _) in &regexps_and_replacements {
if regex.is_match(&tag) {
nids.push(nid);
break;
}
}
}
Ok(())
})?;
if nids.is_empty() {
return Ok(());
}
// update notes
self.transact(None, |col| {
// clear the existing original tags
for (source_tag, _) in &source_tags_and_outputs {
col.storage.clear_tag_and_children(source_tag)?;
}
col.transform_notes(&nids, |note, _nt| {
let mut changed = false;
for (re, repl) in &regexps_and_replacements {
if note.replace_tags(re, NoExpand(&repl).by_ref()) {
changed = true;
}
}
Ok(TransformNoteOutput {
changed,
generate_cards: false,
mark_modified: true,
})
})
})?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{collection::open_test_collection, decks::DeckID};
#[test]
fn tags() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
col.add_note(&mut note, DeckID(1))?;
let tags: String = col.storage.db_scalar("select tags from notes")?;
assert_eq!(tags, "");
// first instance wins in case of duplicates
note.tags = vec!["foo".into(), "FOO".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["foo"]);
let tags: String = col.storage.db_scalar("select tags from notes")?;
assert_eq!(tags, " foo ");
// existing case is used if in DB
note.tags = vec!["FOO".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["foo"]);
assert_eq!(tags, " foo ");
// tags are normalized to nfc
note.tags = vec!["\u{fa47}".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["\u{6f22}"]);
// if code incorrectly adds a space to a tag, it gets split
note.tags = vec!["one two".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["one", "two"]);
// blanks should be handled
note.tags = vec![
"".into(),
"foo".into(),
" ".into(),
"::".into(),
"foo::".into(),
];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["blank::blank", "foo", "foo::blank"]);
Ok(())
}
#[test]
fn bulk() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("test".into());
col.add_note(&mut note, DeckID(1))?;
col.replace_tags_for_notes(&[note.id], "foo test", "bar", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.replace_tags_for_notes(&[note.id], "b.r", "baz", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.replace_tags_for_notes(&[note.id], "b*r", "baz", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
col.replace_tags_for_notes(&[note.id], "b.r", "baz", true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(cnt, 1);
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["aye", "baz", "cee"]);
// if all tags already on note, it doesn't get updated
let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(cnt, 0);
// empty replacement deletes tag
col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]);
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec![
"foo::bar".into(),
"foo::bar::foo".into(),
"bar::foo".into(),
"bar::foo::bar".into(),
];
col.update_note(&mut note)?;
col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]);
// ensure replacements fully match
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec!["foobar".into(), "barfoo".into(), "foo".into()];
col.update_note(&mut note)?;
col.replace_tags_for_notes(&[note.id], "foo", "", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["barfoo", "foobar"]);
// tag children are also cleared when clearing their parent
col.storage.clear_all_tags()?;
for name in vec!["a", "a::b", "A::b::c"] {
col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?;
}
col.storage.clear_tag_and_children("a")?;
assert_eq!(col.storage.all_tags()?, vec![]);
Ok(())
}
fn node(name: &str, level: u32, children: Vec<TagTreeNode>) -> TagTreeNode {
TagTreeNode {
name: name.into(),
level,
children,
..Default::default()
}
}
fn leaf(name: &str, level: u32) -> TagTreeNode {
node(name, level, vec![])
}
#[test]
fn tree() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("foo::bar::a".into());
note.tags.push("foo::bar::b".into());
col.add_note(&mut note, DeckID(1))?;
// missing parents are added
assert_eq!(
col.tag_tree()?,
node(
"",
0,
vec![node(
"foo",
1,
vec![node("bar", 2, vec![leaf("a", 3), leaf("b", 3)])]
)]
)
);
// differing case should result in only one parent case being added -
// the first one
col.storage.clear_all_tags()?;
note.tags[0] = "foo::BAR::a".into();
note.tags[1] = "FOO::bar::b".into();
col.update_note(&mut note)?;
assert_eq!(
col.tag_tree()?,
node(
"",
0,
vec![node(
"foo",
1,
vec![node("BAR", 2, vec![leaf("a", 3), leaf("b", 3)])]
)]
)
);
// things should work even if the immediate parent is not missing
col.storage.clear_all_tags()?;
note.tags[0] = "foo::bar::baz".into();
note.tags[1] = "foo::bar::baz::quux".into();
col.update_note(&mut note)?;
assert_eq!(
col.tag_tree()?,
node(
"",
0,
vec![node(
"foo",
1,
vec![node("bar", 2, vec![node("baz", 3, vec![leaf("quux", 4)])])]
)]
)
);
// numbers have a smaller ascii number than ':', so a naive sort on
// '::' would result in one::two being nested under one1.
col.storage.clear_all_tags()?;
note.tags[0] = "one".into();
note.tags[1] = "one1".into();
note.tags.push("one::two".into());
col.update_note(&mut note)?;
assert_eq!(
col.tag_tree()?,
node(
"",
0,
vec![node("one", 1, vec![leaf("two", 2)]), leaf("one1", 1)]
)
);
// children should match the case of their parents
col.storage.clear_all_tags()?;
note.tags[0] = "FOO".into();
note.tags[1] = "foo::BAR".into();
note.tags[2] = "foo::bar::baz".into();
col.update_note(&mut note)?;
assert_eq!(note.tags, vec!["FOO", "FOO::BAR", "FOO::BAR::baz"]);
Ok(())
}
#[test]
fn clearing() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("one".into());
note.tags.push("two".into());
col.add_note(&mut note, DeckID(1))?;
col.set_tag_expanded("one", true)?;
col.clear_unused_tags()?;
assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true);
assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false);
Ok(())
}
fn alltags(col: &Collection) -> Vec<String> {
col.storage
.all_tags()
.unwrap()
.into_iter()
.map(|t| t.name)
.collect()
}
#[test]
fn dragdrop() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
for tag in &[
"another",
"parent1::child1::grandchild1",
"parent1::child1",
"parent1",
"parent2",
"yet::another",
] {
let mut note = nt.new_note();
note.tags.push(tag.to_string());
col.add_note(&mut note, DeckID(1))?;
}
// two decks with the same base name; they both get mapped
// to parent1::another
col.drag_drop_tags(
&["another".to_string(), "yet::another".to_string()],
Some("parent1".to_string()),
)?;
assert_eq!(
alltags(&col),
&[
"parent1",
"parent1::another",
"parent1::child1",
"parent1::child1::grandchild1",
"parent2",
]
);
// child and children moved to parent2
col.drag_drop_tags(
&["parent1::child1".to_string()],
Some("parent2".to_string()),
)?;
assert_eq!(
alltags(&col),
&[
"parent1",
"parent1::another",
"parent2",
"parent2::child1",
"parent2::child1::grandchild1",
]
);
// empty target reparents to root
col.drag_drop_tags(&["parent1::another".to_string()], None)?;
assert_eq!(
alltags(&col),
&[
"another",
"parent1",
"parent2",
"parent2::child1",
"parent2::child1::grandchild1",
]
);
Ok(())
}
} }

213
rslib/src/tags/register.rs Normal file
View file

@ -0,0 +1,213 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{immediate_parent_name_str, is_tag_separator, split_tags, Tag};
use crate::{prelude::*, text::normalize_to_nfc, types::Usn};
use std::{borrow::Cow, collections::HashSet};
use unicase::UniCase;
impl Collection {
/// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added.
/// Each tag is split on spaces, so if you have a &str, you
/// can pass that in as a one-element vec.
pub(crate) fn canonify_tags(
&mut self,
tags: Vec<String>,
usn: Usn,
) -> Result<(Vec<String>, bool)> {
let mut seen = HashSet::new();
let mut added = false;
let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
for tag in tags {
let mut tag = Tag::new(tag.to_string(), usn);
added |= self.register_tag(&mut tag)?;
seen.insert(UniCase::new(tag.name));
}
// exit early if no non-empty tags
if seen.is_empty() {
return Ok((vec![], added));
}
// return the sorted, canonified tags
let mut tags = seen.into_iter().collect::<Vec<_>>();
tags.sort_unstable();
let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect();
Ok((tags, added))
}
/// Returns true if any cards were added to the tag list.
pub(crate) fn canonified_tags_as_vec(
&mut self,
tags: &str,
usn: Usn,
) -> Result<Vec<UniCase<String>>> {
let mut out_tags = vec![];
for tag in split_tags(tags) {
let mut tag = Tag::new(tag.to_string(), usn);
self.register_tag(&mut tag)?;
out_tags.push(UniCase::new(tag.name));
}
Ok(out_tags)
}
/// Adjust tag casing to match any existing parents, and register it if it's not already
/// in the tags list. True if the tag was added and not already in tag list.
/// In the case the tag is already registered, tag will be mutated to match the existing
/// name.
pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result<bool> {
let is_new = self.prepare_tag_for_registering(tag)?;
if is_new {
self.register_tag_undoable(&tag)?;
}
Ok(is_new)
}
/// Create a tag object, normalize text, and match parents/existing case if available.
/// True if tag is new.
pub(super) fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result<bool> {
let normalized_name = normalize_tag_name(&tag.name);
if normalized_name.is_empty() {
// this should not be possible
return Err(AnkiError::invalid_input("blank tag"));
}
if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? {
tag.name = existing_tag.name;
Ok(false)
} else {
if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? {
tag.name = new_name;
} else if let Cow::Owned(new_name) = normalized_name {
tag.name = new_name;
}
Ok(true)
}
}
pub(super) fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result<bool> {
let mut tag = Tag::new(tag, usn);
self.register_tag(&mut tag)
}
}
impl Collection {
/// If parent tag(s) exist and differ in case, return a rewritten tag.
fn adjusted_case_for_parents(&self, tag: &str) -> Result<Option<String>> {
if let Some(parent_tag) = self.first_existing_parent_tag(&tag)? {
let child_split: Vec<_> = tag.split("::").collect();
let parent_count = parent_tag.matches("::").count() + 1;
Ok(Some(format!(
"{}::{}",
parent_tag,
&child_split[parent_count..].join("::")
)))
} else {
Ok(None)
}
}
fn first_existing_parent_tag(&self, mut tag: &str) -> Result<Option<String>> {
while let Some(parent_name) = immediate_parent_name_str(tag) {
if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? {
return Ok(Some(parent_tag));
}
tag = parent_name;
}
Ok(None)
}
}
fn invalid_char_for_tag(c: char) -> bool {
c.is_ascii_control() || is_tag_separator(c) || c == '"'
}
fn normalized_tag_name_component(comp: &str) -> Cow<str> {
let mut out = normalize_to_nfc(comp);
if out.contains(invalid_char_for_tag) {
out = out.replace(invalid_char_for_tag, "").into();
}
let trimmed = out.trim();
if trimmed.is_empty() {
"blank".to_string().into()
} else if trimmed.len() != out.len() {
trimmed.to_string().into()
} else {
out
}
}
fn normalize_tag_name(name: &str) -> Cow<str> {
if name
.split("::")
.any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_)))
{
let comps: Vec<_> = name
.split("::")
.map(normalized_tag_name_component)
.collect::<Vec<_>>();
comps.join("::").into()
} else {
// no changes required
name.into()
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{collection::open_test_collection, decks::DeckID};
#[test]
fn tags() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
col.add_note(&mut note, DeckID(1))?;
let tags: String = col.storage.db_scalar("select tags from notes")?;
assert_eq!(tags, "");
// first instance wins in case of duplicates
note.tags = vec!["foo".into(), "FOO".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["foo"]);
let tags: String = col.storage.db_scalar("select tags from notes")?;
assert_eq!(tags, " foo ");
// existing case is used if in DB
note.tags = vec!["FOO".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["foo"]);
assert_eq!(tags, " foo ");
// tags are normalized to nfc
note.tags = vec!["\u{fa47}".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["\u{6f22}"]);
// if code incorrectly adds a space to a tag, it gets split
note.tags = vec!["one two".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["one", "two"]);
// blanks should be handled
note.tags = vec![
"".into(),
"foo".into(),
" ".into(),
"::".into(),
"foo::".into(),
];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["blank::blank", "foo", "foo::blank"]);
Ok(())
}
}

126
rslib/src/tags/remove.rs Normal file
View file

@ -0,0 +1,126 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::matcher::TagMatcher;
use crate::prelude::*;
impl Collection {
/// Take tags as a whitespace-separated string and remove them from all notes and the tag list.
pub fn remove_tags(&mut self, tags: &str) -> Result<OpOutput<usize>> {
self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags))
}
/// Remove whitespace-separated tags from provided notes.
pub fn remove_tags_from_notes(
&mut self,
nids: &[NoteID],
tags: &str,
) -> Result<OpOutput<usize>> {
self.transact(Op::RemoveTag, |col| {
col.remove_tags_from_notes_inner(nids, tags)
})
}
/// Remove tags not referenced by notes, returning removed count.
pub fn clear_unused_tags(&mut self) -> Result<OpOutput<usize>> {
self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner())
}
}
impl Collection {
fn remove_tags_inner(&mut self, tags: &str) -> Result<usize> {
let usn = self.usn()?;
// gather tags that need removing
let mut re = TagMatcher::new(tags)?;
let matched_notes = self
.storage
.get_note_tags_by_predicate(|tags| re.is_match(tags))?;
let match_count = matched_notes.len();
// remove from the tag list
for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? {
self.remove_single_tag_undoable(tag)?;
}
// replace tags
for mut note in matched_notes {
let original = note.clone();
note.tags = re.remove(&note.tags);
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
Ok(match_count)
}
fn remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result<usize> {
let usn = self.usn()?;
let mut re = TagMatcher::new(tags)?;
let mut match_count = 0;
let notes = self.storage.get_note_tags_by_id_list(nids)?;
for mut note in notes {
if !re.is_match(&note.tags) {
continue;
}
match_count += 1;
let original = note.clone();
note.tags = re.remove(&note.tags);
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
Ok(match_count)
}
fn clear_unused_tags_inner(&mut self) -> Result<usize> {
let mut count = 0;
let in_notes = self.storage.all_tags_in_notes()?;
let need_remove = self
.storage
.all_tags()?
.into_iter()
.filter(|tag| !in_notes.contains(&tag.name));
for tag in need_remove {
self.remove_single_tag_undoable(tag)?;
count += 1;
}
Ok(count)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::collection::open_test_collection;
use crate::tags::Tag;
#[test]
fn clearing() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("one".into());
note.tags.push("two".into());
col.add_note(&mut note, DeckID(1))?;
col.set_tag_expanded("one", true)?;
col.clear_unused_tags()?;
assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true);
assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false);
// tag children are also cleared when clearing their parent
col.storage.clear_all_tags()?;
for name in vec!["a", "a::b", "A::b::c"] {
col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?;
}
col.remove_tags("a")?;
assert_eq!(col.storage.all_tags()?, vec![]);
Ok(())
}
}

68
rslib/src/tags/rename.rs Normal file
View file

@ -0,0 +1,68 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{is_tag_separator, matcher::TagMatcher, Tag};
use crate::prelude::*;
impl Collection {
/// Rename a given tag and its children on all notes that reference it, returning changed
/// note count.
pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result<OpOutput<usize>> {
self.transact(Op::RenameTag, |col| {
col.rename_tag_inner(old_prefix, new_prefix)
})
}
}
impl Collection {
fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result<usize> {
if new_prefix.contains(is_tag_separator) {
return Err(AnkiError::invalid_input(
"replacement name can not contain a space",
));
}
if new_prefix.trim().is_empty() {
return Err(AnkiError::invalid_input(
"replacement name must not be empty",
));
}
let usn = self.usn()?;
// match existing case if available, and ensure normalized.
let mut tag = Tag::new(new_prefix.to_string(), usn);
self.prepare_tag_for_registering(&mut tag)?;
let new_prefix = &tag.name;
// gather tags that need replacing
let mut re = TagMatcher::new(old_prefix)?;
let matched_notes = self
.storage
.get_note_tags_by_predicate(|tags| re.is_match(tags))?;
let match_count = matched_notes.len();
if match_count == 0 {
// no matches; exit early so we don't clobber the empty tag entries
return Ok(0);
}
// remove old prefix from the tag list
for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? {
self.remove_single_tag_undoable(tag)?;
}
// replace tags
for mut note in matched_notes {
let original = note.clone();
note.tags = re.replace(&note.tags, new_prefix);
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
// update tag list
for tag in re.into_new_tags() {
self.register_tag_string(tag, usn)?;
}
Ok(match_count)
}
}

Some files were not shown because too many files have changed in this diff Show more