Merge branch 'master' into sidebar-tools

This commit is contained in:
RumovZ 2021-03-11 12:08:32 +01:00
commit f4c2fe6485
42 changed files with 2302 additions and 1947 deletions

View File

@ -11,7 +11,9 @@ undo-action-redone = { $action } redone
undo-answer-card = Answer Card
undo-unbury-unsuspend = Unbury/Unsuspend
undo-add-deck = Add Deck
undo-add-note = Add Note
undo-update-tag = Update Tag
undo-update-note = Update Note
undo-update-card = Update Card
undo-update-deck = Update Deck

View File

@ -36,7 +36,7 @@ py_binary(
genrule(
name = "rsbackend_gen",
outs = ["generated.py"],
cmd = "$(location genbackend) > $@",
cmd = "$(location genbackend) $@",
tools = ["genbackend"],
)

View File

@ -95,10 +95,10 @@ class RustBackend(RustBackendGenerated):
)
return self.format_timespan(seconds=seconds, context=context)
def _run_command(self, method: int, input: Any) -> bytes:
def _run_command(self, service: int, method: int, input: Any) -> bytes:
input_bytes = input.SerializeToString()
try:
return self._backend.command(method, input_bytes)
return self._backend.command(service, method, input_bytes)
except Exception as e:
err_bytes = bytes(e.args[0])
err = pb.BackendError()

View File

