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-answer-card = Answer Card
undo-unbury-unsuspend = Unbury/Unsuspend undo-unbury-unsuspend = Unbury/Unsuspend
undo-add-deck = Add Deck
undo-add-note = Add Note undo-add-note = Add Note
undo-update-tag = Update Tag undo-update-tag = Update Tag
undo-update-note = Update Note undo-update-note = Update Note
undo-update-card = Update Card undo-update-card = Update Card
undo-update-deck = Update Deck

View File

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

View File

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

View File

@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import os import os
import re import re
import sys import sys
import google.protobuf.descriptor
import pylib.anki._backend.backend_pb2 as pb import pylib.anki._backend.backend_pb2 as pb
import stringcase import stringcase
@ -97,7 +100,7 @@ def get_input_assign(msg):
return ", ".join(f"{f.name}={f.name}" for f in fields) 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 input_name = method.input_type.name
if ( if (
(input_name.endswith("In") or len(method.input_type.fields) < 2) (input_name.endswith("In") or len(method.input_type.fields) < 2)
@ -134,11 +137,11 @@ def render_method(method, idx):
{input_assign_outer}""" {input_assign_outer}"""
if method.name in SKIP_DECODE: 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: else:
buf += f"""output = pb.{method.output_type.name}() 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} return output{single_field}
""" """
@ -146,13 +149,29 @@ def render_method(method, idx):
out = [] 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) out = "\n".join(out)
sys.stdout.buffer.write( open(sys.argv[1], "wb").write(
( (
'''# Copyright: Ankitects Pty Ltd and contributors '''# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@ -174,7 +193,7 @@ from typing import *
import anki._backend.backend_pb2 as pb import anki._backend.backend_pb2 as pb
class RustBackendGenerated: 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") raise Exception("not implemented")
''' '''

View File

@ -3,5 +3,5 @@ def open_backend(data: bytes) -> Backend: ...
class Backend: class Backend:
@classmethod @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: ... 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 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." "Rename deck prefix to NAME if not exists. Updates children."
g["name"] = newName if isinstance(deck, int):
self.update(g, preserve_usn=False) deck_id = deck
return else:
deck_id = deck["id"]
self.col._backend.rename_deck(deck_id=deck_id, new_name=new_name)
# Drag/drop # Drag/drop
############################################################# #############################################################

View File

@ -1,15 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki::backend::{init_backend, Backend as RustBackend, BackendMethod}; use anki::backend::{init_backend, Backend as RustBackend};
use pyo3::exceptions::PyException; use pyo3::exceptions::PyException;
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3::types::PyBytes; use pyo3::types::PyBytes;
use pyo3::{create_exception, wrap_pyfunction}; use pyo3::{create_exception, wrap_pyfunction};
use std::convert::TryFrom;
// Regular backend
//////////////////////////////////
#[pyclass(module = "rsbridge")] #[pyclass(module = "rsbridge")]
struct Backend { struct Backend {
@ -31,50 +27,22 @@ 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] #[pymethods]
impl Backend { 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(); let in_bytes = input.as_bytes();
if want_release_gil(method) { py.allow_threads(|| self.backend.run_method(service, method, in_bytes))
py.allow_threads(|| self.backend.run_command_bytes(method, in_bytes)) .map(|out_bytes| {
} else { let out_obj = PyBytes::new(py, &out_bytes);
self.backend.run_command_bytes(method, in_bytes) out_obj.into()
} })
.map(|out_bytes| { .map_err(BackendError::new_err)
let out_obj = PyBytes::new(py, &out_bytes);
out_obj.into()
})
.map_err(BackendError::new_err)
} }
/// This takes and returns JSON, due to Python's slow protobuf /// This takes and returns JSON, due to Python's slow protobuf

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import anki.lang import anki.lang
import aqt import aqt
from anki.consts import newCardSchedulingLabels
from aqt import AnkiQt from aqt import AnkiQt
from aqt.profiles import RecordingDriver, VideoDriver from aqt.profiles import RecordingDriver, VideoDriver
from aqt.qt import * 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): class Preferences(QDialog):
def __init__(self, mw: AnkiQt) -> None: def __init__(self, mw: AnkiQt) -> None:
QDialog.__init__(self, mw, Qt.Window) QDialog.__init__(self, mw, Qt.Window)
@ -45,22 +32,18 @@ class Preferences(QDialog):
self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES) self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)
) )
self.silentlyClose = True self.silentlyClose = True
self.prefs = self.mw.col.get_preferences() self.setup_collection()
self.setupLang() self.setup_profile()
self.setupCollection() self.setup_global()
self.setupNetwork()
self.setupBackup()
self.setupOptions()
self.show() self.show()
def accept(self) -> None: def accept(self) -> None:
# avoid exception if main window is already closed # avoid exception if main window is already closed
if not self.mw.col: if not self.mw.col:
return return
self.updateCollection() self.update_collection()
self.updateNetwork() self.update_profile()
self.updateBackup() self.update_global()
self.updateOptions()
self.mw.pm.save() self.mw.pm.save()
self.mw.reset() self.mw.reset()
self.done(0) self.done(0)
@ -69,16 +52,214 @@ class Preferences(QDialog):
def reject(self) -> None: def reject(self) -> None:
self.accept() 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 = self.form
f.lang.addItems([x[0] for x in anki.lang.langs]) f.lang.addItems([x[0] for x in anki.lang.langs])
f.lang.setCurrentIndex(self.langIdx()) f.lang.setCurrentIndex(self.current_lang_index())
qconnect(f.lang.currentIndexChanged, self.onLangIdxChanged) 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] codes = [x[1] for x in anki.lang.langs]
lang = anki.lang.currentLang lang = anki.lang.currentLang
if lang in anki.lang.compatMap: if lang in anki.lang.compatMap:
@ -90,43 +271,16 @@ class Preferences(QDialog):
except: except:
return codes.index("en_US") 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] code = anki.lang.langs[idx][1]
self.mw.pm.setLang(code) self.mw.pm.setLang(code)
showInfo( showInfo(
tr(TR.PREFERENCES_PLEASE_RESTART_ANKI_TO_COMPLETE_LANGUAGE), parent=self 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: def setup_video_driver(self) -> None:
self.video_drivers = VideoDriver.all_for_platform() self.video_drivers = VideoDriver.all_for_platform()
names = [ names = [
@ -144,133 +298,17 @@ class Preferences(QDialog):
self.mw.pm.set_video_driver(new_driver) self.mw.pm.set_video_driver(new_driver)
showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU)) 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() def video_driver_name_for_platform(driver: VideoDriver) -> str:
if driver == VideoDriver.ANGLE:
qc = d.conf return tr(TR.PREFERENCES_VIDEO_DRIVER_ANGLE)
qc["addToCur"] = not f.useCurrent.currentIndex() elif driver == VideoDriver.Software:
if isMac:
s = self.prefs.sched return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_MAC)
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()
else: else:
self.form.syncUser.setText(self.prof.get("syncUser", "")) return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_OTHER)
qconnect(self.form.syncDeauth.clicked, self.onSyncDeauth) else:
self.form.syncDeauth.setText(tr(TR.SYNC_LOG_OUT_BUTTON)) if isMac:
return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_MAC)
def on_media_log(self) -> None: else:
self.mw.media_syncer.show_sync_log() return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_OTHER)
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))

View File

@ -89,14 +89,8 @@ profileConf: Dict[str, Any] = dict(
numBackups=50, numBackups=50,
lastOptimize=intTime(), lastOptimize=intTime(),
# editing # editing
fullSearch=False,
searchHistory=[], searchHistory=[],
lastColour="#00f", lastColour="#00f",
stripHTML=True,
pastePNG=False,
# not exposed in gui
deleteMedia=False,
preserveKeyboard=True,
# syncing # syncing
syncKey=None, syncKey=None,
syncMedia=True, syncMedia=True,
@ -104,6 +98,10 @@ profileConf: Dict[str, Any] = dict(
# importing # importing
allowHTML=False, allowHTML=False,
importMode=1, 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 # 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: def set_sync_key(self, val: Optional[str]) -> None:
self.profile["syncKey"] = val self.profile["syncKey"] = val
@ -667,8 +658,3 @@ create table if not exists profiles
def set_recording_driver(self, driver: RecordingDriver) -> None: def set_recording_driver(self, driver: RecordingDriver) -> None:
self.profile["recordingDriver"] = driver.value 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 return
self.refresh() self.refresh()
self.mw.deckBrowser.refresh() self.mw.deckBrowser.refresh()
self.mw.update_undo_actions()
def on_save() -> None: def on_save() -> None:
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK))
self.browser.model.beginReset() self.browser.model.beginReset()
self.mw.taskman.with_progress( self.mw.taskman.with_progress(
lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done
@ -1130,7 +1130,6 @@ class SidebarTreeView(QTreeView):
def rename_deck(self, item: SidebarItem, new_name: str) -> None: def rename_deck(self, item: SidebarItem, new_name: str) -> None:
deck = self.mw.col.decks.get(item.id) deck = self.mw.col.decks.get(item.id)
new_name = item.name_prefix + new_name new_name = item.name_prefix + new_name
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK))
try: try:
self.mw.col.decks.rename(deck, new_name) self.mw.col.decks.rename(deck, new_name)
except DeckIsFilteredError as err: except DeckIsFilteredError as err:
@ -1141,6 +1140,7 @@ class SidebarTreeView(QTreeView):
and other.id == item.id and other.id == item.id
) )
self.mw.deckBrowser.refresh() self.mw.deckBrowser.refresh()
self.mw.update_undo_actions()
def remove_tags(self, item: SidebarItem) -> None: def remove_tags(self, item: SidebarItem) -> None:
self.browser.editor.saveNow(lambda: self._remove_tags(item)) self.browser.editor.saveNow(lambda: self._remove_tags(item))
@ -1210,7 +1210,6 @@ class SidebarTreeView(QTreeView):
self.refresh() self.refresh()
dids = self._selected_decks() dids = self._selected_decks()
self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK))
self.browser.model.beginReset() self.browser.model.beginReset()
self.mw.taskman.with_progress(do_delete, on_done) self.mw.taskman.with_progress(do_delete, on_done)

View File

@ -76,33 +76,31 @@ message DeckConfigID {
int64 dcid = 1; int64 dcid = 1;
} }
// New style RPC definitions // Backend methods
/////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////
service BackendService { /// while the protobuf descriptors expose the order services are defined in,
rpc LatestProgress(Empty) returns (Progress); /// that information is not available in prost, so we define an enum to make
rpc SetWantsAbort(Empty) returns (Empty); /// sure all clients agree on the service index
enum ServiceIndex {
// card rendering SERVICE_INDEX_SCHEDULING = 0;
SERVICE_INDEX_DECKS = 1;
rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut); SERVICE_INDEX_NOTES = 2;
rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut); SERVICE_INDEX_SYNC = 3;
rpc GetEmptyCards(Empty) returns (EmptyCardsReport); SERVICE_INDEX_NOTE_TYPES = 4;
rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut); SERVICE_INDEX_CONFIG = 5;
rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut); SERVICE_INDEX_CARD_RENDERING = 6;
rpc StripAVTags(String) returns (String); SERVICE_INDEX_DECK_CONFIG = 7;
SERVICE_INDEX_TAGS = 8;
// searching SERVICE_INDEX_SEARCH = 9;
SERVICE_INDEX_STATS = 10;
rpc BuildSearchString(SearchNode) returns (String); SERVICE_INDEX_MEDIA = 11;
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); SERVICE_INDEX_I18N = 12;
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); SERVICE_INDEX_COLLECTION = 13;
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); SERVICE_INDEX_CARDS = 14;
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); }
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
// scheduling
service SchedulingService {
rpc SchedTimingToday(Empty) returns (SchedTimingTodayOut); rpc SchedTimingToday(Empty) returns (SchedTimingTodayOut);
rpc StudiedToday(Empty) returns (String); rpc StudiedToday(Empty) returns (String);
rpc StudiedTodayMessage(StudiedTodayMessageIn) returns (String); rpc StudiedTodayMessage(StudiedTodayMessageIn) returns (String);
@ -125,24 +123,9 @@ service BackendService {
rpc AnswerCard(AnswerCardIn) returns (Empty); rpc AnswerCard(AnswerCardIn) returns (Empty);
rpc UpgradeScheduler(Empty) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty);
rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut);
}
// stats service DecksService {
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
rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyIn) returns (DeckID); rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyIn) returns (DeckID);
rpc DeckTree(DeckTreeIn) returns (DeckTreeNode); rpc DeckTree(DeckTreeIn) returns (DeckTreeNode);
rpc DeckTreeLegacy(Empty) returns (Json); rpc DeckTreeLegacy(Empty) returns (Json);
@ -153,25 +136,10 @@ service BackendService {
rpc NewDeckLegacy(Bool) returns (Json); rpc NewDeckLegacy(Bool) returns (Json);
rpc RemoveDecks(DeckIDs) returns (UInt32); rpc RemoveDecks(DeckIDs) returns (UInt32);
rpc DragDropDecks(DragDropDecksIn) returns (Empty); rpc DragDropDecks(DragDropDecksIn) returns (Empty);
rpc RenameDeck(RenameDeckIn) returns (Empty);
}
// deck config service NotesService {
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
rpc NewNote(NoteTypeID) returns (Note); rpc NewNote(NoteTypeID) returns (Note);
rpc AddNote(AddNoteIn) returns (NoteID); rpc AddNote(AddNoteIn) returns (NoteID);
rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype); rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype);
@ -186,28 +154,9 @@ service BackendService {
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote(NoteID) returns (CardIDs); rpc CardsOfNote(NoteID) returns (CardIDs);
}
// note types service SyncService {
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
rpc SyncMedia(SyncAuth) returns (Empty); rpc SyncMedia(SyncAuth) returns (Empty);
rpc AbortSync(Empty) returns (Empty); rpc AbortSync(Empty) returns (Empty);
rpc AbortMediaSync(Empty) returns (Empty); rpc AbortMediaSync(Empty) returns (Empty);
@ -218,26 +167,9 @@ service BackendService {
rpc FullUpload(SyncAuth) returns (Empty); rpc FullUpload(SyncAuth) returns (Empty);
rpc FullDownload(SyncAuth) returns (Empty); rpc FullDownload(SyncAuth) returns (Empty);
rpc SyncServerMethod(SyncServerMethodIn) returns (Json); rpc SyncServerMethod(SyncServerMethodIn) returns (Json);
}
// translation/messages/text manipulation service ConfigService {
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
rpc GetConfigJson(String) returns (Json); rpc GetConfigJson(String) returns (Json);
rpc SetConfigJson(SetConfigJsonIn) returns (Empty); rpc SetConfigJson(SetConfigJsonIn) returns (Empty);
rpc RemoveConfig(String) returns (Empty); rpc RemoveConfig(String) returns (Empty);
@ -246,13 +178,97 @@ service BackendService {
rpc SetConfigBool(SetConfigBoolIn) returns (Empty); rpc SetConfigBool(SetConfigBoolIn) returns (Empty);
rpc GetConfigString(Config.String) returns (String); rpc GetConfigString(Config.String) returns (String);
rpc SetConfigString(SetConfigStringIn) returns (Empty); rpc SetConfigString(SetConfigStringIn) returns (Empty);
// preferences
rpc GetPreferences(Empty) returns (Preferences); rpc GetPreferences(Empty) returns (Preferences);
rpc SetPreferences(Preferences) returns (Empty); 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 // Protobuf stored in .anki2 files
// These should be moved to a separate file in the future // These should be moved to a separate file in the future
/////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////
@ -1027,30 +1043,41 @@ message CheckDatabaseOut {
repeated string problems = 1; repeated string problems = 1;
} }
message CollectionSchedulingSettings { message Preferences {
enum NewReviewMix { message Scheduling {
DISTRIBUTE = 0; enum NewReviewMix {
REVIEWS_FIRST = 1; DISTRIBUTE = 0;
NEW_FIRST = 2; REVIEWS_FIRST = 1;
NEW_FIRST = 2;
}
// read only
uint32 scheduler_version = 1;
uint32 rollover = 2;
uint32 learn_ahead_secs = 3;
NewReviewMix new_review_mix = 4;
// v2 only
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;
} }
// read only Scheduling scheduling = 1;
uint32 scheduler_version = 1; Reviewing reviewing = 2;
Editing editing = 3;
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;
}
message Preferences {
CollectionSchedulingSettings sched = 1;
} }
message ClozeNumbersInNoteOut { message ClozeNumbersInNoteOut {
@ -1280,6 +1307,10 @@ message Config {
COLLAPSE_FLAGS = 8; COLLAPSE_FLAGS = 8;
SCHED_2021 = 9; SCHED_2021 = 9;
ADDING_DEFAULTS_TO_CURRENT_DECK = 10; 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; Key key = 1;
} }
@ -1423,3 +1454,8 @@ message DeckAndNotetype {
int64 deck_id = 1; int64 deck_id = 1;
int64 notetype_id = 2; 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 {} 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) { fn write_method_trait(buf: &mut String, service: &prost_build::Service) {
buf.push_str( buf.push_str(
r#" r#"
use prost::Message; pub trait Service {
pub type BackendResult<T> = std::result::Result<T, crate::err::AnkiError>; fn run_method(&self, method: u32, input: &[u8]) -> Result<Vec<u8>> {
pub trait BackendService {
fn run_command_bytes2_inner(&self, method: u32, input: &[u8]) -> std::result::Result<Vec<u8>, crate::err::AnkiError> {
match method { match method {
"#, "#,
); );
for (idx, method) in service.methods.iter().enumerate() { for (idx, method) in service.methods.iter().enumerate() {
write!( write!(
buf, buf,
@ -39,7 +21,7 @@ pub trait BackendService {
"{idx} => {{ let input = {input_type}::decode(input)?;\n", "{idx} => {{ let input = {input_type}::decode(input)?;\n",
"let output = self.{rust_method}(input)?;\n", "let output = self.{rust_method}(input)?;\n",
"let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; Ok(out_bytes) }}, "), "let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; Ok(out_bytes) }}, "),
idx = idx + 1, idx = idx,
input_type = method.input_type, input_type = method.input_type,
rust_method = method.name rust_method = method.name
) )
@ -58,7 +40,7 @@ pub trait BackendService {
buf, buf,
concat!( concat!(
" fn {method_name}(&self, input: {input_type}) -> ", " fn {method_name}(&self, input: {input_type}) -> ",
"BackendResult<{output_type}>;\n" "Result<{output_type}>;\n"
), ),
method_name = method.name, method_name = method.name,
input_type = method.input_type, input_type = method.input_type,
@ -71,8 +53,18 @@ pub trait BackendService {
impl prost_build::ServiceGenerator for CustomGenerator { impl prost_build::ServiceGenerator for CustomGenerator {
fn generate(&mut self, service: prost_build::Service, buf: &mut String) { 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); write_method_trait(buf, &service);
buf.push('}');
} }
} }

View File

@ -1,13 +1,60 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::convert::TryFrom; use std::convert::{TryFrom, TryInto};
use super::Backend;
use crate::prelude::*; use crate::prelude::*;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
card::{CardQueue, CardType}, 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 { impl TryFrom<pb::Card> for Card {
type Error = AnkiError; 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 // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::Backend;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
config::{BoolKey, StringKey}, config::{BoolKey, StringKey},
prelude::*,
}; };
use pb::config::bool::Key as BoolKeyProto; use pb::config::bool::Key as BoolKeyProto;
use pb::config::string::Key as StringKeyProto; 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 { impl From<BoolKeyProto> for BoolKey {
fn from(k: BoolKeyProto) -> Self { fn from(k: BoolKeyProto) -> Self {
@ -22,6 +26,10 @@ impl From<BoolKeyProto> for BoolKey {
BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags, BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags,
BoolKeyProto::Sched2021 => BoolKey::Sched2021, BoolKeyProto::Sched2021 => BoolKey::Sched2021,
BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck, 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 { impl From<pb::DeckConfigId> for DeckConfID {
fn from(dcid: pb::DeckConfigId) -> Self { fn from(dcid: pb::DeckConfigId) -> Self {
DeckConfID(dcid.dcid) 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 answering;
mod states; 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 itertools::Itertools;
use std::convert::{TryFrom, TryInto}; use std::convert::{TryFrom, TryInto};
use super::Backend;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
backend_proto::{ backend_proto::{
@ -12,11 +13,83 @@ use crate::{
config::SortKind, config::SortKind,
prelude::*, prelude::*,
search::{ search::{
parse_search, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node,
StateKind, TemplateKind, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind,
}, },
text::escape_anki_wildcards, 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 { impl TryFrom<pb::SearchNode> for Node {
type Error = AnkiError; 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 // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod server;
use std::sync::Arc; use std::sync::Arc;
use futures::future::{AbortHandle, AbortRegistration, Abortable}; use futures::future::{AbortHandle, AbortRegistration, Abortable};
@ -12,12 +14,20 @@ use crate::{
media::MediaManager, media::MediaManager,
prelude::*, prelude::*,
sync::{ sync::{
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, get_remote_sync_meta, http::SyncRequest, sync_abort, sync_login, FullSyncProgress,
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, LocalServer, NormalSyncProgress, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput,
}, },
}; };
use super::{progress::AbortHandleSlot, Backend}; 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)] #[derive(Default, Debug)]
pub(super) struct RemoteSyncStatus { 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 { impl Backend {
fn sync_abort_handle( fn sync_abort_handle(
&self, &self,
@ -104,11 +167,11 @@ impl Backend {
let (abort_handle, abort_reg) = AbortHandle::new_pair(); let (abort_handle, abort_reg) = AbortHandle::new_pair();
{ {
let mut guard = self.state.lock().unwrap(); 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 // media sync is already active
return Ok(()); return Ok(());
} else { } 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); let result = rt.block_on(abortable_sync);
// mark inactive // mark inactive
self.state.lock().unwrap().media_sync_abort.take(); self.state.lock().unwrap().sync.media_sync_abort.take();
// return result // return result
match result { match result {
@ -146,14 +209,14 @@ impl Backend {
/// Abort the media sync. Won't return until aborted. /// Abort the media sync. Won't return until aborted.
pub(super) fn abort_media_sync_and_wait(&self) { pub(super) fn abort_media_sync_and_wait(&self) {
let guard = self.state.lock().unwrap(); 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(); handle.abort();
self.progress_state.lock().unwrap().want_abort = true; self.progress_state.lock().unwrap().want_abort = true;
} }
drop(guard); drop(guard);
// block until it aborts // 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)); std::thread::sleep(std::time::Duration::from_millis(100));
self.progress_state.lock().unwrap().want_abort = true; 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 // return cached server response if only a short time has elapsed
{ {
let guard = self.state.lock().unwrap(); let guard = self.state.lock().unwrap();
if guard.remote_sync_status.last_check.elapsed_secs() < 300 { if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 {
return Ok(guard.remote_sync_status.last_response.into()); 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, // 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, // 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. // so we discard it if stale.
if guard.remote_sync_status.last_check < time_at_check_begin { if guard.sync.remote_sync_status.last_check < time_at_check_begin {
guard.remote_sync_status.last_check = time_at_check_begin; guard.sync.remote_sync_status.last_check = time_at_check_begin;
guard.remote_sync_status.last_response = response; guard.sync.remote_sync_status.last_response = response;
} }
} }
@ -247,6 +310,7 @@ impl Backend {
self.state self.state
.lock() .lock()
.unwrap() .unwrap()
.sync
.remote_sync_status .remote_sync_status
.update(output.required.into()); .update(output.required.into());
Ok(output.into()) Ok(output.into())
@ -302,6 +366,7 @@ impl Backend {
self.state self.state
.lock() .lock()
.unwrap() .unwrap()
.sync
.remote_sync_status .remote_sync_status
.update(pb::sync_status_out::Required::NoChanges); .update(pb::sync_status_out::Required::NoChanges);
} }

View File

@ -4,7 +4,7 @@
use std::{path::PathBuf, sync::MutexGuard}; use std::{path::PathBuf, sync::MutexGuard};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use super::{Backend, BackendState}; use crate::backend::{Backend, BackendState};
use crate::{ use crate::{
err::SyncErrorKind, err::SyncErrorKind,
prelude::*, prelude::*,
@ -24,16 +24,12 @@ impl Backend {
F: FnOnce(&mut LocalServer) -> Result<T>, F: FnOnce(&mut LocalServer) -> Result<T>,
{ {
let mut state_guard = self.state.lock().unwrap(); let mut state_guard = self.state.lock().unwrap();
let out = let out = func(state_guard.sync.http_sync_server.as_mut().ok_or_else(|| {
func( AnkiError::SyncError {
state_guard kind: SyncErrorKind::SyncNotStarted,
.http_sync_server info: Default::default(),
.as_mut() }
.ok_or_else(|| AnkiError::SyncError { })?);
kind: SyncErrorKind::SyncNotStarted,
info: Default::default(),
})?,
);
if out.is_err() { if out.is_err() {
self.abort_and_restore_collection(Some(state_guard)) 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> { fn take_server(&self, state_guard: Option<MutexGuard<BackendState>>) -> Result<LocalServer> {
let mut state_guard = state_guard.unwrap_or_else(|| self.state.lock().unwrap()); let mut state_guard = state_guard.unwrap_or_else(|| self.state.lock().unwrap());
state_guard state_guard
.sync
.http_sync_server .http_sync_server
.take() .take()
.ok_or_else(|| AnkiError::SyncError { .ok_or_else(|| AnkiError::SyncError {
@ -94,7 +91,7 @@ impl Backend {
// place col into new server // place col into new server
let server = self.col_into_server()?; let server = self.col_into_server()?;
let mut state_guard = self.state.lock().unwrap(); 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); drop(state_guard);
self.with_sync_server(|server| { 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, CollapseTags,
CollapseToday, CollapseToday,
FutureDueShowBacklog, FutureDueShowBacklog,
HideAudioPlayButtons,
InterruptAudioWhenAnswering,
PasteImagesAsPng,
PasteStripsFormatting,
PreviewBothSides, PreviewBothSides,
Sched2021, Sched2021,
@ -50,7 +54,9 @@ impl Collection {
} }
// some keys default to true // some keys default to true
BoolKey::AddingDefaultsToCurrentDeck BoolKey::InterruptAudioWhenAnswering
| BoolKey::ShowIntervalsAboveAnswerButtons
| BoolKey::AddingDefaultsToCurrentDeck
| BoolKey::FutureDueShowBacklog | BoolKey::FutureDueShowBacklog
| BoolKey::ShowRemainingDueCountsInStudy | BoolKey::ShowRemainingDueCountsInStudy
| BoolKey::CardCountsSeparateInactive | 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, deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto,
DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck, DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck,
}; };
use crate::{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::{ use crate::{
collection::Collection, collection::Collection,
deckconf::DeckConfID, deckconf::DeckConfID,
@ -263,53 +266,77 @@ impl Collection {
} }
/// Add or update an existing deck modified by the user. May add parents, /// 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<()> { 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()?; let usn = col.usn()?;
col.prepare_deck_for_update(deck, usn)?; col.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn); deck.set_modified(usn);
col.match_or_create_parents(deck, usn)?;
if deck.id.0 == 0 { col.add_deck_undoable(deck)
// 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;
if name_changed {
// match closest parent name
col.match_or_create_parents(deck, usn)?;
// rename children
col.rename_child_decks(&existing_deck, &deck.name, usn)?;
}
col.update_single_deck_undoable(deck, &existing_deck)?;
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)?;
}
Ok(())
} else {
Err(AnkiError::invalid_input("updating non-existent 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
self.match_or_create_parents(deck, usn)?;
// rename children
self.rename_child_decks(&original, &deck.name, usn)?;
}
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
self.create_missing_parents(&deck.name, usn)?;
}
Ok(())
}
/// Add/update a single deck when syncing/importing. Ensures name is unique /// Add/update a single deck when syncing/importing. Ensures name is unique
/// & normalized, but does not check parents/children or update mtime /// & normalized, but does not check parents/children or update mtime
/// (unless the name was changed). Caller must set up transaction. /// (unless the name was changed). Caller must set up transaction.
/// TODO: undo support
pub(crate) fn add_or_update_single_deck_with_existing_id( pub(crate) fn add_or_update_single_deck_with_existing_id(
&mut self, &mut self,
deck: &mut Deck, deck: &mut Deck,
usn: Usn, usn: Usn,
) -> Result<()> { ) -> Result<()> {
self.state.deck_cache.clear();
self.prepare_deck_for_update(deck, usn)?; 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<()> { 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")); let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f"));
child.name = new_name; child.name = new_name;
child.set_modified(usn); child.set_modified(usn);
self.update_single_deck_undoable(&mut child, &original)?; self.update_single_deck_undoable(&mut child, original)?;
} }
Ok(()) Ok(())
@ -367,12 +394,11 @@ impl Collection {
/// Add a single, normal deck with the provided name for a child deck. /// Add a single, normal deck with the provided name for a child deck.
/// Caller must have done necessarily validation on name. /// 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(); let mut deck = Deck::new_normal();
deck.name = machine_name.into(); deck.name = machine_name.into();
deck.set_modified(usn); deck.set_modified(usn);
// fixme: undo self.add_deck_undoable(&mut deck)
self.storage.add_deck(&mut deck)
} }
/// If parent deck(s) exist, rewrite name to match their case. /// 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) { while let Some(parent_name) = immediate_parent_name(machine_name) {
if self.storage.get_deck_id(parent_name)?.is_none() { if self.storage.get_deck_id(parent_name)?.is_none() {
self.add_parent_deck(parent_name, usn)?; 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> { 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; let mut card_count = 0;
self.transact(None, |col| { self.transact(None, |col| {
let usn = col.usn()?; let usn = col.usn()?;
for did in dids { for did in dids {
@ -466,7 +489,6 @@ impl Collection {
} }
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> { pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
// fixme: undo
let card_count = match deck.kind { let card_count = match deck.kind {
DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?,
DeckKind::Filtered(_) => { DeckKind::Filtered(_) => {
@ -476,14 +498,13 @@ impl Collection {
}; };
self.clear_aux_config_for_deck(deck.id)?; self.clear_aux_config_for_deck(deck.id)?;
if deck.id.0 == 1 { 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(); let mut deck = deck.to_owned();
// fixme: separate key
deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into(); deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into();
deck.set_modified(usn); deck.set_modified(usn);
self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?; self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?;
} else { } else {
self.storage.remove_deck(deck.id)?; self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?;
self.storage.add_deck_grave(deck.id, usn)?;
} }
Ok(card_count) Ok(card_count)
} }
@ -597,7 +618,7 @@ impl Collection {
deck.reset_stats_if_day_changed(today); deck.reset_stats_if_day_changed(today);
mutator(&mut deck.common); mutator(&mut deck.common);
deck.set_modified(usn); deck.set_modified(usn);
self.update_single_deck_undoable(deck, &original) self.update_single_deck_undoable(deck, original)
} }
pub fn drag_drop_decks( pub fn drag_drop_decks(
@ -605,9 +626,8 @@ impl Collection {
source_decks: &[DeckID], source_decks: &[DeckID],
target: Option<DeckID>, target: Option<DeckID>,
) -> Result<()> { ) -> Result<()> {
self.state.deck_cache.clear();
let usn = self.usn()?; let usn = self.usn()?;
self.transact(None, |col| { self.transact(Some(UndoableOpKind::RenameDeck), |col| {
let target_deck; let target_deck;
let mut target_name = None; let mut target_name = None;
if let Some(target) = target { if let Some(target) = target {
@ -622,18 +642,25 @@ impl Collection {
for source in source_decks { for source in source_decks {
if let Some(mut source) = col.storage.get_deck(*source)? { 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); let new_name = drag_drop_deck_name(&source.name, target_name);
if new_name == source.name { if new_name == source.name {
continue; 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; source.name = new_name;
col.ensure_deck_name_unique(&mut source, usn)?; col.ensure_deck_name_unique(&mut source, usn)?;
col.rename_child_decks(&orig, &source.name, usn)?; col.rename_child_decks(&orig, &source.name, usn)?;
source.set_modified(usn); col.update_single_deck_undoable(&mut source, orig)?;
col.storage.update_deck(&source)?;
// after updating, we need to ensure all grandparents exist, which may not be the case // after updating, we need to ensure all grandparents exist, which may not be the case
// in the parent->child 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)?; col.create_missing_parents(&source.name, usn)?;
} }
} }
@ -758,6 +785,23 @@ mod test {
col.add_or_update_deck(&mut middle)?; col.add_or_update_deck(&mut middle)?;
assert_eq!(middle.name, "other+"); 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(()) Ok(())
} }

View File

@ -6,32 +6,91 @@ use crate::prelude::*;
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum UndoableDeckChange { pub(crate) enum UndoableDeckChange {
Added(Box<Deck>),
Updated(Box<Deck>), Updated(Box<Deck>),
Removed(Box<Deck>),
GraveAdded(Box<(DeckID, Usn)>),
GraveRemoved(Box<(DeckID, Usn)>),
} }
impl Collection { impl Collection {
pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> { pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> {
match change { match change {
UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck),
UndoableDeckChange::Updated(mut deck) => { UndoableDeckChange::Updated(mut deck) => {
let current = self let current = self
.storage .storage
.get_deck(deck.id)? .get_deck(deck.id)?
.ok_or_else(|| AnkiError::invalid_input("deck disappeared"))?; .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 /// Update an individual, existing deck. Caller is responsible for ensuring deck
/// is normalized, matches parents, is not a duplicate name, and bumping mtime. /// is normalized, matches parents, is not a duplicate name, and bumping mtime.
/// Clears deck cache. /// Clears deck cache.
pub(super) fn update_single_deck_undoable( pub(super) fn update_single_deck_undoable(
&mut self, &mut self,
deck: &mut Deck, deck: &mut Deck,
original: &Deck, original: Deck,
) -> Result<()> { ) -> Result<()> {
self.state.deck_cache.clear(); 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) 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::{ use crate::{
backend_proto::{ backend_proto::{
collection_scheduling_settings::NewReviewMix as NewRevMixPB, CollectionSchedulingSettings, preferences::scheduling::NewReviewMix as NewRevMixPB,
preferences::{Editing, Reviewing, Scheduling},
Preferences, Preferences,
}, },
collection::Collection, collection::Collection,
@ -15,20 +16,37 @@ use crate::{
impl Collection { impl Collection {
pub fn get_preferences(&self) -> Result<Preferences> { pub fn get_preferences(&self) -> Result<Preferences> {
Ok(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<()> { pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
if let Some(sched) = prefs.sched { self.transact(
self.set_collection_scheduling_settings(sched)?; 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(()) Ok(())
} }
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> { pub fn get_scheduling_preferences(&self) -> Result<Scheduling> {
Ok(CollectionSchedulingSettings { Ok(Scheduling {
scheduler_version: match self.scheduler_version() { scheduler_version: match self.scheduler_version() {
crate::config::SchedulerVersion::V1 => 1, crate::config::SchedulerVersion::V1 => 1,
crate::config::SchedulerVersion::V2 => 2, crate::config::SchedulerVersion::V2 => 2,
@ -40,30 +58,15 @@ impl Collection {
crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst, crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst,
crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst, crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst,
} as i32, } 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(), new_timezone: self.get_creation_utc_offset().is_some(),
day_learn_first: self.get_bool(BoolKey::ShowDayLearningCardsFirst), day_learn_first: self.get_bool(BoolKey::ShowDayLearningCardsFirst),
}) })
} }
pub(crate) fn set_collection_scheduling_settings( pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> {
&mut self,
settings: CollectionSchedulingSettings,
) -> Result<()> {
let s = settings; let s = settings;
self.set_bool(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?; 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_learn_ahead_secs(s.learn_ahead_secs)?;
self.set_new_review_mix(match s.new_review_mix() { self.set_new_review_mix(match s.new_review_mix() {
@ -88,4 +91,52 @@ impl Collection {
Ok(()) 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, /// Used for syncing&undo; will keep existing ID. Shouldn't be used to add
/// since it does not allocate an id. /// new decks locally, since it does not allocate an id.
pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> { pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> {
if deck.id.0 == 0 { if deck.id.0 == 0 {
return Err(AnkiError::invalid_input("deck with id 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) 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> { pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result<Graves> {
let mut stmt = self.db.prepare(&format!( let mut stmt = self.db.prepare(&format!(
"select oid, type from graves where {}", "select oid, type from graves where {}",

View File

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

View File

@ -4,7 +4,7 @@
import { caretToEnd } from "./helpers"; import { caretToEnd } from "./helpers";
import { saveField } from "./changeTimer"; import { saveField } from "./changeTimer";
import { filterHTML } from "./htmlFilter"; import { filterHTML } from "./htmlFilter";
import { updateButtonState } from "./toolbar"; import { updateButtonState, disableButtons } from "./toolbar";
import { EditorField } from "./editorField"; import { EditorField } from "./editorField";
import { LabelContainer } from "./labelContainer"; import { LabelContainer } from "./labelContainer";
@ -113,6 +113,11 @@ export function setFields(fields: [string, string][]): void {
forEditorField(fields, (field, [name, fieldContent]) => forEditorField(fields, (field, [name, fieldContent]) =>
field.initialize(name, color, 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 { export function setBackgrounds(cols: ("dupe" | "")[]): void {