@ -1,10 +1,13 @@
#!/usr/bin/env python3
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import os
import re
import sys
import google.protobuf.descriptor
import pylib.anki._backend.backend_pb2 as pb
import stringcase
@ -97,7 +100,7 @@ def get_input_assign(msg):
return ", ".join(f"{f.name}={f.name}" for f in fields)
def render_method(method, idx):
def render_method(service_idx, method_idx, method):
input_name = method.input_type.name
if (
(input_name.endswith("In") or len(method.input_type.fields) < 2)
@ -134,11 +137,11 @@ def render_method(method, idx):
{input_assign_outer}"""
if method.name in SKIP_DECODE:
buf += f"""return self._run_command({idx+1}, input)
buf += f"""return self._run_command({service_idx}, {method_idx}, input)
"""
else:
buf += f"""output = pb.{method.output_type.name}()
output.ParseFromString(self._run_command({idx+1}, input))
output.ParseFromString(self._run_command({service_idx}, {method_idx}, input))
return output{single_field}
"""
@ -146,13 +149,29 @@ def render_method(method, idx):
out = []
for idx, method in enumerate(pb._BACKENDSERVICE.methods):
out.append(render_method(method, idx))
def render_service(
service: google.protobuf.descriptor.ServiceDescriptor, service_index: int
) -> None:
for method_index, method in enumerate(service.methods):
out.append(render_method(service_index, method_index, method))
for service in pb.ServiceIndex.DESCRIPTOR.values:
# SERVICE_INDEX_TEST -> _TESTSERVICE
service_var = (
"_" + service.name.replace("SERVICE_INDEX", "").replace("_", "") + "SERVICE"
)
service_obj = getattr(pb, service_var)
service_index = service.number
render_service(service_obj, service_index)
out = "\n".join(out)
sys.stdout.buffer.write(
open(sys.argv[1], "wb").write(
(
'''# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@ -174,7 +193,7 @@ from typing import *
import anki._backend.backend_pb2 as pb
class RustBackendGenerated:
def _run_command(self, method: int, input: Any) -> bytes:
def _run_command(self, service: int, method: int, input: Any) -> bytes:
raise Exception("not implemented")
'''

View File

@ -3,5 +3,5 @@ def open_backend(data: bytes) -> Backend: ...
class Backend:
@classmethod
def command(self, method: int, data: bytes) -> bytes: ...
def command(self, service: int, method: int, data: bytes) -> bytes: ...
def db_command(self, data: bytes) -> bytes: ...

View File

@ -259,11 +259,13 @@ class DeckManager:
deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn
)
def rename(self, g: Deck, newName: str) -> None:
def rename(self, deck: Union[Deck, int], new_name: str) -> None:
"Rename deck prefix to NAME if not exists. Updates children."
g["name"] = newName
self.update(g, preserve_usn=False)
return
if isinstance(deck, int):
deck_id = deck
else:
deck_id = deck["id"]
self.col._backend.rename_deck(deck_id=deck_id, new_name=new_name)
# Drag/drop
#############################################################

View File

@ -1,15 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki::backend::{init_backend, Backend as RustBackend, BackendMethod};
use anki::backend::{init_backend, Backend as RustBackend};
use pyo3::exceptions::PyException;
use pyo3::prelude::*;
use pyo3::types::PyBytes;
use pyo3::{create_exception, wrap_pyfunction};
use std::convert::TryFrom;
// Regular backend
//////////////////////////////////
#[pyclass(module = "rsbridge")]
struct Backend {
@ -31,45 +27,17 @@ fn open_backend(init_msg: &PyBytes) -> PyResult<Backend> {
}
}
fn want_release_gil(method: u32) -> bool {
if let Ok(method) = BackendMethod::try_from(method) {
!matches!(
method,
BackendMethod::ExtractAVTags
| BackendMethod::ExtractLatex
| BackendMethod::RenderExistingCard
| BackendMethod::RenderUncommittedCard
| BackendMethod::StripAVTags
| BackendMethod::SchedTimingToday
| BackendMethod::AddOrUpdateDeckLegacy
| BackendMethod::NewDeckLegacy
| BackendMethod::NewDeckConfigLegacy
| BackendMethod::GetStockNotetypeLegacy
| BackendMethod::StudiedToday
| BackendMethod::TranslateString
| BackendMethod::FormatTimespan
| BackendMethod::LatestProgress
| BackendMethod::SetWantsAbort
| BackendMethod::I18nResources
| BackendMethod::JoinSearchNodes
| BackendMethod::ReplaceSearchNode
| BackendMethod::BuildSearchString
| BackendMethod::StateIsLeech
)
} else {
false
}
}
#[pymethods]
impl Backend {
fn command(&self, py: Python, method: u32, input: &PyBytes) -> PyResult<PyObject> {
fn command(
&self,
py: Python,
service: u32,
method: u32,
input: &PyBytes,
) -> PyResult<PyObject> {
let in_bytes = input.as_bytes();
if want_release_gil(method) {
py.allow_threads(|| self.backend.run_command_bytes(method, in_bytes))
} else {
self.backend.run_command_bytes(method, in_bytes)
}
py.allow_threads(|| self.backend.run_method(service, method, in_bytes))
.map(|out_bytes| {
let out_obj = PyBytes::new(py, &out_bytes);
out_obj.into()

View File

@ -259,7 +259,6 @@ class DeckBrowser:
self.mw.onExport(did=did)
def _rename(self, did: int) -> None:
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK))
deck = self.mw.col.decks.get(did)
oldName = deck["name"]
newName = getOnlyText(tr(TR.DECKS_NEW_DECK_NAME), default=oldName)
@ -272,6 +271,7 @@ class DeckBrowser:
except DeckIsFilteredError as err:
showWarning(str(err))
return
self.mw.update_undo_actions()
self.show()
def _options(self, did: str) -> None:
@ -288,23 +288,31 @@ class DeckBrowser:
self._renderPage(reuse=True)
def _handle_drag_and_drop(self, source: int, target: int) -> None:
try:
def process() -> None:
self.mw.col.decks.drag_drop_decks([source], target)
def on_done(fut: Future) -> None:
try:
fut.result()
except Exception as e:
showWarning(str(e))
return
self.mw.update_undo_actions()
gui_hooks.sidebar_should_refresh_decks()
self.show()
self.mw.taskman.with_progress(process, on_done)
def _delete(self, did: int) -> None:
def do_delete() -> int:
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.checkpoint(tr(TR.DECKS_DELETE_DECK))
self.mw.taskman.with_progress(do_delete, on_done)
# Top buttons
@ -385,7 +393,7 @@ class DeckBrowser:
defaultno=True,
):
prefs = self.mw.col.get_preferences()
prefs.sched.new_timezone = False
prefs.scheduling.new_timezone = False
self.mw.col.set_preferences(prefs)
showInfo(tr(TR.SCHEDULING_UPDATE_DONE))

View File

@ -20,7 +20,7 @@ from bs4 import BeautifulSoup
import aqt
import aqt.sound
from anki.cards import Card
from anki.collection import SearchNode
from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter
from anki.httpclient import HttpClient
@ -781,7 +781,7 @@ class Editor:
filter = f"{tr(TR.EDITING_MEDIA)} ({extension_filter})"
def accept(file: str) -> None:
self.addMedia(file, canDelete=True)
self.addMedia(file)
file = getFile(
parent=self.widget,
@ -793,24 +793,18 @@ class Editor:
self.parentWindow.activateWindow()
def addMedia(self, path: str, canDelete: bool = False) -> None:
"""canDelete is a legacy arg and is ignored."""
try:
html = self._addMedia(path, canDelete)
html = self._addMedia(path)
except Exception as e:
showWarning(str(e))
return
self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});")
def _addMedia(self, path: str, canDelete: bool = False) -> str:
"Add to media folder and return local img or sound tag."
"""Add to media folder and return local img or sound tag."""
# copy to media folder
fname = self.mw.col.media.addFile(path)
# remove original?
if canDelete and self.mw.pm.profile["deleteMedia"]:
if os.path.abspath(fname) != os.path.abspath(path):
try:
os.unlink(path)
except:
pass
# return a local html link
return self.fnameToLink(fname)
@ -1091,7 +1085,6 @@ class EditorWebView(AnkiWebView):
def __init__(self, parent: QWidget, editor: Editor) -> None:
AnkiWebView.__init__(self, title="editor")
self.editor = editor
self.strip = self.editor.mw.pm.profile["stripHTML"]
self.setAcceptDrops(True)
self._markInternal = False
clip = self.editor.mw.app.clipboard()
@ -1110,10 +1103,12 @@ class EditorWebView(AnkiWebView):
self.triggerPageAction(QWebEnginePage.Copy)
def _wantsExtendedPaste(self) -> bool:
extended = not (self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier)
if self.editor.mw.pm.profile.get("pasteInvert", False):
extended = not extended
return extended
strip_html = self.editor.mw.col.get_config_bool(
Config.Bool.PASTE_STRIPS_FORMATTING
)
if self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier:
strip_html = not strip_html
return strip_html
def _onPaste(self, mode: QClipboard.Mode) -> None:
extended = self._wantsExtendedPaste()
@ -1240,7 +1235,7 @@ class EditorWebView(AnkiWebView):
return None
im = QImage(mime.imageData())
uname = namedtmp("paste")
if self.editor.mw.pm.profile.get("pastePNG", False):
if self.editor.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG):
ext = ".png"
im.save(uname + ext, None, 50)
else:

View File

@ -80,7 +80,7 @@
</widget>
</item>
<item>
<widget class="QCheckBox" name="pasteInvert">
<widget class="QCheckBox" name="paste_strips_formatting">
<property name="text">
<string>PREFERENCES_PASTE_WITHOUT_SHIFT_KEY_STRIPS_FORMATTING</string>
</property>
@ -589,7 +589,7 @@
<tabstop>showPlayButtons</tabstop>
<tabstop>interrupt_audio</tabstop>
<tabstop>pastePNG</tabstop>
<tabstop>pasteInvert</tabstop>
<tabstop>paste_strips_formatting</tabstop>
<tabstop>nightMode</tabstop>
<tabstop>useCurrent</tabstop>
<tabstop>recording_driver</tabstop>

View File

@ -27,7 +27,7 @@ import aqt.toolbar
import aqt.webview
from anki import hooks
from anki._backend import RustBackend as _RustBackend
from anki.collection import BackendUndo, Checkpoint, Collection, ReviewUndo
from anki.collection import BackendUndo, Checkpoint, Collection, Config, ReviewUndo
from anki.decks import Deck
from anki.hooks import runHook
from anki.sound import AVTag, SoundOrVideoTag
@ -391,8 +391,6 @@ class AnkiQt(QMainWindow):
if not self.loadCollection():
return
self.pm.apply_profile_options()
# show main window
if self.pm.profile["mainWindowState"]:
restoreGeom(self, "mainWindow")
@ -467,10 +465,10 @@ class AnkiQt(QMainWindow):
def _add_play_buttons(self, text: str) -> str:
"Return card text with play buttons added, or stripped."
if self.pm.profile.get("showPlayButtons", True):
return aqt.sound.av_refs_to_play_icons(text)
else:
if self.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS):
return anki.sound.strip_av_refs(text)
else:
return aqt.sound.av_refs_to_play_icons(text)
def prepare_card_text_for_display(self, text: str) -> str:
text = self.col.media.escape_media_filenames(text)
@ -508,6 +506,7 @@ class AnkiQt(QMainWindow):
try:
self.update_undo_actions()
gui_hooks.collection_did_load(self.col)
self.apply_collection_options()
self.moveToState("deckBrowser")
except Exception as e:
# dump error to stderr so it gets picked up by errors.py
@ -572,6 +571,12 @@ class AnkiQt(QMainWindow):
self.col.reopen(after_full_sync=False)
self.col.close_for_full_sync()
def apply_collection_options(self) -> None:
"Setup audio after collection loaded."
aqt.sound.av_player.interrupt_current_audio = self.col.get_config_bool(
Config.Bool.INTERRUPT_AUDIO_WHEN_ANSWERING
)
# Backup and auto-optimize
##########################################################################

View File

@ -1,7 +1,9 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import anki.lang
import aqt
from anki.consts import newCardSchedulingLabels
from aqt import AnkiQt
from aqt.profiles import RecordingDriver, VideoDriver
from aqt.qt import *
@ -16,21 +18,6 @@ from aqt.utils import (
)
def video_driver_name_for_platform(driver: VideoDriver) -> str:
if driver == VideoDriver.ANGLE:
return tr(TR.PREFERENCES_VIDEO_DRIVER_ANGLE)
elif driver == VideoDriver.Software:
if isMac:
return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_MAC)
else:
return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_OTHER)
else:
if isMac:
return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_MAC)
else:
return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_OTHER)
class Preferences(QDialog):
def __init__(self, mw: AnkiQt) -> None:
QDialog.__init__(self, mw, Qt.Window)
@ -45,22 +32,18 @@ class Preferences(QDialog):
self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)
)
self.silentlyClose = True
self.prefs = self.mw.col.get_preferences()
self.setupLang()
self.setupCollection()
self.setupNetwork()
self.setupBackup()
self.setupOptions()
self.setup_collection()
self.setup_profile()
self.setup_global()
self.show()
def accept(self) -> None:
# avoid exception if main window is already closed
if not self.mw.col:
return
self.updateCollection()
self.updateNetwork()
self.updateBackup()
self.updateOptions()
self.update_collection()
self.update_profile()
self.update_global()
self.mw.pm.save()
self.mw.reset()
self.done(0)
@ -69,16 +52,214 @@ class Preferences(QDialog):
def reject(self) -> None:
self.accept()
# Language
# Preferences stored in the collection
######################################################################
def setupLang(self) -> None:
def setup_collection(self) -> None:
self.prefs = self.mw.col.get_preferences()
form = self.form
scheduling = self.prefs.scheduling
form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0))
form.newSpread.addItems(list(newCardSchedulingLabels(self.mw.col).values()))
form.newSpread.setCurrentIndex(scheduling.new_review_mix)
form.dayLearnFirst.setChecked(scheduling.day_learn_first)
form.dayOffset.setValue(scheduling.rollover)
if scheduling.scheduler_version < 2:
form.dayLearnFirst.setVisible(False)
form.legacy_timezone.setVisible(False)
else:
form.legacy_timezone.setChecked(not scheduling.new_timezone)
reviewing = self.prefs.reviewing
form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0))
form.showEstimates.setChecked(reviewing.show_intervals_on_buttons)
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
editing = self.prefs.editing
form.useCurrent.setCurrentIndex(
0 if editing.adding_defaults_to_current_deck else 1
)
form.paste_strips_formatting.setChecked(editing.paste_strips_formatting)
form.pastePNG.setChecked(editing.paste_images_as_png)
def update_collection(self) -> None:
form = self.form
scheduling = self.prefs.scheduling
scheduling.new_review_mix = form.newSpread.currentIndex()
scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60
scheduling.day_learn_first = form.dayLearnFirst.isChecked()
scheduling.rollover = form.dayOffset.value()
scheduling.new_timezone = not form.legacy_timezone.isChecked()
reviewing = self.prefs.reviewing
reviewing.show_remaining_due_counts = form.showProgress.isChecked()
reviewing.show_intervals_on_buttons = form.showEstimates.isChecked()
reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
editing.paste_images_as_png = self.form.pastePNG.isChecked()
editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked()
self.mw.col.set_preferences(self.prefs)
self.mw.apply_collection_options()
# Preferences stored in the profile
######################################################################
def setup_profile(self) -> None:
"Setup options stored in the user profile."
self.setup_recording_driver()
self.setup_network()
self.setup_backup()
def update_profile(self) -> None:
self.update_recording_driver()
self.update_network()
self.update_backup()
# Profile: recording driver
######################################################################
def setup_recording_driver(self) -> None:
self._recording_drivers = [
RecordingDriver.QtAudioInput,
RecordingDriver.PyAudio,
]
# The plan is to phase out PyAudio soon, so will hold off on
# making this string translatable for now.
self.form.recording_driver.addItems(
[
f"Voice recording driver: {driver.value}"
for driver in self._recording_drivers
]
)
self.form.recording_driver.setCurrentIndex(
self._recording_drivers.index(self.mw.pm.recording_driver())
)
def update_recording_driver(self) -> None:
new_audio_driver = self._recording_drivers[
self.form.recording_driver.currentIndex()
]
if self.mw.pm.recording_driver() != new_audio_driver:
self.mw.pm.set_recording_driver(new_audio_driver)
if new_audio_driver == RecordingDriver.PyAudio:
showInfo(
"""\
The PyAudio driver will likely be removed in a future update. If you find it works better \
for you than the default driver, please let us know on the Anki forums."""
)
# Profile: network
######################################################################
def setup_network(self) -> None:
self.form.media_log.setText(tr(TR.SYNC_MEDIA_LOG_BUTTON))
qconnect(self.form.media_log.clicked, self.on_media_log)
self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"])
self.form.syncMedia.setChecked(self.prof["syncMedia"])
self.form.autoSyncMedia.setChecked(self.mw.pm.auto_sync_media_minutes() != 0)
if not self.prof["syncKey"]:
self._hide_sync_auth_settings()
else:
self.form.syncUser.setText(self.prof.get("syncUser", ""))
qconnect(self.form.syncDeauth.clicked, self.sync_logout)
self.form.syncDeauth.setText(tr(TR.SYNC_LOG_OUT_BUTTON))
def on_media_log(self) -> None:
self.mw.media_syncer.show_sync_log()
def _hide_sync_auth_settings(self) -> None:
self.form.syncDeauth.setVisible(False)
self.form.syncUser.setText("")
self.form.syncLabel.setText(
tr(TR.PREFERENCES_SYNCHRONIZATIONNOT_CURRENTLY_ENABLED_CLICK_THE_SYNC)
)
def sync_logout(self) -> None:
if self.mw.media_syncer.is_syncing():
showWarning("Can't log out while sync in progress.")
return
self.prof["syncKey"] = None
self.mw.col.media.force_resync()
self._hide_sync_auth_settings()
def update_network(self) -> None:
self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked()
self.prof["syncMedia"] = self.form.syncMedia.isChecked()
self.mw.pm.set_auto_sync_media_minutes(
self.form.autoSyncMedia.isChecked() and 15 or 0
)
if self.form.fullSync.isChecked():
self.mw.col.modSchema(check=False)
# Profile: backup
######################################################################
def setup_backup(self) -> None:
self.form.numBackups.setValue(self.prof["numBackups"])
def update_backup(self) -> None:
self.prof["numBackups"] = self.form.numBackups.value()
# Global preferences
######################################################################
def setup_global(self) -> None:
"Setup options global to all profiles."
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
self.form.nightMode.setChecked(self.mw.pm.night_mode())
self.setup_language()
self.setup_video_driver()
self.setupOptions()
def update_global(self) -> None:
restart_required = False
self.update_video_driver()
newScale = self.form.uiScale.value() / 100
if newScale != self.mw.pm.uiScale():
self.mw.pm.setUiScale(newScale)
restart_required = True
if self.mw.pm.night_mode() != self.form.nightMode.isChecked():
self.mw.pm.set_night_mode(not self.mw.pm.night_mode())
restart_required = True
if restart_required:
showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU))
self.updateOptions()
# legacy - one of Henrik's add-ons is currently wrapping them
def setupOptions(self) -> None:
pass
def updateOptions(self) -> None:
pass
# Global: language
######################################################################
def setup_language(self) -> None:
f = self.form
f.lang.addItems([x[0] for x in anki.lang.langs])
f.lang.setCurrentIndex(self.langIdx())
qconnect(f.lang.currentIndexChanged, self.onLangIdxChanged)
f.lang.setCurrentIndex(self.current_lang_index())
qconnect(f.lang.currentIndexChanged, self.on_language_index_changed)
def langIdx(self) -> int:
def current_lang_index(self) -> int:
codes = [x[1] for x in anki.lang.langs]
lang = anki.lang.currentLang
if lang in anki.lang.compatMap:
@ -90,43 +271,16 @@ class Preferences(QDialog):
except:
return codes.index("en_US")
def onLangIdxChanged(self, idx: int) -> None:
def on_language_index_changed(self, idx: int) -> None:
code = anki.lang.langs[idx][1]
self.mw.pm.setLang(code)
showInfo(
tr(TR.PREFERENCES_PLEASE_RESTART_ANKI_TO_COMPLETE_LANGUAGE), parent=self
)
# Collection options
# Global: video driver
######################################################################
def setupCollection(self) -> None:
import anki.consts as c
f = self.form
qc = self.mw.col.conf
self.setup_video_driver()
f.newSpread.addItems(list(c.newCardSchedulingLabels(self.mw.col).values()))
f.useCurrent.setCurrentIndex(int(not qc.get("addToCur", True)))
s = self.prefs.sched
f.lrnCutoff.setValue(int(s.learn_ahead_secs / 60.0))
f.timeLimit.setValue(int(s.time_limit_secs / 60.0))
f.showEstimates.setChecked(s.show_intervals_on_buttons)
f.showProgress.setChecked(s.show_remaining_due_counts)
f.newSpread.setCurrentIndex(s.new_review_mix)
f.dayLearnFirst.setChecked(s.day_learn_first)
f.dayOffset.setValue(s.rollover)
if s.scheduler_version < 2:
f.dayLearnFirst.setVisible(False)
f.legacy_timezone.setVisible(False)
else:
f.legacy_timezone.setChecked(not s.new_timezone)
def setup_video_driver(self) -> None:
self.video_drivers = VideoDriver.all_for_platform()
names = [
@ -144,133 +298,17 @@ class Preferences(QDialog):
self.mw.pm.set_video_driver(new_driver)
showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU))
def updateCollection(self) -> None:
f = self.form
d = self.mw.col
self.update_video_driver()
qc = d.conf
qc["addToCur"] = not f.useCurrent.currentIndex()
s = self.prefs.sched
s.show_remaining_due_counts = f.showProgress.isChecked()
s.show_intervals_on_buttons = f.showEstimates.isChecked()
s.new_review_mix = f.newSpread.currentIndex()
s.time_limit_secs = f.timeLimit.value() * 60
s.learn_ahead_secs = f.lrnCutoff.value() * 60
s.day_learn_first = f.dayLearnFirst.isChecked()
s.rollover = f.dayOffset.value()
s.new_timezone = not f.legacy_timezone.isChecked()
self.mw.col.set_preferences(self.prefs)
# Network
######################################################################
def setupNetwork(self) -> None:
self.form.media_log.setText(tr(TR.SYNC_MEDIA_LOG_BUTTON))
qconnect(self.form.media_log.clicked, self.on_media_log)
self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"])
self.form.syncMedia.setChecked(self.prof["syncMedia"])
self.form.autoSyncMedia.setChecked(self.mw.pm.auto_sync_media_minutes() != 0)
if not self.prof["syncKey"]:
self._hideAuth()
def video_driver_name_for_platform(driver: VideoDriver) -> str:
if driver == VideoDriver.ANGLE:
return tr(TR.PREFERENCES_VIDEO_DRIVER_ANGLE)
elif driver == VideoDriver.Software:
if isMac:
return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_MAC)
else:
self.form.syncUser.setText(self.prof.get("syncUser", ""))
qconnect(self.form.syncDeauth.clicked, self.onSyncDeauth)
self.form.syncDeauth.setText(tr(TR.SYNC_LOG_OUT_BUTTON))
def on_media_log(self) -> None:
self.mw.media_syncer.show_sync_log()
def _hideAuth(self) -> None:
self.form.syncDeauth.setVisible(False)
self.form.syncUser.setText("")
self.form.syncLabel.setText(
tr(TR.PREFERENCES_SYNCHRONIZATIONNOT_CURRENTLY_ENABLED_CLICK_THE_SYNC)
)
def onSyncDeauth(self) -> None:
if self.mw.media_syncer.is_syncing():
showWarning("Can't log out while sync in progress.")
return
self.prof["syncKey"] = None
self.mw.col.media.force_resync()
self._hideAuth()
def updateNetwork(self) -> None:
self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked()
self.prof["syncMedia"] = self.form.syncMedia.isChecked()
self.mw.pm.set_auto_sync_media_minutes(
self.form.autoSyncMedia.isChecked() and 15 or 0
)
if self.form.fullSync.isChecked():
self.mw.col.modSchema(check=False)
# Backup
######################################################################
def setupBackup(self) -> None:
self.form.numBackups.setValue(self.prof["numBackups"])
def updateBackup(self) -> None:
self.prof["numBackups"] = self.form.numBackups.value()
# Basic & Advanced Options
######################################################################
def setupOptions(self) -> None:
self.form.pastePNG.setChecked(self.prof.get("pastePNG", False))
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
self.form.pasteInvert.setChecked(self.prof.get("pasteInvert", False))
self.form.showPlayButtons.setChecked(self.prof.get("showPlayButtons", True))
self.form.nightMode.setChecked(self.mw.pm.night_mode())
self.form.interrupt_audio.setChecked(self.mw.pm.interrupt_audio())
self._recording_drivers = [
RecordingDriver.QtAudioInput,
RecordingDriver.PyAudio,
]
# The plan is to phase out PyAudio soon, so will hold off on
# making this string translatable for now.
self.form.recording_driver.addItems(
[
f"Voice recording driver: {driver.value}"
for driver in self._recording_drivers
]
)
self.form.recording_driver.setCurrentIndex(
self._recording_drivers.index(self.mw.pm.recording_driver())
)
def updateOptions(self) -> None:
restart_required = False
self.prof["pastePNG"] = self.form.pastePNG.isChecked()
self.prof["pasteInvert"] = self.form.pasteInvert.isChecked()
newScale = self.form.uiScale.value() / 100
if newScale != self.mw.pm.uiScale():
self.mw.pm.setUiScale(newScale)
restart_required = True
self.prof["showPlayButtons"] = self.form.showPlayButtons.isChecked()
if self.mw.pm.night_mode() != self.form.nightMode.isChecked():
self.mw.pm.set_night_mode(not self.mw.pm.night_mode())
restart_required = True
self.mw.pm.set_interrupt_audio(self.form.interrupt_audio.isChecked())
new_audio_driver = self._recording_drivers[
self.form.recording_driver.currentIndex()
]
if self.mw.pm.recording_driver() != new_audio_driver:
self.mw.pm.set_recording_driver(new_audio_driver)
if new_audio_driver == RecordingDriver.PyAudio:
showInfo(
"""\
The PyAudio driver will likely be removed in a future update. If you find it works better \
for you than the default driver, please let us know on the Anki forums."""
)
if restart_required:
showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU))
return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_OTHER)
else:
if isMac:
return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_MAC)
else:
return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_OTHER)

View File

@ -89,14 +89,8 @@ profileConf: Dict[str, Any] = dict(
numBackups=50,
lastOptimize=intTime(),
# editing
fullSearch=False,
searchHistory=[],
lastColour="#00f",
stripHTML=True,
pastePNG=False,
# not exposed in gui
deleteMedia=False,
preserveKeyboard=True,
# syncing
syncKey=None,
syncMedia=True,
@ -104,6 +98,10 @@ profileConf: Dict[str, Any] = dict(
# importing
allowHTML=False,
importMode=1,
# these are not used, but Anki 2.1.42 and below
# expect these keys to exist
stripHTML=True,
deleteMedia=False,
)
@ -617,13 +615,6 @@ create table if not exists profiles
# Profile-specific
######################################################################
def interrupt_audio(self) -> bool:
return self.profile.get("interrupt_audio", True)
def set_interrupt_audio(self, val: bool) -> None:
self.profile["interrupt_audio"] = val
aqt.sound.av_player.interrupt_current_audio = val
def set_sync_key(self, val: Optional[str]) -> None:
self.profile["syncKey"] = val
@ -667,8 +658,3 @@ create table if not exists profiles
def set_recording_driver(self, driver: RecordingDriver) -> None:
self.profile["recordingDriver"] = driver.value
######################################################################
def apply_profile_options(self) -> None:
aqt.sound.av_player.interrupt_current_audio = self.interrupt_audio()

View File

@ -593,9 +593,9 @@ class SidebarTreeView(QTreeView):
return
self.refresh()
self.mw.deckBrowser.refresh()
self.mw.update_undo_actions()
def on_save() -> None:
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK))
self.browser.model.beginReset()
self.mw.taskman.with_progress(
lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done
@ -1130,7 +1130,6 @@ class SidebarTreeView(QTreeView):
def rename_deck(self, item: SidebarItem, new_name: str) -> None:
deck = self.mw.col.decks.get(item.id)
new_name = item.name_prefix + new_name
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK))
try:
self.mw.col.decks.rename(deck, new_name)
except DeckIsFilteredError as err:
@ -1141,6 +1140,7 @@ class SidebarTreeView(QTreeView):
and other.id == item.id
)
self.mw.deckBrowser.refresh()
self.mw.update_undo_actions()
def remove_tags(self, item: SidebarItem) -> None:
self.browser.editor.saveNow(lambda: self._remove_tags(item))
@ -1210,7 +1210,6 @@ class SidebarTreeView(QTreeView):
self.refresh()
dids = self._selected_decks()
self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK))
self.browser.model.beginReset()
self.mw.taskman.with_progress(do_delete, on_done)

View File

@ -76,33 +76,31 @@ message DeckConfigID {
int64 dcid = 1;
}
// New style RPC definitions
// Backend methods
///////////////////////////////////////////////////////////
service BackendService {
rpc LatestProgress(Empty) returns (Progress);
rpc SetWantsAbort(Empty) returns (Empty);
// card rendering
rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut);
rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut);
rpc GetEmptyCards(Empty) returns (EmptyCardsReport);
rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut);
rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut);
rpc StripAVTags(String) returns (String);
// searching
rpc BuildSearchString(SearchNode) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
// scheduling
/// while the protobuf descriptors expose the order services are defined in,
/// that information is not available in prost, so we define an enum to make
/// sure all clients agree on the service index
enum ServiceIndex {
SERVICE_INDEX_SCHEDULING = 0;
SERVICE_INDEX_DECKS = 1;
SERVICE_INDEX_NOTES = 2;
SERVICE_INDEX_SYNC = 3;
SERVICE_INDEX_NOTE_TYPES = 4;
SERVICE_INDEX_CONFIG = 5;
SERVICE_INDEX_CARD_RENDERING = 6;
SERVICE_INDEX_DECK_CONFIG = 7;
SERVICE_INDEX_TAGS = 8;
SERVICE_INDEX_SEARCH = 9;
SERVICE_INDEX_STATS = 10;
SERVICE_INDEX_MEDIA = 11;
SERVICE_INDEX_I18N = 12;
SERVICE_INDEX_COLLECTION = 13;
SERVICE_INDEX_CARDS = 14;
}
service SchedulingService {
rpc SchedTimingToday(Empty) returns (SchedTimingTodayOut);
rpc StudiedToday(Empty) returns (String);
rpc StudiedTodayMessage(StudiedTodayMessageIn) returns (String);
@ -125,24 +123,9 @@ service BackendService {
rpc AnswerCard(AnswerCardIn) returns (Empty);
rpc UpgradeScheduler(Empty) returns (Empty);
rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut);
}
// stats
rpc CardStats(CardID) returns (String);
rpc Graphs(GraphsIn) returns (GraphsOut);
rpc GetGraphPreferences(Empty) returns (GraphPreferences);
rpc SetGraphPreferences(GraphPreferences) returns (Empty);
// media
rpc CheckMedia(Empty) returns (CheckMediaOut);
rpc TrashMediaFiles(TrashMediaFilesIn) returns (Empty);
rpc AddMediaFile(AddMediaFileIn) returns (String);
rpc EmptyTrash(Empty) returns (Empty);
rpc RestoreTrash(Empty) returns (Empty);
// decks
service DecksService {
rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyIn) returns (DeckID);
rpc DeckTree(DeckTreeIn) returns (DeckTreeNode);
rpc DeckTreeLegacy(Empty) returns (Json);
@ -153,25 +136,10 @@ service BackendService {
rpc NewDeckLegacy(Bool) returns (Json);
rpc RemoveDecks(DeckIDs) returns (UInt32);
rpc DragDropDecks(DragDropDecksIn) returns (Empty);
rpc RenameDeck(RenameDeckIn) returns (Empty);
}
// deck config
rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn)
returns (DeckConfigID);
rpc AllDeckConfigLegacy(Empty) returns (Json);
rpc GetDeckConfigLegacy(DeckConfigID) returns (Json);
rpc NewDeckConfigLegacy(Empty) returns (Json);
rpc RemoveDeckConfig(DeckConfigID) returns (Empty);
// cards
rpc GetCard(CardID) returns (Card);
rpc UpdateCard(UpdateCardIn) returns (Empty);
rpc RemoveCards(RemoveCardsIn) returns (Empty);
rpc SetDeck(SetDeckIn) returns (Empty);
// notes
service NotesService {
rpc NewNote(NoteTypeID) returns (Note);
rpc AddNote(AddNoteIn) returns (NoteID);
rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype);
@ -186,28 +154,9 @@ service BackendService {
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote(NoteID) returns (CardIDs);
}
// note types
rpc AddOrUpdateNotetype(AddOrUpdateNotetypeIn) returns (NoteTypeID);
rpc GetStockNotetypeLegacy(StockNoteType) returns (Json);
rpc GetNotetypeLegacy(NoteTypeID) returns (Json);
rpc GetNotetypeNames(Empty) returns (NoteTypeNames);
rpc GetNotetypeNamesAndCounts(Empty) returns (NoteTypeUseCounts);
rpc GetNotetypeIDByName(String) returns (NoteTypeID);
rpc RemoveNotetype(NoteTypeID) returns (Empty);
// collection
rpc OpenCollection(OpenCollectionIn) returns (Empty);
rpc CloseCollection(CloseCollectionIn) returns (Empty);
rpc CheckDatabase(Empty) returns (CheckDatabaseOut);
rpc GetUndoStatus(Empty) returns (UndoStatus);
rpc Undo(Empty) returns (UndoStatus);
rpc Redo(Empty) returns (UndoStatus);
// sync
service SyncService {
rpc SyncMedia(SyncAuth) returns (Empty);
rpc AbortSync(Empty) returns (Empty);
rpc AbortMediaSync(Empty) returns (Empty);
@ -218,26 +167,9 @@ service BackendService {
rpc FullUpload(SyncAuth) returns (Empty);
rpc FullDownload(SyncAuth) returns (Empty);
rpc SyncServerMethod(SyncServerMethodIn) returns (Json);
}
// translation/messages/text manipulation
rpc TranslateString(TranslateStringIn) returns (String);
rpc FormatTimespan(FormatTimespanIn) returns (String);
rpc I18nResources(Empty) returns (Json);
rpc RenderMarkdown(RenderMarkdownIn) returns (String);
// tags
rpc ClearUnusedTags(Empty) returns (Empty);
rpc AllTags(Empty) returns (StringList);
rpc ExpungeTags(String) returns (UInt32);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode);
rpc DragDropTags(DragDropTagsIn) returns (Empty);
// config
service ConfigService {
rpc GetConfigJson(String) returns (Json);
rpc SetConfigJson(SetConfigJsonIn) returns (Empty);
rpc RemoveConfig(String) returns (Empty);
@ -246,13 +178,97 @@ service BackendService {
rpc SetConfigBool(SetConfigBoolIn) returns (Empty);
rpc GetConfigString(Config.String) returns (String);
rpc SetConfigString(SetConfigStringIn) returns (Empty);
// preferences
rpc GetPreferences(Empty) returns (Preferences);
rpc SetPreferences(Preferences) returns (Empty);
}
service NoteTypesService {
rpc AddOrUpdateNotetype(AddOrUpdateNotetypeIn) returns (NoteTypeID);
rpc GetStockNotetypeLegacy(StockNoteType) returns (Json);
rpc GetNotetypeLegacy(NoteTypeID) returns (Json);
rpc GetNotetypeNames(Empty) returns (NoteTypeNames);
rpc GetNotetypeNamesAndCounts(Empty) returns (NoteTypeUseCounts);
rpc GetNotetypeIDByName(String) returns (NoteTypeID);
rpc RemoveNotetype(NoteTypeID) returns (Empty);
}
service CardRenderingService {
rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut);
rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut);
rpc GetEmptyCards(Empty) returns (EmptyCardsReport);
rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut);
rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut);
rpc StripAVTags(String) returns (String);
rpc RenderMarkdown(RenderMarkdownIn) returns (String);
}
service DeckConfigService {
rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn)
returns (DeckConfigID);
rpc AllDeckConfigLegacy(Empty) returns (Json);
rpc GetDeckConfigLegacy(DeckConfigID) returns (Json);
rpc NewDeckConfigLegacy(Empty) returns (Json);
rpc RemoveDeckConfig(DeckConfigID) returns (Empty);
}
service TagsService {
rpc ClearUnusedTags(Empty) returns (Empty);
rpc AllTags(Empty) returns (StringList);
rpc ExpungeTags(String) returns (UInt32);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode);
rpc DragDropTags(DragDropTagsIn) returns (Empty);
}
service SearchService {
rpc BuildSearchString(SearchNode) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
}
service StatsService {
rpc CardStats(CardID) returns (String);
rpc Graphs(GraphsIn) returns (GraphsOut);
rpc GetGraphPreferences(Empty) returns (GraphPreferences);
rpc SetGraphPreferences(GraphPreferences) returns (Empty);
}
service MediaService {
rpc CheckMedia(Empty) returns (CheckMediaOut);
rpc TrashMediaFiles(TrashMediaFilesIn) returns (Empty);
rpc AddMediaFile(AddMediaFileIn) returns (String);
rpc EmptyTrash(Empty) returns (Empty);
rpc RestoreTrash(Empty) returns (Empty);
}
service I18nService {
rpc TranslateString(TranslateStringIn) returns (String);
rpc FormatTimespan(FormatTimespanIn) returns (String);
rpc I18nResources(Empty) returns (Json);
}
service CollectionService {
rpc OpenCollection(OpenCollectionIn) returns (Empty);
rpc CloseCollection(CloseCollectionIn) returns (Empty);
rpc CheckDatabase(Empty) returns (CheckDatabaseOut);
rpc GetUndoStatus(Empty) returns (UndoStatus);
rpc Undo(Empty) returns (UndoStatus);
rpc Redo(Empty) returns (UndoStatus);
rpc LatestProgress(Empty) returns (Progress);
rpc SetWantsAbort(Empty) returns (Empty);
}
service CardsService {
rpc GetCard(CardID) returns (Card);
rpc UpdateCard(UpdateCardIn) returns (Empty);
rpc RemoveCards(RemoveCardsIn) returns (Empty);
rpc SetDeck(SetDeckIn) returns (Empty);
}
// Protobuf stored in .anki2 files
// These should be moved to a separate file in the future
///////////////////////////////////////////////////////////
@ -1027,7 +1043,8 @@ message CheckDatabaseOut {
repeated string problems = 1;
}
message CollectionSchedulingSettings {
message Preferences {
message Scheduling {
enum NewReviewMix {
DISTRIBUTE = 0;
REVIEWS_FIRST = 1;
@ -1040,17 +1057,27 @@ message CollectionSchedulingSettings {
uint32 rollover = 2;
uint32 learn_ahead_secs = 3;
NewReviewMix new_review_mix = 4;
bool show_remaining_due_counts = 5;
bool show_intervals_on_buttons = 6;
uint32 time_limit_secs = 7;
// v2 only
bool new_timezone = 8;
bool day_learn_first = 9;
bool new_timezone = 5;
bool day_learn_first = 6;
}
message Reviewing {
bool hide_audio_play_buttons = 1;
bool interrupt_audio_when_answering = 2;
bool show_remaining_due_counts = 3;
bool show_intervals_on_buttons = 4;
uint32 time_limit_secs = 5;
}
message Editing {
bool adding_defaults_to_current_deck = 1;
bool paste_images_as_png = 2;
bool paste_strips_formatting = 3;
}
message Preferences {
CollectionSchedulingSettings sched = 1;
Scheduling scheduling = 1;
Reviewing reviewing = 2;
Editing editing = 3;
}
message ClozeNumbersInNoteOut {
@ -1280,6 +1307,10 @@ message Config {
COLLAPSE_FLAGS = 8;
SCHED_2021 = 9;
ADDING_DEFAULTS_TO_CURRENT_DECK = 10;
HIDE_AUDIO_PLAY_BUTTONS = 11;
INTERRUPT_AUDIO_WHEN_ANSWERING = 12;
PASTE_IMAGES_AS_PNG = 13;
PASTE_STRIPS_FORMATTING = 14;
}
Key key = 1;
}
@ -1423,3 +1454,8 @@ message DeckAndNotetype {
int64 deck_id = 1;
int64 notetype_id = 2;
}
message RenameDeckIn {
int64 deck_id = 1;
string new_name = 2;
}

View File

@ -6,32 +6,14 @@ use std::{env, fmt::Write};
struct CustomGenerator {}
fn write_method_enum(buf: &mut String, service: &prost_build::Service) {
buf.push_str(
r#"
use num_enum::TryFromPrimitive;
#[derive(PartialEq,TryFromPrimitive)]
#[repr(u32)]
pub enum BackendMethod {
"#,
);
for (idx, method) in service.methods.iter().enumerate() {
writeln!(buf, " {} = {},", method.proto_name, idx + 1).unwrap();
}
buf.push_str("}\n\n");
}
fn write_method_trait(buf: &mut String, service: &prost_build::Service) {
buf.push_str(
r#"
use prost::Message;
pub type BackendResult<T> = std::result::Result<T, crate::err::AnkiError>;
pub trait BackendService {
fn run_command_bytes2_inner(&self, method: u32, input: &[u8]) -> std::result::Result<Vec<u8>, crate::err::AnkiError> {
pub trait Service {
fn run_method(&self, method: u32, input: &[u8]) -> Result<Vec<u8>> {
match method {
"#,
);
for (idx, method) in service.methods.iter().enumerate() {
write!(
buf,
@ -39,7 +21,7 @@ pub trait BackendService {
"{idx} => {{ let input = {input_type}::decode(input)?;\n",
"let output = self.{rust_method}(input)?;\n",
"let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; Ok(out_bytes) }}, "),
idx = idx + 1,
idx = idx,
input_type = method.input_type,
rust_method = method.name
)
@ -58,7 +40,7 @@ pub trait BackendService {
buf,
concat!(
" fn {method_name}(&self, input: {input_type}) -> ",
"BackendResult<{output_type}>;\n"
"Result<{output_type}>;\n"
),
method_name = method.name,
input_type = method.input_type,
@ -71,8 +53,18 @@ pub trait BackendService {
impl prost_build::ServiceGenerator for CustomGenerator {
fn generate(&mut self, service: prost_build::Service, buf: &mut String) {
write_method_enum(buf, &service);
write!(
buf,
"pub mod {name}_service {{
use super::*;
use prost::Message;
use crate::err::Result;
",
name = service.name.replace("Service", "").to_ascii_lowercase()
)
.unwrap();
write_method_trait(buf, &service);
buf.push('}');
}
}

View File

@ -1,13 +1,60 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::convert::TryFrom;
use std::convert::{TryFrom, TryInto};
use super::Backend;
use crate::prelude::*;
use crate::{
backend_proto as pb,
card::{CardQueue, CardType},
};
pub(super) use pb::cards_service::Service as CardsService;
impl CardsService for Backend {
fn get_card(&self, input: pb::CardId) -> Result<pb::Card> {
self.with_col(|col| {
col.storage
.get_card(input.into())
.and_then(|opt| opt.ok_or(AnkiError::NotFound))
.map(Into::into)
})
}
fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::Empty> {
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()?;
col.update_card_with_op(&mut card, op)
})
.map(Into::into)
}
fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
col.remove_cards_and_orphaned_notes(
&input
.card_ids
.into_iter()
.map(Into::into)
.collect::<Vec<_>>(),
)?;
Ok(().into())
})
})
}
fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::Empty> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let deck_id = input.deck_id.into();
self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into))
}
}
impl TryFrom<pb::Card> for Card {
type Error = AnkiError;
@ -39,3 +86,28 @@ impl TryFrom<pb::Card> for Card {
})
}
}
impl From<Card> for pb::Card {
fn from(c: Card) -> Self {
pb::Card {
id: c.id.0,
note_id: c.note_id.0,
deck_id: c.deck_id.0,
template_idx: c.template_idx as u32,
mtime_secs: c.mtime.0,
usn: c.usn.0,
ctype: c.ctype as u32,
queue: c.queue as i32,
due: c.due,
interval: c.interval,
ease_factor: c.ease_factor as u32,
reps: c.reps,
lapses: c.lapses,
remaining_steps: c.remaining_steps,
original_due: c.original_due,
original_deck_id: c.original_deck_id.0,
flags: c.flags as u32,
data: c.data,
}
}
}

View File

@ -0,0 +1,163 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto as pb,
latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex},
markdown::render_markdown,
notetype::{CardTemplateSchema11, RenderCardOutput},
prelude::*,
template::RenderedNode,
text::{extract_av_tags, sanitize_html_no_images, strip_av_tags, AVTag},
};
pub(super) use pb::cardrendering_service::Service as CardRenderingService;
impl CardRenderingService for Backend {
fn extract_av_tags(&self, input: pb::ExtractAvTagsIn) -> Result<pb::ExtractAvTagsOut> {
let (text, tags) = extract_av_tags(&input.text, input.question_side);
let pt_tags = tags
.into_iter()
.map(|avtag| match avtag {
AVTag::SoundOrVideo(file) => pb::AvTag {
value: Some(pb::av_tag::Value::SoundOrVideo(file)),
},
AVTag::TextToSpeech {
field_text,
lang,
voices,
other_args,
speed,
} => pb::AvTag {
value: Some(pb::av_tag::Value::Tts(pb::TtsTag {
field_text,
lang,
voices,
other_args,
speed,
})),
},
})
.collect();
Ok(pb::ExtractAvTagsOut {
text: text.into(),
av_tags: pt_tags,
})
}
fn extract_latex(&self, input: pb::ExtractLatexIn) -> Result<pb::ExtractLatexOut> {
let func = if input.expand_clozes {
extract_latex_expanding_clozes
} else {
extract_latex
};
let (text, extracted) = func(&input.text, input.svg);
Ok(pb::ExtractLatexOut {
text,
latex: extracted
.into_iter()
.map(|e: ExtractedLatex| pb::ExtractedLatex {
filename: e.fname,
latex_body: e.latex,
})
.collect(),
})
}
fn get_empty_cards(&self, _input: pb::Empty) -> Result<pb::EmptyCardsReport> {
self.with_col(|col| {
let mut empty = col.empty_cards()?;
let report = col.empty_cards_report(&mut empty)?;
let mut outnotes = vec![];
for (_ntid, notes) in empty {
outnotes.extend(notes.into_iter().map(|e| {
pb::empty_cards_report::NoteWithEmptyCards {
note_id: e.nid.0,
will_delete_note: e.empty.len() == e.current_count,
card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(),
}
}))
}
Ok(pb::EmptyCardsReport {
report,
notes: outnotes,
})
})
}
fn render_existing_card(&self, input: pb::RenderExistingCardIn) -> Result<pb::RenderCardOut> {
self.with_col(|col| {
col.render_existing_card(CardID(input.card_id), input.browser)
.map(Into::into)
})
}
fn render_uncommitted_card(
&self,
input: pb::RenderUncommittedCardIn,
) -> Result<pb::RenderCardOut> {
let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?;
let template = schema11.into();
let mut note = input
.note
.ok_or_else(|| AnkiError::invalid_input("missing note"))?
.into();
let ord = input.card_ord as u16;
let fill_empty = input.fill_empty;
self.with_col(|col| {
col.render_uncommitted_card(&mut note, &template, ord, fill_empty)
.map(Into::into)
})
}
fn strip_av_tags(&self, input: pb::String) -> Result<pb::String> {
Ok(pb::String {
val: strip_av_tags(&input.val).into(),
})
}
fn render_markdown(&self, input: pb::RenderMarkdownIn) -> Result<pb::String> {
let mut text = render_markdown(&input.markdown);
if input.sanitize {
// currently no images
text = sanitize_html_no_images(&text);
}
Ok(text.into())
}
}
fn rendered_nodes_to_proto(nodes: Vec<RenderedNode>) -> Vec<pb::RenderedTemplateNode> {
nodes
.into_iter()
.map(|n| pb::RenderedTemplateNode {
value: Some(rendered_node_to_proto(n)),
})
.collect()
}
fn rendered_node_to_proto(node: RenderedNode) -> pb::rendered_template_node::Value {
match node {
RenderedNode::Text { text } => pb::rendered_template_node::Value::Text(text),
RenderedNode::Replacement {
field_name,
current_text,
filters,
} => pb::rendered_template_node::Value::Replacement(pb::RenderedTemplateReplacement {
field_name,
current_text,
filters,
}),
}
}
impl From<RenderCardOutput> for pb::RenderCardOut {
fn from(o: RenderCardOutput) -> Self {
pb::RenderCardOut {
question_nodes: rendered_nodes_to_proto(o.qnodes),
answer_nodes: rendered_nodes_to_proto(o.anodes),
}
}
}

View File

@ -0,0 +1,104 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{progress::Progress, Backend};
use crate::{
backend::progress::progress_to_proto,
backend_proto as pb,
collection::open_collection,
log::{self, default_logger},
prelude::*,
};
pub(super) use pb::collection_service::Service as CollectionService;
use slog::error;
impl CollectionService for Backend {
fn latest_progress(&self, _input: pb::Empty) -> Result<pb::Progress> {
let progress = self.progress_state.lock().unwrap().last_progress;
Ok(progress_to_proto(progress, &self.i18n))
}
fn set_wants_abort(&self, _input: pb::Empty) -> Result<pb::Empty> {
self.progress_state.lock().unwrap().want_abort = true;
Ok(().into())
}
fn open_collection(&self, input: pb::OpenCollectionIn) -> Result<pb::Empty> {
let mut col = self.col.lock().unwrap();
if col.is_some() {
return Err(AnkiError::CollectionAlreadyOpen);
}
let mut path = input.collection_path.clone();
path.push_str(".log");
let log_path = match input.log_path.as_str() {
"" => None,
path => Some(path),
};
let logger = default_logger(log_path)?;
let new_col = open_collection(
input.collection_path,
input.media_folder_path,
input.media_db_path,
self.server,
self.i18n.clone(),
logger,
)?;
*col = Some(new_col);
Ok(().into())
}
fn close_collection(&self, input: pb::CloseCollectionIn) -> Result<pb::Empty> {
self.abort_media_sync_and_wait();
let mut col = self.col.lock().unwrap();
if col.is_none() {
return Err(AnkiError::CollectionNotOpen);
}
let col_inner = col.take().unwrap();
if input.downgrade_to_schema11 {
let log = log::terminal();
if let Err(e) = col_inner.close(input.downgrade_to_schema11) {
error!(log, " failed: {:?}", e);
}
}
Ok(().into())
}
fn check_database(&self, _input: pb::Empty) -> Result<pb::CheckDatabaseOut> {
let mut handler = self.new_progress_handler();
let progress_fn = move |progress, throttle| {
handler.update(Progress::DatabaseCheck(progress), throttle);
};
self.with_col(|col| {
col.check_database(progress_fn)
.map(|problems| pb::CheckDatabaseOut {
problems: problems.to_i18n_strings(&col.i18n),
})
})
}
fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| Ok(col.undo_status()))
}
fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| {
col.undo()?;
Ok(col.undo_status())
})
}
fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| {
col.redo()?;
Ok(col.undo_status())
})
}
}

View File

@ -1,12 +1,16 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto as pb,
config::{BoolKey, StringKey},
prelude::*,
};
use pb::config::bool::Key as BoolKeyProto;
use pb::config::string::Key as StringKeyProto;
pub(super) use pb::config_service::Service as ConfigService;
use serde_json::Value;
impl From<BoolKeyProto> for BoolKey {
fn from(k: BoolKeyProto) -> Self {
@ -22,6 +26,10 @@ impl From<BoolKeyProto> for BoolKey {
BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags,
BoolKeyProto::Sched2021 => BoolKey::Sched2021,
BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck,
BoolKeyProto::HideAudioPlayButtons => BoolKey::HideAudioPlayButtons,
BoolKeyProto::InterruptAudioWhenAnswering => BoolKey::InterruptAudioWhenAnswering,
BoolKeyProto::PasteImagesAsPng => BoolKey::PasteImagesAsPng,
BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting,
}
}
}
@ -34,3 +42,75 @@ impl From<StringKeyProto> for StringKey {
}
}
}
impl ConfigService for Backend {
fn get_config_json(&self, input: pb::String) -> Result<pb::Json> {
self.with_col(|col| {
let val: Option<Value> = col.get_config_optional(input.val.as_str());
val.ok_or(AnkiError::NotFound)
.and_then(|v| serde_json::to_vec(&v).map_err(Into::into))
.map(Into::into)
})
}
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
// ensure it's a well-formed object
let val: Value = serde_json::from_slice(&input.value_json)?;
col.set_config(input.key.as_str(), &val)
})
})
.map(Into::into)
}
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())))
.map(Into::into)
}
fn get_all_config(&self, _input: pb::Empty) -> Result<pb::Json> {
self.with_col(|col| {
let conf = col.storage.get_all_config()?;
serde_json::to_vec(&conf).map_err(Into::into)
})
.map(Into::into)
}
fn get_config_bool(&self, input: pb::config::Bool) -> Result<pb::Bool> {
self.with_col(|col| {
Ok(pb::Bool {
val: col.get_bool(input.key().into()),
})
})
}
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)))
.map(Into::into)
}
fn get_config_string(&self, input: pb::config::String) -> Result<pb::String> {
self.with_col(|col| {
Ok(pb::String {
val: col.get_string(input.key().into()),
})
})
}
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| col.set_string(input.key().into(), &input.value))
})
.map(Into::into)
}
fn get_preferences(&self, _input: pb::Empty) -> Result<pb::Preferences> {
self.with_col(|col| col.get_preferences())
}
fn set_preferences(&self, input: pb::Preferences) -> Result<pb::Empty> {
self.with_col(|col| col.set_preferences(input))
.map(Into::into)
}
}

View File

@ -0,0 +1,60 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto as pb,
deckconf::{DeckConf, DeckConfSchema11},
prelude::*,
};
pub(super) use pb::deckconfig_service::Service as DeckConfigService;
impl DeckConfigService for Backend {
fn add_or_update_deck_config_legacy(
&self,
input: pb::AddOrUpdateDeckConfigLegacyIn,
) -> Result<pb::DeckConfigId> {
let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?;
let mut conf: DeckConf = conf.into();
self.with_col(|col| {
col.transact(None, |col| {
col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?;
Ok(pb::DeckConfigId { dcid: conf.id.0 })
})
})
.map(Into::into)
}
fn all_deck_config_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
self.with_col(|col| {
let conf: Vec<DeckConfSchema11> = col
.storage
.all_deck_config()?
.into_iter()
.map(Into::into)
.collect();
serde_json::to_vec(&conf).map_err(Into::into)
})
.map(Into::into)
}
fn get_deck_config_legacy(&self, input: pb::DeckConfigId) -> Result<pb::Json> {
self.with_col(|col| {
let conf = col.get_deck_config(input.into(), true)?.unwrap();
let conf: DeckConfSchema11 = conf.into();
Ok(serde_json::to_vec(&conf)?)
})
.map(Into::into)
}
fn new_deck_config_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
serde_json::to_vec(&DeckConfSchema11::default())
.map_err(Into::into)
.map(Into::into)
}
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())))
.map(Into::into)
}
}

150
rslib/src/backend/decks.rs Normal file
View File

@ -0,0 +1,150 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto::{self as pb},
decks::{Deck, DeckID, DeckSchema11},
prelude::*,
};
pub(super) use pb::decks_service::Service as DecksService;
impl DecksService for Backend {
fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result<pb::DeckId> {
self.with_col(|col| {
let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?;
let mut deck: Deck = schema11.into();
if input.preserve_usn_and_mtime {
col.transact(None, |col| {
let usn = col.usn()?;
col.add_or_update_single_deck_with_existing_id(&mut deck, usn)
})?;
} else {
col.add_or_update_deck(&mut deck)?;
}
Ok(pb::DeckId { did: deck.id.0 })
})
}
fn deck_tree(&self, input: pb::DeckTreeIn) -> Result<pb::DeckTreeNode> {
let lim = if input.top_deck_id > 0 {
Some(DeckID(input.top_deck_id))
} else {
None
};
self.with_col(|col| {
let now = if input.now == 0 {
None
} else {
Some(TimestampSecs(input.now))
};
col.deck_tree(now, lim)
})
}
fn deck_tree_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
self.with_col(|col| {
let tree = col.legacy_deck_tree()?;
serde_json::to_vec(&tree)
.map_err(Into::into)
.map(Into::into)
})
}
fn get_all_decks_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
self.with_col(|col| {
let decks = col.storage.get_all_decks_as_schema11()?;
serde_json::to_vec(&decks).map_err(Into::into)
})
.map(Into::into)
}
fn get_deck_id_by_name(&self, input: pb::String) -> Result<pb::DeckId> {
self.with_col(|col| {
col.get_deck_id(&input.val).and_then(|d| {
d.ok_or(AnkiError::NotFound)
.map(|d| pb::DeckId { did: d.0 })
})
})
}
fn get_deck_legacy(&self, input: pb::DeckId) -> Result<pb::Json> {
self.with_col(|col| {
let deck: DeckSchema11 = col
.storage
.get_deck(input.into())?
.ok_or(AnkiError::NotFound)?
.into();
serde_json::to_vec(&deck)
.map_err(Into::into)
.map(Into::into)
})
}
fn get_deck_names(&self, input: pb::GetDeckNamesIn) -> Result<pb::DeckNames> {
self.with_col(|col| {
let names = if input.include_filtered {
col.get_all_deck_names(input.skip_empty_default)?
} else {
col.get_all_normal_deck_names()?
};
Ok(pb::DeckNames {
entries: names
.into_iter()
.map(|(id, name)| pb::DeckNameId { id: id.0, name })
.collect(),
})
})
}
fn new_deck_legacy(&self, input: pb::Bool) -> Result<pb::Json> {
let deck = if input.val {
Deck::new_filtered()
} else {
Deck::new_normal()
};
let schema11: DeckSchema11 = deck.into();
serde_json::to_vec(&schema11)
.map_err(Into::into)
.map(Into::into)
}
fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::UInt32> {
self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
.map(Into::into)
}
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::Empty> {
let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect();
let target_did = if input.target_deck_id == 0 {
None
} else {
Some(input.target_deck_id.into())
};
self.with_col(|col| col.drag_drop_decks(&source_dids, target_did))
.map(Into::into)
}
fn rename_deck(&self, input: pb::RenameDeckIn) -> Result<pb::Empty> {
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
.map(Into::into)
}
}
impl From<pb::DeckId> for DeckID {
fn from(did: pb::DeckId) -> Self {
DeckID(did.did)
}
}
impl From<pb::DeckIDs> for Vec<DeckID> {
fn from(dids: pb::DeckIDs) -> Self {
dids.dids.into_iter().map(DeckID).collect()
}
}
impl From<DeckID> for pb::DeckId {
fn from(did: DeckID) -> Self {
pb::DeckId { did: did.0 }
}
}

View File

@ -69,24 +69,6 @@ impl From<pb::NoteTypeId> for NoteTypeID {
}
}
impl From<pb::DeckId> for DeckID {
fn from(did: pb::DeckId) -> Self {
DeckID(did.did)
}
}
impl From<pb::DeckIDs> for Vec<DeckID> {
fn from(dids: pb::DeckIDs) -> Self {
dids.dids.into_iter().map(DeckID).collect()
}
}
impl From<DeckID> for pb::DeckId {
fn from(did: DeckID) -> Self {
pb::DeckId { did: did.0 }
}
}
impl From<pb::DeckConfigId> for DeckConfID {
fn from(dcid: pb::DeckConfigId) -> Self {
DeckConfID(dcid.dcid)

55
rslib/src/backend/i18n.rs Normal file
View File

@ -0,0 +1,55 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto as pb,
prelude::*,
scheduler::timespan::{answer_button_time, time_span},
};
use fluent::FluentValue;
pub(super) use pb::i18n_service::Service as I18nService;
impl I18nService for Backend {
fn translate_string(&self, input: pb::TranslateStringIn) -> Result<pb::String> {
let key = match crate::fluent_proto::FluentString::from_i32(input.key) {
Some(key) => key,
None => return Ok("invalid key".to_string().into()),
};
let map = input
.args
.iter()
.map(|(k, v)| (k.as_str(), translate_arg_to_fluent_val(&v)))
.collect();
Ok(self.i18n.trn(key, map).into())
}
fn format_timespan(&self, input: pb::FormatTimespanIn) -> Result<pb::String> {
use pb::format_timespan_in::Context;
Ok(match input.context() {
Context::Precise => time_span(input.seconds, &self.i18n, true),
Context::Intervals => time_span(input.seconds, &self.i18n, false),
Context::AnswerButtons => answer_button_time(input.seconds, &self.i18n),
}
.into())
}
fn i18n_resources(&self, _input: pb::Empty) -> Result<pb::Json> {
serde_json::to_vec(&self.i18n.resources_for_js())
.map(Into::into)
.map_err(Into::into)
}
}
fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue {
use pb::translate_arg_value::Value as V;
match &arg.value {
Some(val) => match val {
V::Str(s) => FluentValue::String(s.into()),
V::Number(f) => FluentValue::Number(f.into()),
},
None => FluentValue::String("".into()),
}
}

View File

@ -0,0 +1,89 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{progress::Progress, Backend};
use crate::{
backend_proto as pb,
media::{check::MediaChecker, MediaManager},
prelude::*,
};
pub(super) use pb::media_service::Service as MediaService;
impl MediaService for Backend {
// media
//-----------------------------------------------
fn check_media(&self, _input: pb::Empty) -> Result<pb::CheckMediaOut> {
let mut handler = self.new_progress_handler();
let progress_fn =
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
let mut output = checker.check()?;
let report = checker.summarize_output(&mut output);
Ok(pb::CheckMediaOut {
unused: output.unused,
missing: output.missing,
report,
have_trash: output.trash_count > 0,
})
})
})
}
fn trash_media_files(&self, input: pb::TrashMediaFilesIn) -> Result<pb::Empty> {
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
let mut ctx = mgr.dbctx();
mgr.remove_files(&mut ctx, &input.fnames)
})
.map(Into::into)
}
fn add_media_file(&self, input: pb::AddMediaFileIn) -> Result<pb::String> {
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
let mut ctx = mgr.dbctx();
Ok(mgr
.add_file(&mut ctx, &input.desired_name, &input.data)?
.to_string()
.into())
})
}
fn empty_trash(&self, _input: pb::Empty) -> Result<pb::Empty> {
let mut handler = self.new_progress_handler();
let progress_fn =
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.empty_trash()
})
})
.map(Into::into)
}
fn restore_trash(&self, _input: pb::Empty) -> Result<pb::Empty> {
let mut handler = self.new_progress_handler();
let progress_fn =
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
col.transact(None, |ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.restore_trash()
})
})
.map(Into::into)
}
}

File diff suppressed because it is too large Load Diff

172
rslib/src/backend/notes.rs Normal file
View File

@ -0,0 +1,172 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::HashSet;
use super::Backend;
use crate::{
backend_proto::{self as pb},
cloze::add_cloze_numbers_in_string,
prelude::*,
};
pub(super) use pb::notes_service::Service as NotesService;
impl NotesService for Backend {
// notes
//-------------------------------------------------------------------
fn new_note(&self, input: pb::NoteTypeId) -> Result<pb::Note> {
self.with_col(|col| {
let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?;
Ok(nt.new_note().into())
})
}
fn add_note(&self, input: pb::AddNoteIn) -> Result<pb::NoteId> {
self.with_col(|col| {
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
col.add_note(&mut note, DeckID(input.deck_id))
.map(|_| pb::NoteId { nid: note.id.0 })
})
}
fn defaults_for_adding(&self, input: pb::DefaultsForAddingIn) -> Result<pb::DeckAndNotetype> {
self.with_col(|col| {
let home_deck: DeckID = input.home_deck_of_current_review_card.into();
col.defaults_for_adding(home_deck).map(Into::into)
})
}
fn default_deck_for_notetype(&self, input: pb::NoteTypeId) -> Result<pb::DeckId> {
self.with_col(|col| {
Ok(col
.default_deck_for_notetype(input.into())?
.unwrap_or(DeckID(0))
.into())
})
}
fn update_note(&self, input: pb::UpdateNoteIn) -> Result<pb::Empty> {
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();
col.update_note_with_op(&mut note, op)
})
.map(Into::into)
}
fn get_note(&self, input: pb::NoteId) -> Result<pb::Note> {
self.with_col(|col| {
col.storage
.get_note(input.into())?
.ok_or(AnkiError::NotFound)
.map(Into::into)
})
}
fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::Empty> {
self.with_col(|col| {
if !input.note_ids.is_empty() {
col.remove_notes(
&input
.note_ids
.into_iter()
.map(Into::into)
.collect::<Vec<_>>(),
)?;
}
if !input.card_ids.is_empty() {
let nids = col.storage.note_ids_of_cards(
&input
.card_ids
.into_iter()
.map(Into::into)
.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)
}
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())
})
}
fn cloze_numbers_in_note(&self, note: pb::Note) -> Result<pb::ClozeNumbersInNoteOut> {
let mut set = HashSet::with_capacity(4);
for field in &note.fields {
add_cloze_numbers_in_string(field, &mut set);
}
Ok(pb::ClozeNumbersInNoteOut {
numbers: set.into_iter().map(|n| n as u32).collect(),
})
}
fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
col.after_note_updates(
&to_nids(input.nids),
input.generate_cards,
input.mark_notes_modified,
)?;
Ok(pb::Empty {})
})
})
}
fn field_names_for_notes(
&self,
input: pb::FieldNamesForNotesIn,
) -> Result<pb::FieldNamesForNotesOut> {
self.with_col(|col| {
let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect();
col.storage
.field_names_for_notes(&nids)
.map(|fields| pb::FieldNamesForNotesOut { fields })
})
}
fn note_is_duplicate_or_empty(&self, input: pb::Note) -> Result<pb::NoteIsDuplicateOrEmptyOut> {
let note: Note = input.into();
self.with_col(|col| {
col.note_is_duplicate_or_empty(&note)
.map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 })
})
}
fn cards_of_note(&self, input: pb::NoteId) -> Result<pb::CardIDs> {
self.with_col(|col| {
col.storage
.all_card_ids_of_note(NoteID(input.nid))
.map(|v| pb::CardIDs {
cids: v.into_iter().map(Into::into).collect(),
})
})
}
}
fn to_nids(ids: Vec<i64>) -> Vec<NoteID> {
ids.into_iter().map(NoteID).collect()
}

View File

@ -0,0 +1,89 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{
backend_proto as pb,
notetype::{all_stock_notetypes, NoteType, NoteTypeSchema11},
prelude::*,
};
pub(super) use pb::notetypes_service::Service as NoteTypesService;
impl NoteTypesService for Backend {
fn add_or_update_notetype(&self, input: pb::AddOrUpdateNotetypeIn) -> Result<pb::NoteTypeId> {
self.with_col(|col| {
let legacy: NoteTypeSchema11 = serde_json::from_slice(&input.json)?;
let mut nt: NoteType = legacy.into();
if nt.id.0 == 0 {
col.add_notetype(&mut nt)?;
} else {
col.update_notetype(&mut nt, input.preserve_usn_and_mtime)?;
}
Ok(pb::NoteTypeId { ntid: nt.id.0 })
})
}
fn get_stock_notetype_legacy(&self, input: pb::StockNoteType) -> Result<pb::Json> {
// fixme: use individual functions instead of full vec
let mut all = all_stock_notetypes(&self.i18n);
let idx = (input.kind as usize).min(all.len() - 1);
let nt = all.swap_remove(idx);
let schema11: NoteTypeSchema11 = nt.into();
serde_json::to_vec(&schema11)
.map_err(Into::into)
.map(Into::into)
}
fn get_notetype_legacy(&self, input: pb::NoteTypeId) -> Result<pb::Json> {
self.with_col(|col| {
let schema11: NoteTypeSchema11 = col
.storage
.get_notetype(input.into())?
.ok_or(AnkiError::NotFound)?
.into();
Ok(serde_json::to_vec(&schema11)?).map(Into::into)
})
}
fn get_notetype_names(&self, _input: pb::Empty) -> Result<pb::NoteTypeNames> {
self.with_col(|col| {
let entries: Vec<_> = col
.storage
.get_all_notetype_names()?
.into_iter()
.map(|(id, name)| pb::NoteTypeNameId { id: id.0, name })
.collect();
Ok(pb::NoteTypeNames { entries })
})
}
fn get_notetype_names_and_counts(&self, _input: pb::Empty) -> Result<pb::NoteTypeUseCounts> {
self.with_col(|col| {
let entries: Vec<_> = col
.storage
.get_notetype_use_counts()?
.into_iter()
.map(|(id, name, use_count)| pb::NoteTypeNameIdUseCount {
id: id.0,
name,
use_count,
})
.collect();
Ok(pb::NoteTypeUseCounts { entries })
})
}
fn get_notetype_id_by_name(&self, input: pb::String) -> Result<pb::NoteTypeId> {
self.with_col(|col| {
col.storage
.get_notetype_id(&input.val)
.and_then(|nt| nt.ok_or(AnkiError::NotFound))
.map(|ntid| pb::NoteTypeId { ntid: ntid.0 })
})
}
fn remove_notetype(&self, input: pb::NoteTypeId) -> Result<pb::Empty> {
self.with_col(|col| col.remove_notetype(input.into()))
.map(Into::into)
}
}

View File

@ -3,3 +3,184 @@
mod answering;
mod states;
use super::Backend;
use crate::{
backend_proto::{self as pb},
prelude::*,
scheduler::{
new::NewCardSortOrder,
parse_due_date_str,
states::{CardState, NextCardStates},
},
stats::studied_today,
};
pub(super) use pb::scheduling_service::Service as SchedulingService;
impl SchedulingService for Backend {
/// This behaves like _updateCutoff() in older code - it also unburies at the start of
/// a new day.
fn sched_timing_today(&self, _input: pb::Empty) -> Result<pb::SchedTimingTodayOut> {
self.with_col(|col| {
let timing = col.timing_today()?;
col.unbury_if_day_rolled_over(timing)?;
Ok(timing.into())
})
}
/// Fetch data from DB and return rendered string.
fn studied_today(&self, _input: pb::Empty) -> Result<pb::String> {
self.with_col(|col| col.studied_today().map(Into::into))
}
/// Message rendering only, for old graphs.
fn studied_today_message(&self, input: pb::StudiedTodayMessageIn) -> Result<pb::String> {
Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into())
}
fn update_stats(&self, input: pb::UpdateStatsIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
let today = col.current_due_day(0)?;
let usn = col.usn()?;
col.update_deck_stats(today, usn, input).map(Into::into)
})
})
}
fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
let today = col.current_due_day(0)?;
let usn = col.usn()?;
col.extend_limits(
today,
usn,
input.deck_id.into(),
input.new_delta,
input.review_delta,
)
.map(Into::into)
})
})
}
fn counts_for_deck_today(&self, input: pb::DeckId) -> Result<pb::CountsForDeckTodayOut> {
self.with_col(|col| col.counts_for_deck_today(input.did.into()))
}
fn congrats_info(&self, _input: pb::Empty) -> Result<pb::CongratsInfoOut> {
self.with_col(|col| col.congrats_info())
}
fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result<pb::Empty> {
let cids: Vec<_> = input.into();
self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into))
}
fn unbury_cards_in_current_deck(
&self,
input: pb::UnburyCardsInCurrentDeckIn,
) -> Result<pb::Empty> {
self.with_col(|col| {
col.unbury_cards_in_current_deck(input.mode())
.map(Into::into)
})
}
fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::Empty> {
self.with_col(|col| {
let mode = input.mode();
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
col.bury_or_suspend_cards(&cids, mode).map(Into::into)
})
}
fn empty_filtered_deck(&self, input: pb::DeckId) -> Result<pb::Empty> {
self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into))
}
fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result<pb::UInt32> {
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> {
self.with_col(|col| {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let log = input.log;
col.reschedule_cards_as_new(&cids, log).map(Into::into)
})
}
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::Empty> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let spec = parse_due_date_str(&input.days)?;
self.with_col(|col| col.set_due_date(&cids, spec).map(Into::into))
}
fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::Empty> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let (start, step, random, shift) = (
input.starting_from,
input.step_size,
input.randomize,
input.shift_existing,
);
let order = if random {
NewCardSortOrder::Random
} else {
NewCardSortOrder::Preserve
};
self.with_col(|col| {
col.sort_cards(&cids, start, step, order, shift)
.map(Into::into)
})
}
fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.sort_deck(input.deck_id.into(), input.randomize)
.map(Into::into)
})
}
fn get_next_card_states(&self, input: pb::CardId) -> Result<pb::NextCardStates> {
let cid: CardID = input.into();
self.with_col(|col| col.get_next_card_states(cid))
.map(Into::into)
}
fn describe_next_states(&self, input: pb::NextCardStates) -> Result<pb::StringList> {
let states: NextCardStates = input.into();
self.with_col(|col| col.describe_next_states(states))
.map(Into::into)
}
fn state_is_leech(&self, input: pb::SchedulingState) -> Result<pb::Bool> {
let state: CardState = input.into();
Ok(state.leeched().into())
}
fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::Empty> {
self.with_col(|col| col.answer_card(&input.into()))
.map(Into::into)
}
fn upgrade_scheduler(&self, _input: pb::Empty) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler()))
.map(Into::into)
}
fn get_queued_cards(&self, input: pb::GetQueuedCardsIn) -> Result<pb::GetQueuedCardsOut> {
self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only))
}
}
impl From<crate::scheduler::timing::SchedTimingToday> for pb::SchedTimingTodayOut {
fn from(t: crate::scheduler::timing::SchedTimingToday) -> pb::SchedTimingTodayOut {
pb::SchedTimingTodayOut {
days_elapsed: t.days_elapsed,
next_day_at: t.next_day_at,
}
}
}

View File

@ -4,6 +4,7 @@
use itertools::Itertools;
use std::convert::{TryFrom, TryInto};
use super::Backend;
use crate::{
backend_proto as pb,
backend_proto::{
@ -12,11 +13,83 @@ use crate::{
config::SortKind,
prelude::*,
search::{
parse_search, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode,
StateKind, TemplateKind,
concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node,
PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind,
},
text::escape_anki_wildcards,
};
pub(super) use pb::search_service::Service as SearchService;
impl SearchService for Backend {
fn build_search_string(&self, input: pb::SearchNode) -> Result<pb::String> {
let node: Node = input.try_into()?;
Ok(write_nodes(&node.into_node_list()).into())
}
fn search_cards(&self, input: pb::SearchCardsIn) -> Result<pb::SearchCardsOut> {
self.with_col(|col| {
let order = input.order.unwrap_or_default().value.into();
let cids = col.search_cards(&input.search, order)?;
Ok(pb::SearchCardsOut {
card_ids: cids.into_iter().map(|v| v.0).collect(),
})
})
}
fn search_notes(&self, input: pb::SearchNotesIn) -> Result<pb::SearchNotesOut> {
self.with_col(|col| {
let nids = col.search_notes(&input.search)?;
Ok(pb::SearchNotesOut {
note_ids: nids.into_iter().map(|v| v.0).collect(),
})
})
}
fn join_search_nodes(&self, input: pb::JoinSearchNodesIn) -> Result<pb::String> {
let sep = input.joiner().into();
let existing_nodes = {
let node: Node = input.existing_node.unwrap_or_default().try_into()?;
node.into_node_list()
};
let additional_node = input.additional_node.unwrap_or_default().try_into()?;
Ok(concatenate_searches(sep, existing_nodes, additional_node).into())
}
fn replace_search_node(&self, input: pb::ReplaceSearchNodeIn) -> Result<pb::String> {
let existing = {
let node = input.existing_node.unwrap_or_default().try_into()?;
if let Node::Group(nodes) = node {
nodes
} else {
vec![node]
}
};
let replacement = input.replacement_node.unwrap_or_default().try_into()?;
Ok(replace_search_node(existing, replacement).into())
}
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::UInt32> {
let mut search = if input.regex {
input.search
} else {
regex::escape(&input.search)
};
if !input.match_case {
search = format!("(?i){}", search);
}
let nids = input.nids.into_iter().map(NoteID).collect();
let field_name = if input.field_name.is_empty() {
None
} else {
Some(input.field_name)
};
let repl = input.replacement;
self.with_col(|col| {
col.find_and_replace(nids, &search, &repl, field_name)
.map(|cnt| pb::UInt32 { val: cnt as u32 })
})
}
}
impl TryFrom<pb::SearchNode> for Node {
type Error = AnkiError;

View File

@ -0,0 +1,26 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{backend_proto as pb, prelude::*};
pub(super) use pb::stats_service::Service as StatsService;
impl StatsService for Backend {
fn card_stats(&self, input: pb::CardId) -> Result<pb::String> {
self.with_col(|col| col.card_stats(input.into()))
.map(Into::into)
}
fn graphs(&self, input: pb::GraphsIn) -> Result<pb::GraphsOut> {
self.with_col(|col| col.graph_data_for_search(&input.search, input.days))
}
fn get_graph_preferences(&self, _input: pb::Empty) -> Result<pb::GraphPreferences> {
self.with_col(|col| col.get_graph_preferences())
}
fn set_graph_preferences(&self, input: pb::GraphPreferences) -> Result<pb::Empty> {
self.with_col(|col| col.set_graph_preferences(input))
.map(Into::into)
}
}

View File

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod server;
use std::sync::Arc;
use futures::future::{AbortHandle, AbortRegistration, Abortable};
@ -12,12 +14,20 @@ use crate::{
media::MediaManager,
prelude::*,
sync::{
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput,
get_remote_sync_meta, http::SyncRequest, sync_abort, sync_login, FullSyncProgress,
LocalServer, NormalSyncProgress, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput,
},
};
use super::{progress::AbortHandleSlot, Backend};
pub(super) use pb::sync_service::Service as SyncService;
#[derive(Default)]
pub(super) struct SyncState {
remote_sync_status: RemoteSyncStatus,
media_sync_abort: Option<AbortHandle>,
http_sync_server: Option<LocalServer>,
}
#[derive(Default, Debug)]
pub(super) struct RemoteSyncStatus {
@ -70,6 +80,59 @@ impl From<pb::SyncAuth> for SyncAuth {
}
}
impl SyncService for Backend {
fn sync_media(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
self.sync_media_inner(input).map(Into::into)
}
fn abort_sync(&self, _input: pb::Empty) -> Result<pb::Empty> {
if let Some(handle) = self.sync_abort.lock().unwrap().take() {
handle.abort();
}
Ok(().into())
}
/// Abort the media sync. Does not wait for completion.
fn abort_media_sync(&self, _input: pb::Empty) -> Result<pb::Empty> {
let guard = self.state.lock().unwrap();
if let Some(handle) = &guard.sync.media_sync_abort {
handle.abort();
}
Ok(().into())
}
fn before_upload(&self, _input: pb::Empty) -> Result<pb::Empty> {
self.with_col(|col| col.before_upload().map(Into::into))
}
fn sync_login(&self, input: pb::SyncLoginIn) -> Result<pb::SyncAuth> {
self.sync_login_inner(input)
}
fn sync_status(&self, input: pb::SyncAuth) -> Result<pb::SyncStatusOut> {
self.sync_status_inner(input)
}
fn sync_collection(&self, input: pb::SyncAuth) -> Result<pb::SyncCollectionOut> {
self.sync_collection_inner(input)
}
fn full_upload(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
self.full_sync_inner(input, true)?;
Ok(().into())
}
fn full_download(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
self.full_sync_inner(input, false)?;
Ok(().into())
}
fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> Result<pb::Json> {
let req = SyncRequest::from_method_and_data(input.method(), input.data)?;
self.sync_server_method_inner(req).map(Into::into)
}
}
impl Backend {
fn sync_abort_handle(
&self,
@ -104,11 +167,11 @@ impl Backend {
let (abort_handle, abort_reg) = AbortHandle::new_pair();
{
let mut guard = self.state.lock().unwrap();
if guard.media_sync_abort.is_some() {
if guard.sync.media_sync_abort.is_some() {
// media sync is already active
return Ok(());
} else {
guard.media_sync_abort = Some(abort_handle);
guard.sync.media_sync_abort = Some(abort_handle);
}
}
@ -131,7 +194,7 @@ impl Backend {
let result = rt.block_on(abortable_sync);
// mark inactive
self.state.lock().unwrap().media_sync_abort.take();
self.state.lock().unwrap().sync.media_sync_abort.take();
// return result
match result {
@ -146,14 +209,14 @@ impl Backend {
/// Abort the media sync. Won't return until aborted.
pub(super) fn abort_media_sync_and_wait(&self) {
let guard = self.state.lock().unwrap();
if let Some(handle) = &guard.media_sync_abort {
if let Some(handle) = &guard.sync.media_sync_abort {
handle.abort();
self.progress_state.lock().unwrap().want_abort = true;
}
drop(guard);
// block until it aborts
while self.state.lock().unwrap().media_sync_abort.is_some() {
while self.state.lock().unwrap().sync.media_sync_abort.is_some() {
std::thread::sleep(std::time::Duration::from_millis(100));
self.progress_state.lock().unwrap().want_abort = true;
}
@ -185,8 +248,8 @@ impl Backend {
// return cached server response if only a short time has elapsed
{
let guard = self.state.lock().unwrap();
if guard.remote_sync_status.last_check.elapsed_secs() < 300 {
return Ok(guard.remote_sync_status.last_response.into());
if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 {
return Ok(guard.sync.remote_sync_status.last_response.into());
}
}
@ -201,9 +264,9 @@ impl Backend {
// On startup, the sync status check will block on network access, and then automatic syncing begins,
// taking hold of the mutex. By the time we reach here, our network status may be out of date,
// so we discard it if stale.
if guard.remote_sync_status.last_check < time_at_check_begin {
guard.remote_sync_status.last_check = time_at_check_begin;
guard.remote_sync_status.last_response = response;
if guard.sync.remote_sync_status.last_check < time_at_check_begin {
guard.sync.remote_sync_status.last_check = time_at_check_begin;
guard.sync.remote_sync_status.last_response = response;
}
}
@ -247,6 +310,7 @@ impl Backend {
self.state
.lock()
.unwrap()
.sync
.remote_sync_status
.update(output.required.into());
Ok(output.into())
@ -302,6 +366,7 @@ impl Backend {
self.state
.lock()
.unwrap()
.sync
.remote_sync_status
.update(pb::sync_status_out::Required::NoChanges);
}

View File

@ -4,7 +4,7 @@
use std::{path::PathBuf, sync::MutexGuard};
use tokio::runtime::Runtime;
use super::{Backend, BackendState};
use crate::backend::{Backend, BackendState};
use crate::{
err::SyncErrorKind,
prelude::*,
@ -24,16 +24,12 @@ impl Backend {
F: FnOnce(&mut LocalServer) -> Result<T>,
{
let mut state_guard = self.state.lock().unwrap();
let out =
func(
state_guard
.http_sync_server
.as_mut()
.ok_or_else(|| AnkiError::SyncError {
let out = func(state_guard.sync.http_sync_server.as_mut().ok_or_else(|| {
AnkiError::SyncError {
kind: SyncErrorKind::SyncNotStarted,
info: Default::default(),
})?,
);
}
})?);
if out.is_err() {
self.abort_and_restore_collection(Some(state_guard))
}
@ -82,6 +78,7 @@ impl Backend {
fn take_server(&self, state_guard: Option<MutexGuard<BackendState>>) -> Result<LocalServer> {
let mut state_guard = state_guard.unwrap_or_else(|| self.state.lock().unwrap());
state_guard
.sync
.http_sync_server
.take()
.ok_or_else(|| AnkiError::SyncError {
@ -94,7 +91,7 @@ impl Backend {
// place col into new server
let server = self.col_into_server()?;
let mut state_guard = self.state.lock().unwrap();
assert!(state_guard.http_sync_server.replace(server).is_none());
assert!(state_guard.sync.http_sync_server.replace(server).is_none());
drop(state_guard);
self.with_sync_server(|server| {

62
rslib/src/backend/tags.rs Normal file
View File

@ -0,0 +1,62 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{backend_proto as pb, prelude::*};
pub(super) use pb::tags_service::Service as TagsService;
impl TagsService for Backend {
fn clear_unused_tags(&self, _input: pb::Empty) -> Result<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
}
fn all_tags(&self, _input: pb::Empty) -> Result<pb::StringList> {
Ok(pb::StringList {
vals: self.with_col(|col| {
Ok(col
.storage
.all_tags()?
.into_iter()
.map(|t| t.name)
.collect())
})?,
})
}
fn expunge_tags(&self, tags: pb::String) -> Result<pb::UInt32> {
self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into))
}
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.transact(None, |col| {
col.set_tag_expanded(&input.name, input.expanded)?;
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> {
self.with_col(|col| col.tag_tree())
}
fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result<pb::Empty> {
let source_tags = input.source_tags;
let target_tag = if input.target_tag.is_empty() {
None
} else {
Some(input.target_tag)
};
self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag))
.map(Into::into)
}
}

View File

@ -18,6 +18,10 @@ pub enum BoolKey {
CollapseTags,
CollapseToday,
FutureDueShowBacklog,
HideAudioPlayButtons,
InterruptAudioWhenAnswering,
PasteImagesAsPng,
PasteStripsFormatting,
PreviewBothSides,
Sched2021,
@ -50,7 +54,9 @@ impl Collection {
}
// some keys default to true
BoolKey::AddingDefaultsToCurrentDeck
BoolKey::InterruptAudioWhenAnswering
| BoolKey::ShowIntervalsAboveAnswerButtons
| BoolKey::AddingDefaultsToCurrentDeck
| BoolKey::FutureDueShowBacklog
| BoolKey::ShowRemainingDueCountsInStudy
| BoolKey::CardCountsSeparateInactive

View File

@ -10,7 +10,10 @@ pub use crate::backend_proto::{
deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto,
DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck,
};
use crate::{backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images};
use crate::{
backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images,
undo::UndoableOpKind,
};
use crate::{
collection::Collection,
deckconf::DeckConfID,
@ -263,53 +266,77 @@ impl Collection {
}
/// Add or update an existing deck modified by the user. May add parents,
/// or rename children as required.
/// or rename children as required. Prefer add_deck() or update_deck() to
/// be explicit about your intentions; this function mainly exists so we
/// can integrate with older Python code that behaved this way.
pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<()> {
self.state.deck_cache.clear();
if deck.id.0 == 0 {
self.add_deck(deck)
} else {
self.update_deck(deck)
}
}
self.transact(None, |col| {
/// 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<()> {
if deck.id.0 != 0 {
return Err(AnkiError::invalid_input("deck to add must have id 0"));
}
self.transact(Some(UndoableOpKind::AddDeck), |col| {
let usn = col.usn()?;
col.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn);
if deck.id.0 == 0 {
// TODO: undo support
col.match_or_create_parents(deck, usn)?;
col.storage.add_deck(deck)
} else if let Some(existing_deck) = col.storage.get_deck(deck.id)? {
let name_changed = existing_deck.name != deck.name;
col.add_deck_undoable(deck)
})
}
pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> {
self.transact(Some(UndoableOpKind::UpdateDeck), |col| {
let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?;
col.update_deck_inner(deck, existing_deck, col.usn()?)
})
}
pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> {
self.transact(Some(UndoableOpKind::RenameDeck), |col| {
let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?;
let mut deck = existing_deck.clone();
deck.name = human_deck_name_to_native(new_human_name);
col.update_deck_inner(&mut deck, existing_deck, col.usn()?)
})
}
fn update_deck_inner(&mut self, deck: &mut Deck, original: Deck, usn: Usn) -> Result<()> {
self.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn);
let name_changed = original.name != deck.name;
if name_changed {
// match closest parent name
col.match_or_create_parents(deck, usn)?;
self.match_or_create_parents(deck, usn)?;
// rename children
col.rename_child_decks(&existing_deck, &deck.name, usn)?;
self.rename_child_decks(&original, &deck.name, usn)?;
}
col.update_single_deck_undoable(deck, &existing_deck)?;
self.update_single_deck_undoable(deck, original)?;
if name_changed {
// after updating, we need to ensure all grandparents exist, which may not be the case
// in the parent->child case
col.create_missing_parents(&deck.name, usn)?;
self.create_missing_parents(&deck.name, usn)?;
}
Ok(())
} else {
Err(AnkiError::invalid_input("updating non-existent deck"))
}
})
}
/// Add/update a single deck when syncing/importing. Ensures name is unique
/// & normalized, but does not check parents/children or update mtime
/// (unless the name was changed). Caller must set up transaction.
/// TODO: undo support
pub(crate) fn add_or_update_single_deck_with_existing_id(
&mut self,
deck: &mut Deck,
usn: Usn,
) -> Result<()> {
self.state.deck_cache.clear();
self.prepare_deck_for_update(deck, usn)?;
self.storage.add_or_update_deck_with_existing_id(deck)
self.add_or_update_deck_with_existing_id_undoable(deck)
}
pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> {
@ -359,7 +386,7 @@ impl Collection {
let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f"));
child.name = new_name;
child.set_modified(usn);
self.update_single_deck_undoable(&mut child, &original)?;
self.update_single_deck_undoable(&mut child, original)?;
}
Ok(())
@ -367,12 +394,11 @@ impl Collection {
/// Add a single, normal deck with the provided name for a child deck.
/// Caller must have done necessarily validation on name.
fn add_parent_deck(&self, machine_name: &str, usn: Usn) -> Result<()> {
fn add_parent_deck(&mut self, machine_name: &str, usn: Usn) -> Result<()> {
let mut deck = Deck::new_normal();
deck.name = machine_name.into();
deck.set_modified(usn);
// fixme: undo
self.storage.add_deck(&mut deck)
self.add_deck_undoable(&mut deck)
}
/// If parent deck(s) exist, rewrite name to match their case.
@ -404,7 +430,7 @@ impl Collection {
}
}
fn create_missing_parents(&self, mut machine_name: &str, usn: Usn) -> Result<()> {
fn create_missing_parents(&mut self, mut machine_name: &str, usn: Usn) -> Result<()> {
while let Some(parent_name) = immediate_parent_name(machine_name) {
if self.storage.get_deck_id(parent_name)?.is_none() {
self.add_parent_deck(parent_name, usn)?;
@ -441,10 +467,7 @@ impl Collection {
}
pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<usize> {
// fixme: vet cache clearing
self.state.deck_cache.clear();
let mut card_count = 0;
self.transact(None, |col| {
let usn = col.usn()?;
for did in dids {
@ -466,7 +489,6 @@ impl Collection {
}
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
// fixme: undo
let card_count = match deck.kind {
DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?,
DeckKind::Filtered(_) => {
@ -476,14 +498,13 @@ impl Collection {
};
self.clear_aux_config_for_deck(deck.id)?;
if deck.id.0 == 1 {
// if deleting the default deck, ensure there's a new one, and avoid the grave
let mut deck = deck.to_owned();
// fixme: separate key
deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into();
deck.set_modified(usn);
self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?;
} else {
self.storage.remove_deck(deck.id)?;
self.storage.add_deck_grave(deck.id, usn)?;
self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?;
}
Ok(card_count)
}
@ -597,7 +618,7 @@ impl Collection {
deck.reset_stats_if_day_changed(today);
mutator(&mut deck.common);
deck.set_modified(usn);
self.update_single_deck_undoable(deck, &original)
self.update_single_deck_undoable(deck, original)
}
pub fn drag_drop_decks(
@ -605,9 +626,8 @@ impl Collection {
source_decks: &[DeckID],
target: Option<DeckID>,
) -> Result<()> {
self.state.deck_cache.clear();
let usn = self.usn()?;
self.transact(None, |col| {
self.transact(Some(UndoableOpKind::RenameDeck), |col| {
let target_deck;
let mut target_name = None;
if let Some(target) = target {
@ -622,18 +642,25 @@ impl Collection {
for source in source_decks {
if let Some(mut source) = col.storage.get_deck(*source)? {
let orig = source.clone();
let new_name = drag_drop_deck_name(&source.name, target_name);
if new_name == source.name {
continue;
}
let orig = source.clone();
// this is basically update_deck_inner(), except:
// - we skip the normalization in prepare_for_update()
// - we skip the match_or_create_parents() step
source.set_modified(usn);
source.name = new_name;
col.ensure_deck_name_unique(&mut source, usn)?;
col.rename_child_decks(&orig, &source.name, usn)?;
source.set_modified(usn);
col.storage.update_deck(&source)?;
col.update_single_deck_undoable(&mut source, orig)?;
// after updating, we need to ensure all grandparents exist, which may not be the case
// in the parent->child case
// FIXME: maybe we only need to do this once at the end of the loop?
col.create_missing_parents(&source.name, usn)?;
}
}
@ -758,6 +785,23 @@ mod test {
col.add_or_update_deck(&mut middle)?;
assert_eq!(middle.name, "other+");
// public function takes human name
col.rename_deck(middle.id, "one::two")?;
assert_eq!(
sorted_names(&col),
vec![
"Default",
"one",
"one::two",
"one::two::baz",
"one::two::baz2",
"other",
"quux",
"quux::foo",
"quux::foo::baz",
]
);
Ok(())
}

View File

@ -6,20 +6,44 @@ use crate::prelude::*;
#[derive(Debug)]
pub(crate) enum UndoableDeckChange {
Added(Box<Deck>),
Updated(Box<Deck>),
Removed(Box<Deck>),
GraveAdded(Box<(DeckID, Usn)>),
GraveRemoved(Box<(DeckID, Usn)>),
}
impl Collection {
pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> {
match change {
UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck),
UndoableDeckChange::Updated(mut deck) => {
let current = self
.storage
.get_deck(deck.id)?
.ok_or_else(|| AnkiError::invalid_input("deck disappeared"))?;
self.update_single_deck_undoable(&mut *deck, &current)
self.update_single_deck_undoable(&mut *deck, current)
}
UndoableDeckChange::Removed(deck) => self.restore_deleted_deck(*deck),
UndoableDeckChange::GraveAdded(e) => self.remove_deck_grave(e.0, e.1),
UndoableDeckChange::GraveRemoved(e) => self.add_deck_grave_undoable(e.0, e.1),
}
}
pub(super) fn add_deck_undoable(&mut self, deck: &mut Deck) -> Result<(), AnkiError> {
self.storage.add_deck(deck)?;
self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone())));
Ok(())
}
pub(super) fn add_or_update_deck_with_existing_id_undoable(
&mut self,
deck: &mut Deck,
) -> Result<(), AnkiError> {
self.state.deck_cache.clear();
self.storage.add_or_update_deck_with_existing_id(deck)?;
self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone())));
Ok(())
}
/// Update an individual, existing deck. Caller is responsible for ensuring deck
@ -28,10 +52,45 @@ impl Collection {
pub(super) fn update_single_deck_undoable(
&mut self,
deck: &mut Deck,
original: &Deck,
original: Deck,
) -> Result<()> {
self.state.deck_cache.clear();
self.save_undo(UndoableDeckChange::Updated(Box::new(original.clone())));
self.save_undo(UndoableDeckChange::Updated(Box::new(original)));
self.storage.update_deck(deck)
}
pub(crate) fn remove_deck_and_add_grave_undoable(
&mut self,
deck: Deck,
usn: Usn,
) -> Result<()> {
self.state.deck_cache.clear();
self.add_deck_grave_undoable(deck.id, usn)?;
self.storage.remove_deck(deck.id)?;
self.save_undo(UndoableDeckChange::Removed(Box::new(deck)));
Ok(())
}
fn restore_deleted_deck(&mut self, deck: Deck) -> Result<()> {
self.storage.add_or_update_deck_with_existing_id(&deck)?;
self.save_undo(UndoableDeckChange::Added(Box::new(deck)));
Ok(())
}
fn remove_deck_undoable(&mut self, deck: Deck) -> Result<()> {
self.state.deck_cache.clear();
self.storage.remove_deck(deck.id)?;
self.save_undo(UndoableDeckChange::Removed(Box::new(deck)));
Ok(())
}
fn add_deck_grave_undoable(&mut self, did: DeckID, usn: Usn) -> Result<()> {
self.save_undo(UndoableDeckChange::GraveAdded(Box::new((did, usn))));
self.storage.add_deck_grave(did, usn)
}
fn remove_deck_grave(&mut self, did: DeckID, usn: Usn) -> Result<()> {
self.save_undo(UndoableDeckChange::GraveRemoved(Box::new((did, usn))));
self.storage.remove_deck_grave(did)
}
}

View File

@ -3,7 +3,8 @@
use crate::{
backend_proto::{
collection_scheduling_settings::NewReviewMix as NewRevMixPB, CollectionSchedulingSettings,
preferences::scheduling::NewReviewMix as NewRevMixPB,
preferences::{Editing, Reviewing, Scheduling},
Preferences,
},
collection::Collection,
@ -15,20 +16,37 @@ use crate::{
impl Collection {
pub fn get_preferences(&self) -> Result<Preferences> {
Ok(Preferences {
sched: Some(self.get_collection_scheduling_settings()?),
scheduling: Some(self.get_scheduling_preferences()?),
reviewing: Some(self.get_reviewing_preferences()?),
editing: Some(self.get_editing_preferences()?),
})
}
pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
if let Some(sched) = prefs.sched {
self.set_collection_scheduling_settings(sched)?;
self.transact(
Some(crate::undo::UndoableOpKind::UpdatePreferences),
|col| col.set_preferences_inner(prefs),
)
}
fn set_preferences_inner(
&mut self,
prefs: Preferences,
) -> Result<(), crate::prelude::AnkiError> {
if let Some(sched) = prefs.scheduling {
self.set_scheduling_preferences(sched)?;
}
if let Some(reviewing) = prefs.reviewing {
self.set_reviewing_preferences(reviewing)?;
}
if let Some(editing) = prefs.editing {
self.set_editing_preferences(editing)?;
}
Ok(())
}
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> {
Ok(CollectionSchedulingSettings {
pub fn get_scheduling_preferences(&self) -> Result<Scheduling> {
Ok(Scheduling {
scheduler_version: match self.scheduler_version() {
crate::config::SchedulerVersion::V1 => 1,
crate::config::SchedulerVersion::V2 => 2,
@ -40,30 +58,15 @@ impl Collection {
crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst,
crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst,
} as i32,
show_remaining_due_counts: self.get_bool(BoolKey::ShowRemainingDueCountsInStudy),
show_intervals_on_buttons: self.get_bool(BoolKey::ShowIntervalsAboveAnswerButtons),
time_limit_secs: self.get_answer_time_limit_secs(),
new_timezone: self.get_creation_utc_offset().is_some(),
day_learn_first: self.get_bool(BoolKey::ShowDayLearningCardsFirst),
})
}
pub(crate) fn set_collection_scheduling_settings(
&mut self,
settings: CollectionSchedulingSettings,
) -> Result<()> {
pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> {
let s = settings;
self.set_bool(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?;
self.set_bool(
BoolKey::ShowRemainingDueCountsInStudy,
s.show_remaining_due_counts,
)?;
self.set_bool(
BoolKey::ShowIntervalsAboveAnswerButtons,
s.show_intervals_on_buttons,
)?;
self.set_answer_time_limit_secs(s.time_limit_secs)?;
self.set_learn_ahead_secs(s.learn_ahead_secs)?;
self.set_new_review_mix(match s.new_review_mix() {
@ -88,4 +91,52 @@ impl Collection {
Ok(())
}
pub fn get_reviewing_preferences(&self) -> Result<Reviewing> {
Ok(Reviewing {
hide_audio_play_buttons: self.get_bool(BoolKey::HideAudioPlayButtons),
interrupt_audio_when_answering: self.get_bool(BoolKey::InterruptAudioWhenAnswering),
show_remaining_due_counts: self.get_bool(BoolKey::ShowRemainingDueCountsInStudy),
show_intervals_on_buttons: self.get_bool(BoolKey::ShowIntervalsAboveAnswerButtons),
time_limit_secs: self.get_answer_time_limit_secs(),
})
}
pub(crate) fn set_reviewing_preferences(&mut self, settings: Reviewing) -> Result<()> {
let s = settings;
self.set_bool(BoolKey::HideAudioPlayButtons, s.hide_audio_play_buttons)?;
self.set_bool(
BoolKey::InterruptAudioWhenAnswering,
s.interrupt_audio_when_answering,
)?;
self.set_bool(
BoolKey::ShowRemainingDueCountsInStudy,
s.show_remaining_due_counts,
)?;
self.set_bool(
BoolKey::ShowIntervalsAboveAnswerButtons,
s.show_intervals_on_buttons,
)?;
self.set_answer_time_limit_secs(s.time_limit_secs)?;
Ok(())
}
pub fn get_editing_preferences(&self) -> Result<Editing> {
Ok(Editing {
adding_defaults_to_current_deck: self.get_bool(BoolKey::AddingDefaultsToCurrentDeck),
paste_images_as_png: self.get_bool(BoolKey::PasteImagesAsPng),
paste_strips_formatting: self.get_bool(BoolKey::PasteStripsFormatting),
})
}
pub(crate) fn set_editing_preferences(&mut self, settings: Editing) -> Result<()> {
let s = settings;
self.set_bool(
BoolKey::AddingDefaultsToCurrentDeck,
s.adding_defaults_to_current_deck,
)?;
self.set_bool(BoolKey::PasteImagesAsPng, s.paste_images_as_png)?;
self.set_bool(BoolKey::PasteStripsFormatting, s.paste_strips_formatting)?;
Ok(())
}
}

View File

@ -138,8 +138,8 @@ impl SqliteStorage {
}
}
/// Used for syncing; will keep existing ID. Shouldn't be used to add new decks locally,
/// since it does not allocate an id.
/// Used for syncing&undo; will keep existing ID. Shouldn't be used to add
/// new decks locally, since it does not allocate an id.
pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> {
if deck.id.0 == 0 {
return Err(AnkiError::invalid_input("deck with id 0"));

View File

@ -48,6 +48,10 @@ impl SqliteStorage {
self.remove_grave(nid.0, GraveKind::Note)
}
pub(crate) fn remove_deck_grave(&self, did: DeckID) -> Result<()> {
self.remove_grave(did.0, GraveKind::Deck)
}
pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result<Graves> {
let mut stmt = self.db.prepare(&format!(
"select oid, type from graves where {}",

View File

@ -5,15 +5,20 @@ use crate::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UndoableOpKind {
UpdateCard,
AddDeck,
AddNote,
AnswerCard,
Bury,
RemoveDeck,
RemoveNote,
RenameDeck,
Suspend,
UnburyUnsuspend,
AddNote,
RemoveNote,
UpdateTag,
UpdateCard,
UpdateDeck,
UpdateNote,
UpdatePreferences,
UpdateTag,
}
impl UndoableOpKind {
@ -25,15 +30,20 @@ impl UndoableOpKind {
impl Collection {
pub fn describe_op_kind(&self, op: UndoableOpKind) -> String {
let key = match op {
UndoableOpKind::UpdateCard => TR::UndoUpdateCard,
UndoableOpKind::AddDeck => TR::UndoAddDeck,
UndoableOpKind::AddNote => TR::UndoAddNote,
UndoableOpKind::AnswerCard => TR::UndoAnswerCard,
UndoableOpKind::Bury => TR::StudyingBury,
UndoableOpKind::RemoveDeck => TR::DecksDeleteDeck,
UndoableOpKind::RemoveNote => TR::StudyingDeleteNote,
UndoableOpKind::RenameDeck => TR::ActionsRenameDeck,
UndoableOpKind::Suspend => TR::StudyingSuspend,
UndoableOpKind::UnburyUnsuspend => TR::UndoUnburyUnsuspend,
UndoableOpKind::AddNote => TR::UndoAddNote,
UndoableOpKind::RemoveNote => TR::StudyingDeleteNote,
UndoableOpKind::UpdateTag => TR::UndoUpdateTag,
UndoableOpKind::UpdateCard => TR::UndoUpdateCard,
UndoableOpKind::UpdateDeck => TR::UndoUpdateDeck,
UndoableOpKind::UpdateNote => TR::UndoUpdateNote,
UndoableOpKind::UpdatePreferences => TR::PreferencesPreferences,
UndoableOpKind::UpdateTag => TR::UndoUpdateTag,
};
self.i18n.tr(key).to_string()

View File

@ -4,7 +4,7 @@
import { caretToEnd } from "./helpers";
import { saveField } from "./changeTimer";
import { filterHTML } from "./htmlFilter";
import { updateButtonState } from "./toolbar";
import { updateButtonState, disableButtons } from "./toolbar";
import { EditorField } from "./editorField";
import { LabelContainer } from "./labelContainer";
@ -113,6 +113,11 @@ export function setFields(fields: [string, string][]): void {
forEditorField(fields, (field, [name, fieldContent]) =>
field.initialize(name, color, fieldContent)
);
if (!getCurrentField()) {
// when initial focus of the window is not on editor (e.g. browser)
disableButtons();
}
}
export function setBackgrounds(cols: ("dupe" | "")[]): void {