diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index e60c9a2a4..e3ae07497 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -11,7 +11,9 @@ undo-action-redone = { $action } redone undo-answer-card = Answer Card undo-unbury-unsuspend = Unbury/Unsuspend +undo-add-deck = Add Deck undo-add-note = Add Note undo-update-tag = Update Tag undo-update-note = Update Note undo-update-card = Update Card +undo-update-deck = Update Deck diff --git a/pylib/anki/_backend/BUILD.bazel b/pylib/anki/_backend/BUILD.bazel index c2667acf5..08bef7231 100644 --- a/pylib/anki/_backend/BUILD.bazel +++ b/pylib/anki/_backend/BUILD.bazel @@ -36,7 +36,7 @@ py_binary( genrule( name = "rsbackend_gen", outs = ["generated.py"], - cmd = "$(location genbackend) > $@", + cmd = "$(location genbackend) $@", tools = ["genbackend"], ) diff --git a/pylib/anki/_backend/__init__.py b/pylib/anki/_backend/__init__.py index d27f32c3a..92d07818a 100644 --- a/pylib/anki/_backend/__init__.py +++ b/pylib/anki/_backend/__init__.py @@ -95,10 +95,10 @@ class RustBackend(RustBackendGenerated): ) return self.format_timespan(seconds=seconds, context=context) - def _run_command(self, method: int, input: Any) -> bytes: + def _run_command(self, service: int, method: int, input: Any) -> bytes: input_bytes = input.SerializeToString() try: - return self._backend.command(method, input_bytes) + return self._backend.command(service, method, input_bytes) except Exception as e: err_bytes = bytes(e.args[0]) err = pb.BackendError() diff --git a/pylib/anki/_backend/genbackend.py b/pylib/anki/_backend/genbackend.py index 808c31689..64fff6da6 100755 --- a/pylib/anki/_backend/genbackend.py +++ b/pylib/anki/_backend/genbackend.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import os import re import sys +import google.protobuf.descriptor + import pylib.anki._backend.backend_pb2 as pb import stringcase @@ -97,7 +100,7 @@ def get_input_assign(msg): return ", ".join(f"{f.name}={f.name}" for f in fields) -def render_method(method, idx): +def render_method(service_idx, method_idx, method): input_name = method.input_type.name if ( (input_name.endswith("In") or len(method.input_type.fields) < 2) @@ -134,11 +137,11 @@ def render_method(method, idx): {input_assign_outer}""" if method.name in SKIP_DECODE: - buf += f"""return self._run_command({idx+1}, input) + buf += f"""return self._run_command({service_idx}, {method_idx}, input) """ else: buf += f"""output = pb.{method.output_type.name}() - output.ParseFromString(self._run_command({idx+1}, input)) + output.ParseFromString(self._run_command({service_idx}, {method_idx}, input)) return output{single_field} """ @@ -146,13 +149,29 @@ def render_method(method, idx): out = [] -for idx, method in enumerate(pb._BACKENDSERVICE.methods): - out.append(render_method(method, idx)) + + +def render_service( + service: google.protobuf.descriptor.ServiceDescriptor, service_index: int +) -> None: + for method_index, method in enumerate(service.methods): + out.append(render_method(service_index, method_index, method)) + + +for service in pb.ServiceIndex.DESCRIPTOR.values: + # SERVICE_INDEX_TEST -> _TESTSERVICE + service_var = ( + "_" + service.name.replace("SERVICE_INDEX", "").replace("_", "") + "SERVICE" + ) + service_obj = getattr(pb, service_var) + service_index = service.number + render_service(service_obj, service_index) + out = "\n".join(out) -sys.stdout.buffer.write( +open(sys.argv[1], "wb").write( ( '''# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html @@ -174,7 +193,7 @@ from typing import * import anki._backend.backend_pb2 as pb class RustBackendGenerated: - def _run_command(self, method: int, input: Any) -> bytes: + def _run_command(self, service: int, method: int, input: Any) -> bytes: raise Exception("not implemented") ''' diff --git a/pylib/anki/_backend/rsbridge.pyi b/pylib/anki/_backend/rsbridge.pyi index 126678bc9..9d0e23d6a 100644 --- a/pylib/anki/_backend/rsbridge.pyi +++ b/pylib/anki/_backend/rsbridge.pyi @@ -3,5 +3,5 @@ def open_backend(data: bytes) -> Backend: ... class Backend: @classmethod - def command(self, method: int, data: bytes) -> bytes: ... + def command(self, service: int, method: int, data: bytes) -> bytes: ... def db_command(self, data: bytes) -> bytes: ... diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 830e89e83..853ba991a 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -259,11 +259,13 @@ class DeckManager: deck=to_json_bytes(g), preserve_usn_and_mtime=preserve_usn ) - def rename(self, g: Deck, newName: str) -> None: + def rename(self, deck: Union[Deck, int], new_name: str) -> None: "Rename deck prefix to NAME if not exists. Updates children." - g["name"] = newName - self.update(g, preserve_usn=False) - return + if isinstance(deck, int): + deck_id = deck + else: + deck_id = deck["id"] + self.col._backend.rename_deck(deck_id=deck_id, new_name=new_name) # Drag/drop ############################################################# diff --git a/pylib/rsbridge/lib.rs b/pylib/rsbridge/lib.rs index 6c780f751..b7ce73955 100644 --- a/pylib/rsbridge/lib.rs +++ b/pylib/rsbridge/lib.rs @@ -1,15 +1,11 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use anki::backend::{init_backend, Backend as RustBackend, BackendMethod}; +use anki::backend::{init_backend, Backend as RustBackend}; use pyo3::exceptions::PyException; use pyo3::prelude::*; use pyo3::types::PyBytes; use pyo3::{create_exception, wrap_pyfunction}; -use std::convert::TryFrom; - -// Regular backend -////////////////////////////////// #[pyclass(module = "rsbridge")] struct Backend { @@ -31,50 +27,22 @@ fn open_backend(init_msg: &PyBytes) -> PyResult { } } -fn want_release_gil(method: u32) -> bool { - if let Ok(method) = BackendMethod::try_from(method) { - !matches!( - method, - BackendMethod::ExtractAVTags - | BackendMethod::ExtractLatex - | BackendMethod::RenderExistingCard - | BackendMethod::RenderUncommittedCard - | BackendMethod::StripAVTags - | BackendMethod::SchedTimingToday - | BackendMethod::AddOrUpdateDeckLegacy - | BackendMethod::NewDeckLegacy - | BackendMethod::NewDeckConfigLegacy - | BackendMethod::GetStockNotetypeLegacy - | BackendMethod::StudiedToday - | BackendMethod::TranslateString - | BackendMethod::FormatTimespan - | BackendMethod::LatestProgress - | BackendMethod::SetWantsAbort - | BackendMethod::I18nResources - | BackendMethod::JoinSearchNodes - | BackendMethod::ReplaceSearchNode - | BackendMethod::BuildSearchString - | BackendMethod::StateIsLeech - ) - } else { - false - } -} - #[pymethods] impl Backend { - fn command(&self, py: Python, method: u32, input: &PyBytes) -> PyResult { + fn command( + &self, + py: Python, + service: u32, + method: u32, + input: &PyBytes, + ) -> PyResult { let in_bytes = input.as_bytes(); - if want_release_gil(method) { - py.allow_threads(|| self.backend.run_command_bytes(method, in_bytes)) - } else { - self.backend.run_command_bytes(method, in_bytes) - } - .map(|out_bytes| { - let out_obj = PyBytes::new(py, &out_bytes); - out_obj.into() - }) - .map_err(BackendError::new_err) + py.allow_threads(|| self.backend.run_method(service, method, in_bytes)) + .map(|out_bytes| { + let out_obj = PyBytes::new(py, &out_bytes); + out_obj.into() + }) + .map_err(BackendError::new_err) } /// This takes and returns JSON, due to Python's slow protobuf diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index a48c8de9c..e80a7953f 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -259,7 +259,6 @@ class DeckBrowser: self.mw.onExport(did=did) def _rename(self, did: int) -> None: - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK)) deck = self.mw.col.decks.get(did) oldName = deck["name"] newName = getOnlyText(tr(TR.DECKS_NEW_DECK_NAME), default=oldName) @@ -272,6 +271,7 @@ class DeckBrowser: except DeckIsFilteredError as err: showWarning(str(err)) return + self.mw.update_undo_actions() self.show() def _options(self, did: str) -> None: @@ -288,23 +288,31 @@ class DeckBrowser: self._renderPage(reuse=True) def _handle_drag_and_drop(self, source: int, target: int) -> None: - try: + def process() -> None: self.mw.col.decks.drag_drop_decks([source], target) - except Exception as e: - showWarning(str(e)) - return - gui_hooks.sidebar_should_refresh_decks() - self.show() + + def on_done(fut: Future) -> None: + try: + fut.result() + except Exception as e: + showWarning(str(e)) + return + + self.mw.update_undo_actions() + gui_hooks.sidebar_should_refresh_decks() + self.show() + + self.mw.taskman.with_progress(process, on_done) def _delete(self, did: int) -> None: def do_delete() -> int: return self.mw.col.decks.remove([did]) def on_done(fut: Future) -> None: + self.mw.update_undo_actions() self.show() tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result())) - self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK)) self.mw.taskman.with_progress(do_delete, on_done) # Top buttons @@ -385,7 +393,7 @@ class DeckBrowser: defaultno=True, ): prefs = self.mw.col.get_preferences() - prefs.sched.new_timezone = False + prefs.scheduling.new_timezone = False self.mw.col.set_preferences(prefs) showInfo(tr(TR.SCHEDULING_UPDATE_DONE)) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 3346dfbc6..ec2d69dd8 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -20,7 +20,7 @@ from bs4 import BeautifulSoup import aqt import aqt.sound from anki.cards import Card -from anki.collection import SearchNode +from anki.collection import Config, SearchNode from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient @@ -781,7 +781,7 @@ class Editor: filter = f"{tr(TR.EDITING_MEDIA)} ({extension_filter})" def accept(file: str) -> None: - self.addMedia(file, canDelete=True) + self.addMedia(file) file = getFile( parent=self.widget, @@ -793,24 +793,18 @@ class Editor: self.parentWindow.activateWindow() def addMedia(self, path: str, canDelete: bool = False) -> None: + """canDelete is a legacy arg and is ignored.""" try: - html = self._addMedia(path, canDelete) + html = self._addMedia(path) except Exception as e: showWarning(str(e)) return self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});") def _addMedia(self, path: str, canDelete: bool = False) -> str: - "Add to media folder and return local img or sound tag." + """Add to media folder and return local img or sound tag.""" # copy to media folder fname = self.mw.col.media.addFile(path) - # remove original? - if canDelete and self.mw.pm.profile["deleteMedia"]: - if os.path.abspath(fname) != os.path.abspath(path): - try: - os.unlink(path) - except: - pass # return a local html link return self.fnameToLink(fname) @@ -1091,7 +1085,6 @@ class EditorWebView(AnkiWebView): def __init__(self, parent: QWidget, editor: Editor) -> None: AnkiWebView.__init__(self, title="editor") self.editor = editor - self.strip = self.editor.mw.pm.profile["stripHTML"] self.setAcceptDrops(True) self._markInternal = False clip = self.editor.mw.app.clipboard() @@ -1110,10 +1103,12 @@ class EditorWebView(AnkiWebView): self.triggerPageAction(QWebEnginePage.Copy) def _wantsExtendedPaste(self) -> bool: - extended = not (self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier) - if self.editor.mw.pm.profile.get("pasteInvert", False): - extended = not extended - return extended + strip_html = self.editor.mw.col.get_config_bool( + Config.Bool.PASTE_STRIPS_FORMATTING + ) + if self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier: + strip_html = not strip_html + return strip_html def _onPaste(self, mode: QClipboard.Mode) -> None: extended = self._wantsExtendedPaste() @@ -1240,7 +1235,7 @@ class EditorWebView(AnkiWebView): return None im = QImage(mime.imageData()) uname = namedtmp("paste") - if self.editor.mw.pm.profile.get("pastePNG", False): + if self.editor.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG): ext = ".png" im.save(uname + ext, None, 50) else: diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 3e5293461..3e950546f 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -80,7 +80,7 @@ - + PREFERENCES_PASTE_WITHOUT_SHIFT_KEY_STRIPS_FORMATTING @@ -589,7 +589,7 @@ showPlayButtons interrupt_audio pastePNG - pasteInvert + paste_strips_formatting nightMode useCurrent recording_driver diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 6a99be3cc..6907c716c 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -27,7 +27,7 @@ import aqt.toolbar import aqt.webview from anki import hooks from anki._backend import RustBackend as _RustBackend -from anki.collection import BackendUndo, Checkpoint, Collection, ReviewUndo +from anki.collection import BackendUndo, Checkpoint, Collection, Config, ReviewUndo from anki.decks import Deck from anki.hooks import runHook from anki.sound import AVTag, SoundOrVideoTag @@ -391,8 +391,6 @@ class AnkiQt(QMainWindow): if not self.loadCollection(): return - self.pm.apply_profile_options() - # show main window if self.pm.profile["mainWindowState"]: restoreGeom(self, "mainWindow") @@ -467,10 +465,10 @@ class AnkiQt(QMainWindow): def _add_play_buttons(self, text: str) -> str: "Return card text with play buttons added, or stripped." - if self.pm.profile.get("showPlayButtons", True): - return aqt.sound.av_refs_to_play_icons(text) - else: + if self.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS): return anki.sound.strip_av_refs(text) + else: + return aqt.sound.av_refs_to_play_icons(text) def prepare_card_text_for_display(self, text: str) -> str: text = self.col.media.escape_media_filenames(text) @@ -508,6 +506,7 @@ class AnkiQt(QMainWindow): try: self.update_undo_actions() gui_hooks.collection_did_load(self.col) + self.apply_collection_options() self.moveToState("deckBrowser") except Exception as e: # dump error to stderr so it gets picked up by errors.py @@ -572,6 +571,12 @@ class AnkiQt(QMainWindow): self.col.reopen(after_full_sync=False) self.col.close_for_full_sync() + def apply_collection_options(self) -> None: + "Setup audio after collection loaded." + aqt.sound.av_player.interrupt_current_audio = self.col.get_config_bool( + Config.Bool.INTERRUPT_AUDIO_WHEN_ANSWERING + ) + # Backup and auto-optimize ########################################################################## diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index aacd48a1d..64fe935e9 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -1,7 +1,9 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import anki.lang import aqt +from anki.consts import newCardSchedulingLabels from aqt import AnkiQt from aqt.profiles import RecordingDriver, VideoDriver from aqt.qt import * @@ -16,21 +18,6 @@ from aqt.utils import ( ) -def video_driver_name_for_platform(driver: VideoDriver) -> str: - if driver == VideoDriver.ANGLE: - return tr(TR.PREFERENCES_VIDEO_DRIVER_ANGLE) - elif driver == VideoDriver.Software: - if isMac: - return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_MAC) - else: - return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_OTHER) - else: - if isMac: - return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_MAC) - else: - return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_OTHER) - - class Preferences(QDialog): def __init__(self, mw: AnkiQt) -> None: QDialog.__init__(self, mw, Qt.Window) @@ -45,22 +32,18 @@ class Preferences(QDialog): self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES) ) self.silentlyClose = True - self.prefs = self.mw.col.get_preferences() - self.setupLang() - self.setupCollection() - self.setupNetwork() - self.setupBackup() - self.setupOptions() + self.setup_collection() + self.setup_profile() + self.setup_global() self.show() def accept(self) -> None: # avoid exception if main window is already closed if not self.mw.col: return - self.updateCollection() - self.updateNetwork() - self.updateBackup() - self.updateOptions() + self.update_collection() + self.update_profile() + self.update_global() self.mw.pm.save() self.mw.reset() self.done(0) @@ -69,16 +52,214 @@ class Preferences(QDialog): def reject(self) -> None: self.accept() - # Language + # Preferences stored in the collection ###################################################################### - def setupLang(self) -> None: + def setup_collection(self) -> None: + self.prefs = self.mw.col.get_preferences() + + form = self.form + + scheduling = self.prefs.scheduling + form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0)) + form.newSpread.addItems(list(newCardSchedulingLabels(self.mw.col).values())) + form.newSpread.setCurrentIndex(scheduling.new_review_mix) + form.dayLearnFirst.setChecked(scheduling.day_learn_first) + form.dayOffset.setValue(scheduling.rollover) + if scheduling.scheduler_version < 2: + form.dayLearnFirst.setVisible(False) + form.legacy_timezone.setVisible(False) + else: + form.legacy_timezone.setChecked(not scheduling.new_timezone) + + reviewing = self.prefs.reviewing + form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0)) + form.showEstimates.setChecked(reviewing.show_intervals_on_buttons) + form.showProgress.setChecked(reviewing.show_remaining_due_counts) + form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons) + form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering) + + editing = self.prefs.editing + form.useCurrent.setCurrentIndex( + 0 if editing.adding_defaults_to_current_deck else 1 + ) + form.paste_strips_formatting.setChecked(editing.paste_strips_formatting) + form.pastePNG.setChecked(editing.paste_images_as_png) + + def update_collection(self) -> None: + form = self.form + + scheduling = self.prefs.scheduling + scheduling.new_review_mix = form.newSpread.currentIndex() + scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60 + scheduling.day_learn_first = form.dayLearnFirst.isChecked() + scheduling.rollover = form.dayOffset.value() + scheduling.new_timezone = not form.legacy_timezone.isChecked() + + reviewing = self.prefs.reviewing + reviewing.show_remaining_due_counts = form.showProgress.isChecked() + reviewing.show_intervals_on_buttons = form.showEstimates.isChecked() + reviewing.time_limit_secs = form.timeLimit.value() * 60 + reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked() + reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked() + + editing = self.prefs.editing + editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex() + editing.paste_images_as_png = self.form.pastePNG.isChecked() + editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked() + + self.mw.col.set_preferences(self.prefs) + self.mw.apply_collection_options() + + # Preferences stored in the profile + ###################################################################### + + def setup_profile(self) -> None: + "Setup options stored in the user profile." + self.setup_recording_driver() + self.setup_network() + self.setup_backup() + + def update_profile(self) -> None: + self.update_recording_driver() + self.update_network() + self.update_backup() + + # Profile: recording driver + ###################################################################### + + def setup_recording_driver(self) -> None: + self._recording_drivers = [ + RecordingDriver.QtAudioInput, + RecordingDriver.PyAudio, + ] + # The plan is to phase out PyAudio soon, so will hold off on + # making this string translatable for now. + self.form.recording_driver.addItems( + [ + f"Voice recording driver: {driver.value}" + for driver in self._recording_drivers + ] + ) + self.form.recording_driver.setCurrentIndex( + self._recording_drivers.index(self.mw.pm.recording_driver()) + ) + + def update_recording_driver(self) -> None: + new_audio_driver = self._recording_drivers[ + self.form.recording_driver.currentIndex() + ] + if self.mw.pm.recording_driver() != new_audio_driver: + self.mw.pm.set_recording_driver(new_audio_driver) + if new_audio_driver == RecordingDriver.PyAudio: + showInfo( + """\ +The PyAudio driver will likely be removed in a future update. If you find it works better \ +for you than the default driver, please let us know on the Anki forums.""" + ) + + # Profile: network + ###################################################################### + + def setup_network(self) -> None: + self.form.media_log.setText(tr(TR.SYNC_MEDIA_LOG_BUTTON)) + qconnect(self.form.media_log.clicked, self.on_media_log) + self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"]) + self.form.syncMedia.setChecked(self.prof["syncMedia"]) + self.form.autoSyncMedia.setChecked(self.mw.pm.auto_sync_media_minutes() != 0) + if not self.prof["syncKey"]: + self._hide_sync_auth_settings() + else: + self.form.syncUser.setText(self.prof.get("syncUser", "")) + qconnect(self.form.syncDeauth.clicked, self.sync_logout) + self.form.syncDeauth.setText(tr(TR.SYNC_LOG_OUT_BUTTON)) + + def on_media_log(self) -> None: + self.mw.media_syncer.show_sync_log() + + def _hide_sync_auth_settings(self) -> None: + self.form.syncDeauth.setVisible(False) + self.form.syncUser.setText("") + self.form.syncLabel.setText( + tr(TR.PREFERENCES_SYNCHRONIZATIONNOT_CURRENTLY_ENABLED_CLICK_THE_SYNC) + ) + + def sync_logout(self) -> None: + if self.mw.media_syncer.is_syncing(): + showWarning("Can't log out while sync in progress.") + return + self.prof["syncKey"] = None + self.mw.col.media.force_resync() + self._hide_sync_auth_settings() + + def update_network(self) -> None: + self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked() + self.prof["syncMedia"] = self.form.syncMedia.isChecked() + self.mw.pm.set_auto_sync_media_minutes( + self.form.autoSyncMedia.isChecked() and 15 or 0 + ) + if self.form.fullSync.isChecked(): + self.mw.col.modSchema(check=False) + + # Profile: backup + ###################################################################### + + def setup_backup(self) -> None: + self.form.numBackups.setValue(self.prof["numBackups"]) + + def update_backup(self) -> None: + self.prof["numBackups"] = self.form.numBackups.value() + + # Global preferences + ###################################################################### + + def setup_global(self) -> None: + "Setup options global to all profiles." + self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100)) + self.form.nightMode.setChecked(self.mw.pm.night_mode()) + + self.setup_language() + self.setup_video_driver() + + self.setupOptions() + + def update_global(self) -> None: + restart_required = False + + self.update_video_driver() + + newScale = self.form.uiScale.value() / 100 + if newScale != self.mw.pm.uiScale(): + self.mw.pm.setUiScale(newScale) + restart_required = True + + if self.mw.pm.night_mode() != self.form.nightMode.isChecked(): + self.mw.pm.set_night_mode(not self.mw.pm.night_mode()) + restart_required = True + + if restart_required: + showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU)) + + self.updateOptions() + + # legacy - one of Henrik's add-ons is currently wrapping them + + def setupOptions(self) -> None: + pass + + def updateOptions(self) -> None: + pass + + # Global: language + ###################################################################### + + def setup_language(self) -> None: f = self.form f.lang.addItems([x[0] for x in anki.lang.langs]) - f.lang.setCurrentIndex(self.langIdx()) - qconnect(f.lang.currentIndexChanged, self.onLangIdxChanged) + f.lang.setCurrentIndex(self.current_lang_index()) + qconnect(f.lang.currentIndexChanged, self.on_language_index_changed) - def langIdx(self) -> int: + def current_lang_index(self) -> int: codes = [x[1] for x in anki.lang.langs] lang = anki.lang.currentLang if lang in anki.lang.compatMap: @@ -90,43 +271,16 @@ class Preferences(QDialog): except: return codes.index("en_US") - def onLangIdxChanged(self, idx: int) -> None: + def on_language_index_changed(self, idx: int) -> None: code = anki.lang.langs[idx][1] self.mw.pm.setLang(code) showInfo( tr(TR.PREFERENCES_PLEASE_RESTART_ANKI_TO_COMPLETE_LANGUAGE), parent=self ) - # Collection options + # Global: video driver ###################################################################### - def setupCollection(self) -> None: - import anki.consts as c - - f = self.form - qc = self.mw.col.conf - - self.setup_video_driver() - - f.newSpread.addItems(list(c.newCardSchedulingLabels(self.mw.col).values())) - - f.useCurrent.setCurrentIndex(int(not qc.get("addToCur", True))) - - s = self.prefs.sched - f.lrnCutoff.setValue(int(s.learn_ahead_secs / 60.0)) - f.timeLimit.setValue(int(s.time_limit_secs / 60.0)) - f.showEstimates.setChecked(s.show_intervals_on_buttons) - f.showProgress.setChecked(s.show_remaining_due_counts) - f.newSpread.setCurrentIndex(s.new_review_mix) - f.dayLearnFirst.setChecked(s.day_learn_first) - f.dayOffset.setValue(s.rollover) - - if s.scheduler_version < 2: - f.dayLearnFirst.setVisible(False) - f.legacy_timezone.setVisible(False) - else: - f.legacy_timezone.setChecked(not s.new_timezone) - def setup_video_driver(self) -> None: self.video_drivers = VideoDriver.all_for_platform() names = [ @@ -144,133 +298,17 @@ class Preferences(QDialog): self.mw.pm.set_video_driver(new_driver) showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU)) - def updateCollection(self) -> None: - f = self.form - d = self.mw.col - self.update_video_driver() - - qc = d.conf - qc["addToCur"] = not f.useCurrent.currentIndex() - - s = self.prefs.sched - s.show_remaining_due_counts = f.showProgress.isChecked() - s.show_intervals_on_buttons = f.showEstimates.isChecked() - s.new_review_mix = f.newSpread.currentIndex() - s.time_limit_secs = f.timeLimit.value() * 60 - s.learn_ahead_secs = f.lrnCutoff.value() * 60 - s.day_learn_first = f.dayLearnFirst.isChecked() - s.rollover = f.dayOffset.value() - s.new_timezone = not f.legacy_timezone.isChecked() - - self.mw.col.set_preferences(self.prefs) - - # Network - ###################################################################### - - def setupNetwork(self) -> None: - self.form.media_log.setText(tr(TR.SYNC_MEDIA_LOG_BUTTON)) - qconnect(self.form.media_log.clicked, self.on_media_log) - self.form.syncOnProgramOpen.setChecked(self.prof["autoSync"]) - self.form.syncMedia.setChecked(self.prof["syncMedia"]) - self.form.autoSyncMedia.setChecked(self.mw.pm.auto_sync_media_minutes() != 0) - if not self.prof["syncKey"]: - self._hideAuth() +def video_driver_name_for_platform(driver: VideoDriver) -> str: + if driver == VideoDriver.ANGLE: + return tr(TR.PREFERENCES_VIDEO_DRIVER_ANGLE) + elif driver == VideoDriver.Software: + if isMac: + return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_MAC) else: - self.form.syncUser.setText(self.prof.get("syncUser", "")) - qconnect(self.form.syncDeauth.clicked, self.onSyncDeauth) - self.form.syncDeauth.setText(tr(TR.SYNC_LOG_OUT_BUTTON)) - - def on_media_log(self) -> None: - self.mw.media_syncer.show_sync_log() - - def _hideAuth(self) -> None: - self.form.syncDeauth.setVisible(False) - self.form.syncUser.setText("") - self.form.syncLabel.setText( - tr(TR.PREFERENCES_SYNCHRONIZATIONNOT_CURRENTLY_ENABLED_CLICK_THE_SYNC) - ) - - def onSyncDeauth(self) -> None: - if self.mw.media_syncer.is_syncing(): - showWarning("Can't log out while sync in progress.") - return - self.prof["syncKey"] = None - self.mw.col.media.force_resync() - self._hideAuth() - - def updateNetwork(self) -> None: - self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked() - self.prof["syncMedia"] = self.form.syncMedia.isChecked() - self.mw.pm.set_auto_sync_media_minutes( - self.form.autoSyncMedia.isChecked() and 15 or 0 - ) - if self.form.fullSync.isChecked(): - self.mw.col.modSchema(check=False) - - # Backup - ###################################################################### - - def setupBackup(self) -> None: - self.form.numBackups.setValue(self.prof["numBackups"]) - - def updateBackup(self) -> None: - self.prof["numBackups"] = self.form.numBackups.value() - - # Basic & Advanced Options - ###################################################################### - - def setupOptions(self) -> None: - self.form.pastePNG.setChecked(self.prof.get("pastePNG", False)) - self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100)) - self.form.pasteInvert.setChecked(self.prof.get("pasteInvert", False)) - self.form.showPlayButtons.setChecked(self.prof.get("showPlayButtons", True)) - self.form.nightMode.setChecked(self.mw.pm.night_mode()) - self.form.interrupt_audio.setChecked(self.mw.pm.interrupt_audio()) - self._recording_drivers = [ - RecordingDriver.QtAudioInput, - RecordingDriver.PyAudio, - ] - # The plan is to phase out PyAudio soon, so will hold off on - # making this string translatable for now. - self.form.recording_driver.addItems( - [ - f"Voice recording driver: {driver.value}" - for driver in self._recording_drivers - ] - ) - self.form.recording_driver.setCurrentIndex( - self._recording_drivers.index(self.mw.pm.recording_driver()) - ) - - def updateOptions(self) -> None: - restart_required = False - - self.prof["pastePNG"] = self.form.pastePNG.isChecked() - self.prof["pasteInvert"] = self.form.pasteInvert.isChecked() - newScale = self.form.uiScale.value() / 100 - if newScale != self.mw.pm.uiScale(): - self.mw.pm.setUiScale(newScale) - restart_required = True - self.prof["showPlayButtons"] = self.form.showPlayButtons.isChecked() - - if self.mw.pm.night_mode() != self.form.nightMode.isChecked(): - self.mw.pm.set_night_mode(not self.mw.pm.night_mode()) - restart_required = True - - self.mw.pm.set_interrupt_audio(self.form.interrupt_audio.isChecked()) - - new_audio_driver = self._recording_drivers[ - self.form.recording_driver.currentIndex() - ] - if self.mw.pm.recording_driver() != new_audio_driver: - self.mw.pm.set_recording_driver(new_audio_driver) - if new_audio_driver == RecordingDriver.PyAudio: - showInfo( - """\ -The PyAudio driver will likely be removed in a future update. If you find it works better \ -for you than the default driver, please let us know on the Anki forums.""" - ) - - if restart_required: - showInfo(tr(TR.PREFERENCES_CHANGES_WILL_TAKE_EFFECT_WHEN_YOU)) + return tr(TR.PREFERENCES_VIDEO_DRIVER_SOFTWARE_OTHER) + else: + if isMac: + return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_MAC) + else: + return tr(TR.PREFERENCES_VIDEO_DRIVER_OPENGL_OTHER) diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 50bc6a3a6..200e11f80 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -89,14 +89,8 @@ profileConf: Dict[str, Any] = dict( numBackups=50, lastOptimize=intTime(), # editing - fullSearch=False, searchHistory=[], lastColour="#00f", - stripHTML=True, - pastePNG=False, - # not exposed in gui - deleteMedia=False, - preserveKeyboard=True, # syncing syncKey=None, syncMedia=True, @@ -104,6 +98,10 @@ profileConf: Dict[str, Any] = dict( # importing allowHTML=False, importMode=1, + # these are not used, but Anki 2.1.42 and below + # expect these keys to exist + stripHTML=True, + deleteMedia=False, ) @@ -617,13 +615,6 @@ create table if not exists profiles # Profile-specific ###################################################################### - def interrupt_audio(self) -> bool: - return self.profile.get("interrupt_audio", True) - - def set_interrupt_audio(self, val: bool) -> None: - self.profile["interrupt_audio"] = val - aqt.sound.av_player.interrupt_current_audio = val - def set_sync_key(self, val: Optional[str]) -> None: self.profile["syncKey"] = val @@ -667,8 +658,3 @@ create table if not exists profiles def set_recording_driver(self, driver: RecordingDriver) -> None: self.profile["recordingDriver"] = driver.value - - ###################################################################### - - def apply_profile_options(self) -> None: - aqt.sound.av_player.interrupt_current_audio = self.interrupt_audio() diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 6ce7d3427..da4905b20 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -593,9 +593,9 @@ class SidebarTreeView(QTreeView): return self.refresh() self.mw.deckBrowser.refresh() + self.mw.update_undo_actions() def on_save() -> None: - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK)) self.browser.model.beginReset() self.mw.taskman.with_progress( lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done @@ -1130,7 +1130,6 @@ class SidebarTreeView(QTreeView): def rename_deck(self, item: SidebarItem, new_name: str) -> None: deck = self.mw.col.decks.get(item.id) new_name = item.name_prefix + new_name - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_DECK)) try: self.mw.col.decks.rename(deck, new_name) except DeckIsFilteredError as err: @@ -1141,6 +1140,7 @@ class SidebarTreeView(QTreeView): and other.id == item.id ) self.mw.deckBrowser.refresh() + self.mw.update_undo_actions() def remove_tags(self, item: SidebarItem) -> None: self.browser.editor.saveNow(lambda: self._remove_tags(item)) @@ -1210,7 +1210,6 @@ class SidebarTreeView(QTreeView): self.refresh() dids = self._selected_decks() - self.mw.checkpoint(tr(TR.DECKS_DELETE_DECK)) self.browser.model.beginReset() self.mw.taskman.with_progress(do_delete, on_done) diff --git a/rslib/backend.proto b/rslib/backend.proto index 45ae4b6ed..a18f34dc6 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -76,33 +76,31 @@ message DeckConfigID { int64 dcid = 1; } -// New style RPC definitions +// Backend methods /////////////////////////////////////////////////////////// -service BackendService { - rpc LatestProgress(Empty) returns (Progress); - rpc SetWantsAbort(Empty) returns (Empty); - - // card rendering - - rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut); - rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut); - rpc GetEmptyCards(Empty) returns (EmptyCardsReport); - rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut); - rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut); - rpc StripAVTags(String) returns (String); - - // searching - - rpc BuildSearchString(SearchNode) returns (String); - rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); - rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); - rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); - rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); - rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); - - // scheduling +/// while the protobuf descriptors expose the order services are defined in, +/// that information is not available in prost, so we define an enum to make +/// sure all clients agree on the service index +enum ServiceIndex { + SERVICE_INDEX_SCHEDULING = 0; + SERVICE_INDEX_DECKS = 1; + SERVICE_INDEX_NOTES = 2; + SERVICE_INDEX_SYNC = 3; + SERVICE_INDEX_NOTE_TYPES = 4; + SERVICE_INDEX_CONFIG = 5; + SERVICE_INDEX_CARD_RENDERING = 6; + SERVICE_INDEX_DECK_CONFIG = 7; + SERVICE_INDEX_TAGS = 8; + SERVICE_INDEX_SEARCH = 9; + SERVICE_INDEX_STATS = 10; + SERVICE_INDEX_MEDIA = 11; + SERVICE_INDEX_I18N = 12; + SERVICE_INDEX_COLLECTION = 13; + SERVICE_INDEX_CARDS = 14; +} +service SchedulingService { rpc SchedTimingToday(Empty) returns (SchedTimingTodayOut); rpc StudiedToday(Empty) returns (String); rpc StudiedTodayMessage(StudiedTodayMessageIn) returns (String); @@ -125,24 +123,9 @@ service BackendService { rpc AnswerCard(AnswerCardIn) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty); rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); +} - // stats - - rpc CardStats(CardID) returns (String); - rpc Graphs(GraphsIn) returns (GraphsOut); - rpc GetGraphPreferences(Empty) returns (GraphPreferences); - rpc SetGraphPreferences(GraphPreferences) returns (Empty); - - // media - - rpc CheckMedia(Empty) returns (CheckMediaOut); - rpc TrashMediaFiles(TrashMediaFilesIn) returns (Empty); - rpc AddMediaFile(AddMediaFileIn) returns (String); - rpc EmptyTrash(Empty) returns (Empty); - rpc RestoreTrash(Empty) returns (Empty); - - // decks - +service DecksService { rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyIn) returns (DeckID); rpc DeckTree(DeckTreeIn) returns (DeckTreeNode); rpc DeckTreeLegacy(Empty) returns (Json); @@ -153,25 +136,10 @@ service BackendService { rpc NewDeckLegacy(Bool) returns (Json); rpc RemoveDecks(DeckIDs) returns (UInt32); rpc DragDropDecks(DragDropDecksIn) returns (Empty); + rpc RenameDeck(RenameDeckIn) returns (Empty); +} - // deck config - - rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn) - returns (DeckConfigID); - rpc AllDeckConfigLegacy(Empty) returns (Json); - rpc GetDeckConfigLegacy(DeckConfigID) returns (Json); - rpc NewDeckConfigLegacy(Empty) returns (Json); - rpc RemoveDeckConfig(DeckConfigID) returns (Empty); - - // cards - - rpc GetCard(CardID) returns (Card); - rpc UpdateCard(UpdateCardIn) returns (Empty); - rpc RemoveCards(RemoveCardsIn) returns (Empty); - rpc SetDeck(SetDeckIn) returns (Empty); - - // notes - +service NotesService { rpc NewNote(NoteTypeID) returns (Note); rpc AddNote(AddNoteIn) returns (NoteID); rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype); @@ -186,28 +154,9 @@ service BackendService { rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); rpc CardsOfNote(NoteID) returns (CardIDs); +} - // note types - - rpc AddOrUpdateNotetype(AddOrUpdateNotetypeIn) returns (NoteTypeID); - rpc GetStockNotetypeLegacy(StockNoteType) returns (Json); - rpc GetNotetypeLegacy(NoteTypeID) returns (Json); - rpc GetNotetypeNames(Empty) returns (NoteTypeNames); - rpc GetNotetypeNamesAndCounts(Empty) returns (NoteTypeUseCounts); - rpc GetNotetypeIDByName(String) returns (NoteTypeID); - rpc RemoveNotetype(NoteTypeID) returns (Empty); - - // collection - - rpc OpenCollection(OpenCollectionIn) returns (Empty); - rpc CloseCollection(CloseCollectionIn) returns (Empty); - rpc CheckDatabase(Empty) returns (CheckDatabaseOut); - rpc GetUndoStatus(Empty) returns (UndoStatus); - rpc Undo(Empty) returns (UndoStatus); - rpc Redo(Empty) returns (UndoStatus); - - // sync - +service SyncService { rpc SyncMedia(SyncAuth) returns (Empty); rpc AbortSync(Empty) returns (Empty); rpc AbortMediaSync(Empty) returns (Empty); @@ -218,26 +167,9 @@ service BackendService { rpc FullUpload(SyncAuth) returns (Empty); rpc FullDownload(SyncAuth) returns (Empty); rpc SyncServerMethod(SyncServerMethodIn) returns (Json); +} - // translation/messages/text manipulation - - rpc TranslateString(TranslateStringIn) returns (String); - rpc FormatTimespan(FormatTimespanIn) returns (String); - rpc I18nResources(Empty) returns (Json); - rpc RenderMarkdown(RenderMarkdownIn) returns (String); - - // tags - - rpc ClearUnusedTags(Empty) returns (Empty); - rpc AllTags(Empty) returns (StringList); - rpc ExpungeTags(String) returns (UInt32); - rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); - rpc ClearTag(String) returns (Empty); - rpc TagTree(Empty) returns (TagTreeNode); - rpc DragDropTags(DragDropTagsIn) returns (Empty); - - // config - +service ConfigService { rpc GetConfigJson(String) returns (Json); rpc SetConfigJson(SetConfigJsonIn) returns (Empty); rpc RemoveConfig(String) returns (Empty); @@ -246,13 +178,97 @@ service BackendService { rpc SetConfigBool(SetConfigBoolIn) returns (Empty); rpc GetConfigString(Config.String) returns (String); rpc SetConfigString(SetConfigStringIn) returns (Empty); - - // preferences - rpc GetPreferences(Empty) returns (Preferences); rpc SetPreferences(Preferences) returns (Empty); } +service NoteTypesService { + rpc AddOrUpdateNotetype(AddOrUpdateNotetypeIn) returns (NoteTypeID); + rpc GetStockNotetypeLegacy(StockNoteType) returns (Json); + rpc GetNotetypeLegacy(NoteTypeID) returns (Json); + rpc GetNotetypeNames(Empty) returns (NoteTypeNames); + rpc GetNotetypeNamesAndCounts(Empty) returns (NoteTypeUseCounts); + rpc GetNotetypeIDByName(String) returns (NoteTypeID); + rpc RemoveNotetype(NoteTypeID) returns (Empty); +} + +service CardRenderingService { + rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut); + rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut); + rpc GetEmptyCards(Empty) returns (EmptyCardsReport); + rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut); + rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut); + rpc StripAVTags(String) returns (String); + rpc RenderMarkdown(RenderMarkdownIn) returns (String); +} + +service DeckConfigService { + rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn) + returns (DeckConfigID); + rpc AllDeckConfigLegacy(Empty) returns (Json); + rpc GetDeckConfigLegacy(DeckConfigID) returns (Json); + rpc NewDeckConfigLegacy(Empty) returns (Json); + rpc RemoveDeckConfig(DeckConfigID) returns (Empty); +} + +service TagsService { + rpc ClearUnusedTags(Empty) returns (Empty); + rpc AllTags(Empty) returns (StringList); + rpc ExpungeTags(String) returns (UInt32); + rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); + rpc ClearTag(String) returns (Empty); + rpc TagTree(Empty) returns (TagTreeNode); + rpc DragDropTags(DragDropTagsIn) returns (Empty); +} + +service SearchService { + rpc BuildSearchString(SearchNode) returns (String); + rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); + rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); + rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); + rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); + rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); +} + +service StatsService { + rpc CardStats(CardID) returns (String); + rpc Graphs(GraphsIn) returns (GraphsOut); + rpc GetGraphPreferences(Empty) returns (GraphPreferences); + rpc SetGraphPreferences(GraphPreferences) returns (Empty); +} + +service MediaService { + rpc CheckMedia(Empty) returns (CheckMediaOut); + rpc TrashMediaFiles(TrashMediaFilesIn) returns (Empty); + rpc AddMediaFile(AddMediaFileIn) returns (String); + rpc EmptyTrash(Empty) returns (Empty); + rpc RestoreTrash(Empty) returns (Empty); +} + +service I18nService { + rpc TranslateString(TranslateStringIn) returns (String); + rpc FormatTimespan(FormatTimespanIn) returns (String); + rpc I18nResources(Empty) returns (Json); +} + +service CollectionService { + rpc OpenCollection(OpenCollectionIn) returns (Empty); + rpc CloseCollection(CloseCollectionIn) returns (Empty); + rpc CheckDatabase(Empty) returns (CheckDatabaseOut); + rpc GetUndoStatus(Empty) returns (UndoStatus); + rpc Undo(Empty) returns (UndoStatus); + rpc Redo(Empty) returns (UndoStatus); + rpc LatestProgress(Empty) returns (Progress); + rpc SetWantsAbort(Empty) returns (Empty); +} + +service CardsService { + rpc GetCard(CardID) returns (Card); + rpc UpdateCard(UpdateCardIn) returns (Empty); + rpc RemoveCards(RemoveCardsIn) returns (Empty); + rpc SetDeck(SetDeckIn) returns (Empty); +} + // Protobuf stored in .anki2 files // These should be moved to a separate file in the future /////////////////////////////////////////////////////////// @@ -1027,30 +1043,41 @@ message CheckDatabaseOut { repeated string problems = 1; } -message CollectionSchedulingSettings { - enum NewReviewMix { - DISTRIBUTE = 0; - REVIEWS_FIRST = 1; - NEW_FIRST = 2; +message Preferences { + message Scheduling { + enum NewReviewMix { + DISTRIBUTE = 0; + 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 - uint32 scheduler_version = 1; - - 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; + Scheduling scheduling = 1; + Reviewing reviewing = 2; + Editing editing = 3; } message ClozeNumbersInNoteOut { @@ -1280,6 +1307,10 @@ message Config { COLLAPSE_FLAGS = 8; SCHED_2021 = 9; ADDING_DEFAULTS_TO_CURRENT_DECK = 10; + HIDE_AUDIO_PLAY_BUTTONS = 11; + INTERRUPT_AUDIO_WHEN_ANSWERING = 12; + PASTE_IMAGES_AS_PNG = 13; + PASTE_STRIPS_FORMATTING = 14; } Key key = 1; } @@ -1423,3 +1454,8 @@ message DeckAndNotetype { int64 deck_id = 1; int64 notetype_id = 2; } + +message RenameDeckIn { + int64 deck_id = 1; + string new_name = 2; +} \ No newline at end of file diff --git a/rslib/build/protobuf.rs b/rslib/build/protobuf.rs index 93143af4a..7ba44254d 100644 --- a/rslib/build/protobuf.rs +++ b/rslib/build/protobuf.rs @@ -6,32 +6,14 @@ use std::{env, fmt::Write}; struct CustomGenerator {} -fn write_method_enum(buf: &mut String, service: &prost_build::Service) { - buf.push_str( - r#" -use num_enum::TryFromPrimitive; -#[derive(PartialEq,TryFromPrimitive)] -#[repr(u32)] -pub enum BackendMethod { -"#, - ); - for (idx, method) in service.methods.iter().enumerate() { - writeln!(buf, " {} = {},", method.proto_name, idx + 1).unwrap(); - } - buf.push_str("}\n\n"); -} - fn write_method_trait(buf: &mut String, service: &prost_build::Service) { buf.push_str( r#" -use prost::Message; -pub type BackendResult = std::result::Result; -pub trait BackendService { - fn run_command_bytes2_inner(&self, method: u32, input: &[u8]) -> std::result::Result, crate::err::AnkiError> { +pub trait Service { + fn run_method(&self, method: u32, input: &[u8]) -> Result> { match method { "#, ); - for (idx, method) in service.methods.iter().enumerate() { write!( buf, @@ -39,7 +21,7 @@ pub trait BackendService { "{idx} => {{ let input = {input_type}::decode(input)?;\n", "let output = self.{rust_method}(input)?;\n", "let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; Ok(out_bytes) }}, "), - idx = idx + 1, + idx = idx, input_type = method.input_type, rust_method = method.name ) @@ -58,7 +40,7 @@ pub trait BackendService { buf, concat!( " fn {method_name}(&self, input: {input_type}) -> ", - "BackendResult<{output_type}>;\n" + "Result<{output_type}>;\n" ), method_name = method.name, input_type = method.input_type, @@ -71,8 +53,18 @@ pub trait BackendService { impl prost_build::ServiceGenerator for CustomGenerator { fn generate(&mut self, service: prost_build::Service, buf: &mut String) { - write_method_enum(buf, &service); + write!( + buf, + "pub mod {name}_service {{ + use super::*; + use prost::Message; + use crate::err::Result; + ", + name = service.name.replace("Service", "").to_ascii_lowercase() + ) + .unwrap(); write_method_trait(buf, &service); + buf.push('}'); } } diff --git a/rslib/src/backend/card.rs b/rslib/src/backend/card.rs index c859d8312..8172f3f56 100644 --- a/rslib/src/backend/card.rs +++ b/rslib/src/backend/card.rs @@ -1,13 +1,60 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::convert::TryFrom; +use std::convert::{TryFrom, TryInto}; +use super::Backend; use crate::prelude::*; use crate::{ backend_proto as pb, card::{CardQueue, CardType}, }; +pub(super) use pb::cards_service::Service as CardsService; + +impl CardsService for Backend { + fn get_card(&self, input: pb::CardId) -> Result { + 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 { + 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 { + self.with_col(|col| { + col.transact(None, |col| { + col.remove_cards_and_orphaned_notes( + &input + .card_ids + .into_iter() + .map(Into::into) + .collect::>(), + )?; + Ok(().into()) + }) + }) + } + + fn set_deck(&self, input: pb::SetDeckIn) -> Result { + 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 for Card { type Error = AnkiError; @@ -39,3 +86,28 @@ impl TryFrom for Card { }) } } + +impl From 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, + } + } +} diff --git a/rslib/src/backend/cardrendering.rs b/rslib/src/backend/cardrendering.rs new file mode 100644 index 000000000..a245973af --- /dev/null +++ b/rslib/src/backend/cardrendering.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + Ok(pb::String { + val: strip_av_tags(&input.val).into(), + }) + } + + fn render_markdown(&self, input: pb::RenderMarkdownIn) -> Result { + 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) -> Vec { + 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 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), + } + } +} diff --git a/rslib/src/backend/collection.rs b/rslib/src/backend/collection.rs new file mode 100644 index 000000000..6153d1f9d --- /dev/null +++ b/rslib/src/backend/collection.rs @@ -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 { + 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 { + self.progress_state.lock().unwrap().want_abort = true; + Ok(().into()) + } + + fn open_collection(&self, input: pb::OpenCollectionIn) -> Result { + 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 { + 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 { + 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 { + self.with_col(|col| Ok(col.undo_status())) + } + + fn undo(&self, _input: pb::Empty) -> Result { + self.with_col(|col| { + col.undo()?; + Ok(col.undo_status()) + }) + } + + fn redo(&self, _input: pb::Empty) -> Result { + self.with_col(|col| { + col.redo()?; + Ok(col.undo_status()) + }) + } +} diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index fcac7d7cc..965be7e6d 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -1,12 +1,16 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use super::Backend; use crate::{ backend_proto as pb, config::{BoolKey, StringKey}, + prelude::*, }; use pb::config::bool::Key as BoolKeyProto; use pb::config::string::Key as StringKeyProto; +pub(super) use pb::config_service::Service as ConfigService; +use serde_json::Value; impl From for BoolKey { fn from(k: BoolKeyProto) -> Self { @@ -22,6 +26,10 @@ impl From for BoolKey { BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags, BoolKeyProto::Sched2021 => BoolKey::Sched2021, BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck, + BoolKeyProto::HideAudioPlayButtons => BoolKey::HideAudioPlayButtons, + BoolKeyProto::InterruptAudioWhenAnswering => BoolKey::InterruptAudioWhenAnswering, + BoolKeyProto::PasteImagesAsPng => BoolKey::PasteImagesAsPng, + BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting, } } } @@ -34,3 +42,75 @@ impl From for StringKey { } } } + +impl ConfigService for Backend { + fn get_config_json(&self, input: pb::String) -> Result { + self.with_col(|col| { + let val: Option = 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 { + 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 { + 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 { + 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 { + self.with_col(|col| { + Ok(pb::Bool { + val: col.get_bool(input.key().into()), + }) + }) + } + + fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result { + 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 { + self.with_col(|col| { + Ok(pb::String { + val: col.get_string(input.key().into()), + }) + }) + } + + fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result { + 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 { + self.with_col(|col| col.get_preferences()) + } + + fn set_preferences(&self, input: pb::Preferences) -> Result { + self.with_col(|col| col.set_preferences(input)) + .map(Into::into) + } +} diff --git a/rslib/src/backend/deckconfig.rs b/rslib/src/backend/deckconfig.rs new file mode 100644 index 000000000..9b20fe8f7 --- /dev/null +++ b/rslib/src/backend/deckconfig.rs @@ -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 { + 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 { + self.with_col(|col| { + let conf: Vec = 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 { + 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 { + serde_json::to_vec(&DeckConfSchema11::default()) + .map_err(Into::into) + .map(Into::into) + } + + fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result { + self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into()))) + .map(Into::into) + } +} diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs new file mode 100644 index 000000000..ab1fae037 --- /dev/null +++ b/rslib/src/backend/decks.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + self.with_col(|col| col.remove_decks_and_child_decks(&Into::>::into(input))) + .map(Into::into) + } + + fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result { + 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 { + self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name)) + .map(Into::into) + } +} + +impl From for DeckID { + fn from(did: pb::DeckId) -> Self { + DeckID(did.did) + } +} + +impl From for Vec { + fn from(dids: pb::DeckIDs) -> Self { + dids.dids.into_iter().map(DeckID).collect() + } +} + +impl From for pb::DeckId { + fn from(did: DeckID) -> Self { + pb::DeckId { did: did.0 } + } +} diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs index 68664954f..3aae3b721 100644 --- a/rslib/src/backend/generic.rs +++ b/rslib/src/backend/generic.rs @@ -69,24 +69,6 @@ impl From for NoteTypeID { } } -impl From for DeckID { - fn from(did: pb::DeckId) -> Self { - DeckID(did.did) - } -} - -impl From for Vec { - fn from(dids: pb::DeckIDs) -> Self { - dids.dids.into_iter().map(DeckID).collect() - } -} - -impl From for pb::DeckId { - fn from(did: DeckID) -> Self { - pb::DeckId { did: did.0 } - } -} - impl From for DeckConfID { fn from(dcid: pb::DeckConfigId) -> Self { DeckConfID(dcid.dcid) diff --git a/rslib/src/backend/i18n.rs b/rslib/src/backend/i18n.rs new file mode 100644 index 000000000..6a17a517e --- /dev/null +++ b/rslib/src/backend/i18n.rs @@ -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 { + 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 { + 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 { + 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()), + } +} diff --git a/rslib/src/backend/media.rs b/rslib/src/backend/media.rs new file mode 100644 index 000000000..f36a92c60 --- /dev/null +++ b/rslib/src/backend/media.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index fc9b37ca4..a13cb88f9 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -3,74 +3,62 @@ mod adding; mod card; +mod cardrendering; +mod collection; mod config; mod dbproxy; +mod deckconfig; +mod decks; mod err; mod generic; -mod http_sync_server; +mod i18n; +mod media; +mod notes; +mod notetypes; mod progress; mod scheduler; mod search; +mod stats; mod sync; +mod tags; + +use self::{ + card::CardsService, + cardrendering::CardRenderingService, + collection::CollectionService, + config::ConfigService, + deckconfig::DeckConfigService, + decks::DecksService, + i18n::I18nService, + media::MediaService, + notes::NotesService, + notetypes::NoteTypesService, + progress::ProgressState, + scheduler::SchedulingService, + search::SearchService, + stats::StatsService, + sync::{SyncService, SyncState}, + tags::TagsService, +}; -pub use crate::backend_proto::BackendMethod; use crate::{ backend::dbproxy::db_command_bytes, backend_proto as pb, - backend_proto::{ - AddOrUpdateDeckConfigLegacyIn, BackendResult, Empty, RenderedTemplateReplacement, - }, - card::{Card, CardID}, - cloze::add_cloze_numbers_in_string, - collection::{open_collection, Collection}, - deckconf::{DeckConf, DeckConfSchema11}, - decks::{Deck, DeckID, DeckSchema11}, + collection::Collection, err::{AnkiError, Result}, i18n::I18n, - latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex}, log, - log::default_logger, - markdown::render_markdown, - media::check::MediaChecker, - media::MediaManager, - notes::{Note, NoteID}, - notetype::{ - all_stock_notetypes, CardTemplateSchema11, NoteType, NoteTypeSchema11, RenderCardOutput, - }, - scheduler::{ - new::NewCardSortOrder, - parse_due_date_str, - states::{CardState, NextCardStates}, - timespan::{answer_button_time, time_span}, - }, - search::{concatenate_searches, replace_search_node, write_nodes, Node}, - stats::studied_today, - sync::{http::SyncRequest, LocalServer}, - template::RenderedNode, - text::{extract_av_tags, sanitize_html_no_images, strip_av_tags, AVTag}, - timestamp::TimestampSecs, - undo::UndoableOpKind, }; -use fluent::FluentValue; -use futures::future::AbortHandle; -use log::error; use once_cell::sync::OnceCell; -use pb::BackendService; -use progress::{AbortHandleSlot, Progress}; +use progress::AbortHandleSlot; use prost::Message; -use serde_json::Value as JsonValue; -use std::{collections::HashSet, convert::TryInto}; use std::{ result, sync::{Arc, Mutex}, }; use tokio::runtime::{self, Runtime}; -use self::{ - err::anki_error_to_proto_error, - progress::{progress_to_proto, ProgressState}, - sync::RemoteSyncStatus, -}; +use self::err::anki_error_to_proto_error; pub struct Backend { col: Arc>>, @@ -82,13 +70,9 @@ pub struct Backend { state: Arc>, } -// fixme: move other items like runtime into here as well - #[derive(Default)] struct BackendState { - remote_sync_status: RemoteSyncStatus, - media_sync_abort: Option, - http_sync_server: Option, + sync: SyncState, } pub fn init_backend(init_msg: &[u8]) -> std::result::Result { @@ -106,1228 +90,6 @@ pub fn init_backend(init_msg: &[u8]) -> std::result::Result { Ok(Backend::new(i18n, input.server)) } -impl BackendService for Backend { - fn latest_progress(&self, _input: Empty) -> BackendResult { - let progress = self.progress_state.lock().unwrap().last_progress; - Ok(progress_to_proto(progress, &self.i18n)) - } - - fn set_wants_abort(&self, _input: Empty) -> BackendResult { - self.progress_state.lock().unwrap().want_abort = true; - Ok(().into()) - } - - // card rendering - - fn extract_av_tags(&self, input: pb::ExtractAvTagsIn) -> BackendResult { - 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) -> BackendResult { - 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 { - 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, - ) -> BackendResult { - 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, - ) -> BackendResult { - 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) -> BackendResult { - Ok(pb::String { - val: strip_av_tags(&input.val).into(), - }) - } - - // searching - //----------------------------------------------- - - fn build_search_string(&self, input: pb::SearchNode) -> Result { - let node: Node = input.try_into()?; - Ok(write_nodes(&node.into_node_list()).into()) - } - - fn search_cards(&self, input: pb::SearchCardsIn) -> Result { - 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 { - 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 { - 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 { - 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) -> BackendResult { - 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 }) - }) - } - - // scheduling - //----------------------------------------------- - - /// 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 { - 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) -> BackendResult { - self.with_col(|col| col.studied_today().map(Into::into)) - } - - /// Message rendering only, for old graphs. - fn studied_today_message(&self, input: pb::StudiedTodayMessageIn) -> BackendResult { - Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into()) - } - - fn update_stats(&self, input: pb::UpdateStatsIn) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| col.counts_for_deck_today(input.did.into())) - } - - fn congrats_info(&self, _input: Empty) -> BackendResult { - self.with_col(|col| col.congrats_info()) - } - - fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> BackendResult { - 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, - ) -> BackendResult { - self.with_col(|col| { - col.unbury_cards_in_current_deck(input.mode()) - .map(Into::into) - }) - } - - fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into)) - } - - fn rebuild_filtered_deck(&self, input: pb::DeckId) -> BackendResult { - self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) - } - - fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - let state: CardState = input.into(); - Ok(state.leeched().into()) - } - - fn answer_card(&self, input: pb::AnswerCardIn) -> BackendResult { - self.with_col(|col| col.answer_card(&input.into())) - .map(Into::into) - } - - fn upgrade_scheduler(&self, _input: Empty) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler())) - .map(Into::into) - } - - fn get_queued_cards( - &self, - input: pb::GetQueuedCardsIn, - ) -> BackendResult { - self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only)) - } - - // statistics - //----------------------------------------------- - - fn card_stats(&self, input: pb::CardId) -> BackendResult { - self.with_col(|col| col.card_stats(input.into())) - .map(Into::into) - } - - fn graphs(&self, input: pb::GraphsIn) -> BackendResult { - self.with_col(|col| col.graph_data_for_search(&input.search, input.days)) - } - - fn get_graph_preferences(&self, _input: pb::Empty) -> BackendResult { - self.with_col(|col| col.get_graph_preferences()) - } - - fn set_graph_preferences(&self, input: pb::GraphPreferences) -> BackendResult { - self.with_col(|col| col.set_graph_preferences(input)) - .map(Into::into) - } - - // media - //----------------------------------------------- - - fn check_media(&self, _input: pb::Empty) -> Result { - 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) -> BackendResult { - 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) -> BackendResult { - 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: Empty) -> BackendResult { - 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: Empty) -> BackendResult { - 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) - } - - // decks - //---------------------------------------------------- - - fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result { - 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 { - 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) -> BackendResult { - 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: Empty) -> BackendResult { - 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 { - 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 { - 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 { - 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) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| col.remove_decks_and_child_decks(&Into::>::into(input))) - .map(Into::into) - } - - fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> BackendResult { - 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) - } - - // deck config - //---------------------------------------------------- - - fn add_or_update_deck_config_legacy( - &self, - input: AddOrUpdateDeckConfigLegacyIn, - ) -> BackendResult { - 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: Empty) -> BackendResult { - self.with_col(|col| { - let conf: Vec = 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) -> BackendResult { - 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: Empty) -> BackendResult { - serde_json::to_vec(&DeckConfSchema11::default()) - .map_err(Into::into) - .map(Into::into) - } - - fn remove_deck_config(&self, input: pb::DeckConfigId) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into()))) - .map(Into::into) - } - - // cards - //------------------------------------------------------------------- - - fn get_card(&self, input: pb::CardId) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| { - col.transact(None, |col| { - col.remove_cards_and_orphaned_notes( - &input - .card_ids - .into_iter() - .map(Into::into) - .collect::>(), - )?; - Ok(().into()) - }) - }) - } - - fn set_deck(&self, input: pb::SetDeckIn) -> BackendResult { - 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)) - } - - // notes - //------------------------------------------------------------------- - - fn new_note(&self, input: pb::NoteTypeId) -> BackendResult { - 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) -> BackendResult { - 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, - ) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| { - Ok(col - .default_deck_for_notetype(input.into())? - .unwrap_or(DeckID(0)) - .into()) - }) - } - - fn update_note(&self, input: pb::UpdateNoteIn) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| { - col.storage - .get_note(input.into())? - .ok_or(AnkiError::NotFound) - .map(Into::into) - }) - } - - fn remove_notes(&self, input: pb::RemoveNotesIn) -> BackendResult { - self.with_col(|col| { - if !input.note_ids.is_empty() { - col.remove_notes( - &input - .note_ids - .into_iter() - .map(Into::into) - .collect::>(), - )?; - } - if !input.card_ids.is_empty() { - let nids = col.storage.note_ids_of_cards( - &input - .card_ids - .into_iter() - .map(Into::into) - .collect::>(), - )?; - col.remove_notes(&nids.into_iter().collect::>())? - } - Ok(().into()) - }) - } - - fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - let mut set = HashSet::with_capacity(4); - for field in ¬e.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) -> BackendResult { - 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, - ) -> BackendResult { - 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, - ) -> BackendResult { - let note: Note = input.into(); - self.with_col(|col| { - col.note_is_duplicate_or_empty(¬e) - .map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 }) - }) - } - - fn cards_of_note(&self, input: pb::NoteId) -> BackendResult { - 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(), - }) - }) - } - - // notetypes - //------------------------------------------------------------------- - - fn add_or_update_notetype( - &self, - input: pb::AddOrUpdateNotetypeIn, - ) -> BackendResult { - 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) -> BackendResult { - // 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) -> BackendResult { - 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: Empty) -> BackendResult { - 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: Empty) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| col.remove_notetype(input.into())) - .map(Into::into) - } - - // collection - //------------------------------------------------------------------- - - fn open_collection(&self, input: pb::OpenCollectionIn) -> BackendResult { - 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) -> BackendResult { - 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) -> BackendResult { - 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 { - self.with_col(|col| Ok(col.undo_status())) - } - - fn undo(&self, _input: pb::Empty) -> Result { - self.with_col(|col| { - col.undo()?; - Ok(col.undo_status()) - }) - } - - fn redo(&self, _input: pb::Empty) -> Result { - self.with_col(|col| { - col.redo()?; - Ok(col.undo_status()) - }) - } - - // sync - //------------------------------------------------------------------- - - fn sync_media(&self, input: pb::SyncAuth) -> BackendResult { - self.sync_media_inner(input).map(Into::into) - } - - fn abort_sync(&self, _input: Empty) -> BackendResult { - 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: Empty) -> BackendResult { - let guard = self.state.lock().unwrap(); - if let Some(handle) = &guard.media_sync_abort { - handle.abort(); - } - Ok(().into()) - } - - fn before_upload(&self, _input: Empty) -> BackendResult { - self.with_col(|col| col.before_upload().map(Into::into)) - } - - fn sync_login(&self, input: pb::SyncLoginIn) -> BackendResult { - self.sync_login_inner(input) - } - - fn sync_status(&self, input: pb::SyncAuth) -> BackendResult { - self.sync_status_inner(input) - } - - fn sync_collection(&self, input: pb::SyncAuth) -> BackendResult { - self.sync_collection_inner(input) - } - - fn full_upload(&self, input: pb::SyncAuth) -> BackendResult { - self.full_sync_inner(input, true)?; - Ok(().into()) - } - - fn full_download(&self, input: pb::SyncAuth) -> BackendResult { - self.full_sync_inner(input, false)?; - Ok(().into()) - } - - fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> BackendResult { - let req = SyncRequest::from_method_and_data(input.method(), input.data)?; - self.sync_server_method_inner(req).map(Into::into) - } - - // i18n/messages - //------------------------------------------------------------------- - - fn translate_string(&self, input: pb::TranslateStringIn) -> BackendResult { - 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) -> BackendResult { - 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: Empty) -> BackendResult { - serde_json::to_vec(&self.i18n.resources_for_js()) - .map(Into::into) - .map_err(Into::into) - } - - fn render_markdown(&self, input: pb::RenderMarkdownIn) -> BackendResult { - let mut text = render_markdown(&input.markdown); - if input.sanitize { - // currently no images - text = sanitize_html_no_images(&text); - } - Ok(text.into()) - } - - // tags - //------------------------------------------------------------------- - - fn clear_unused_tags(&self, _input: pb::Empty) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) - } - - fn all_tags(&self, _input: Empty) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into)) - } - - fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> BackendResult { - 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) -> BackendResult { - 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: Empty) -> Result { - self.with_col(|col| col.tag_tree()) - } - - fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> BackendResult { - 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) - } - - // config/preferences - //------------------------------------------------------------------- - - fn get_config_json(&self, input: pb::String) -> BackendResult { - self.with_col(|col| { - let val: Option = 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) -> BackendResult { - self.with_col(|col| { - col.transact(None, |col| { - // ensure it's a well-formed object - let val: JsonValue = 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) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.remove_config(input.val.as_str()))) - .map(Into::into) - } - - fn get_all_config(&self, _input: Empty) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| { - Ok(pb::Bool { - val: col.get_bool(input.key().into()), - }) - }) - } - - fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> BackendResult { - 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) -> BackendResult { - self.with_col(|col| { - Ok(pb::String { - val: col.get_string(input.key().into()), - }) - }) - } - - fn set_config_string(&self, input: pb::SetConfigStringIn) -> BackendResult { - self.with_col(|col| { - col.transact(None, |col| col.set_string(input.key().into(), &input.value)) - }) - .map(Into::into) - } - - fn get_preferences(&self, _input: Empty) -> BackendResult { - self.with_col(|col| col.get_preferences()) - } - - fn set_preferences(&self, input: pb::Preferences) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.set_preferences(input))) - .map(Into::into) - } -} - impl Backend { pub fn new(i18n: I18n, server: bool) -> Backend { Backend { @@ -1348,8 +110,43 @@ impl Backend { &self.i18n } - pub fn run_command_bytes(&self, method: u32, input: &[u8]) -> result::Result, Vec> { - self.run_command_bytes2_inner(method, input).map_err(|err| { + pub fn run_method( + &self, + service: u32, + method: u32, + input: &[u8], + ) -> result::Result, Vec> { + pb::ServiceIndex::from_i32(service as i32) + .ok_or_else(|| AnkiError::invalid_input("invalid service")) + .and_then(|service| match service { + pb::ServiceIndex::Scheduling => SchedulingService::run_method(self, method, input), + pb::ServiceIndex::Decks => DecksService::run_method(self, method, input), + pb::ServiceIndex::Notes => NotesService::run_method(self, method, input), + pb::ServiceIndex::NoteTypes => NoteTypesService::run_method(self, method, input), + pb::ServiceIndex::Config => ConfigService::run_method(self, method, input), + pb::ServiceIndex::Sync => SyncService::run_method(self, method, input), + pb::ServiceIndex::Tags => TagsService::run_method(self, method, input), + pb::ServiceIndex::DeckConfig => DeckConfigService::run_method(self, method, input), + pb::ServiceIndex::CardRendering => { + CardRenderingService::run_method(self, method, input) + } + pb::ServiceIndex::Media => MediaService::run_method(self, method, input), + pb::ServiceIndex::Stats => StatsService::run_method(self, method, input), + pb::ServiceIndex::Search => SearchService::run_method(self, method, input), + pb::ServiceIndex::I18n => I18nService::run_method(self, method, input), + pb::ServiceIndex::Collection => CollectionService::run_method(self, method, input), + pb::ServiceIndex::Cards => CardsService::run_method(self, method, input), + }) + .map_err(|err| { + let backend_err = anki_error_to_proto_error(err, &self.i18n); + let mut bytes = Vec::new(); + backend_err.encode(&mut bytes).unwrap(); + bytes + }) + } + + pub fn run_db_command_bytes(&self, input: &[u8]) -> std::result::Result, Vec> { + self.db_command(input).map_err(|err| { let backend_err = anki_error_to_proto_error(err, &self.i18n); let mut bytes = Vec::new(); backend_err.encode(&mut bytes).unwrap(); @@ -1387,98 +184,7 @@ impl Backend { .clone() } - pub fn db_command(&self, input: &[u8]) -> Result> { + fn db_command(&self, input: &[u8]) -> Result> { self.with_col(|col| db_command_bytes(col, input)) } - - pub fn run_db_command_bytes(&self, input: &[u8]) -> std::result::Result, Vec> { - self.db_command(input).map_err(|err| { - let backend_err = anki_error_to_proto_error(err, &self.i18n); - let mut bytes = Vec::new(); - backend_err.encode(&mut bytes).unwrap(); - bytes - }) - } -} - -fn to_nids(ids: Vec) -> Vec { - ids.into_iter().map(NoteID).collect() -} - -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()), - } -} - -fn rendered_nodes_to_proto(nodes: Vec) -> Vec { - 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(RenderedTemplateReplacement { - field_name, - current_text, - filters, - }), - } -} - -impl From 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), - } - } -} - -impl From 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, - } - } -} - -impl From 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, - } - } } diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs new file mode 100644 index 000000000..5c82ad5a4 --- /dev/null +++ b/rslib/src/backend/notes.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + self.with_col(|col| { + if !input.note_ids.is_empty() { + col.remove_notes( + &input + .note_ids + .into_iter() + .map(Into::into) + .collect::>(), + )?; + } + if !input.card_ids.is_empty() { + let nids = col.storage.note_ids_of_cards( + &input + .card_ids + .into_iter() + .map(Into::into) + .collect::>(), + )?; + col.remove_notes(&nids.into_iter().collect::>())? + } + Ok(().into()) + }) + } + + fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { + 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 { + 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 { + let mut set = HashSet::with_capacity(4); + for field in ¬e.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 { + 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 { + 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 { + let note: Note = input.into(); + self.with_col(|col| { + col.note_is_duplicate_or_empty(¬e) + .map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 }) + }) + } + + fn cards_of_note(&self, input: pb::NoteId) -> Result { + 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) -> Vec { + ids.into_iter().map(NoteID).collect() +} diff --git a/rslib/src/backend/notetypes.rs b/rslib/src/backend/notetypes.rs new file mode 100644 index 000000000..cab29ba03 --- /dev/null +++ b/rslib/src/backend/notetypes.rs @@ -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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + self.with_col(|col| col.remove_notetype(input.into())) + .map(Into::into) + } +} diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 8663da96c..63ffce7e5 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -3,3 +3,184 @@ mod answering; mod states; + +use super::Backend; +use crate::{ + backend_proto::{self as pb}, + prelude::*, + scheduler::{ + new::NewCardSortOrder, + parse_due_date_str, + states::{CardState, NextCardStates}, + }, + stats::studied_today, +}; +pub(super) use pb::scheduling_service::Service as SchedulingService; + +impl SchedulingService for Backend { + /// This behaves like _updateCutoff() in older code - it also unburies at the start of + /// a new day. + fn sched_timing_today(&self, _input: pb::Empty) -> Result { + 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 { + 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 { + Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into()) + } + + fn update_stats(&self, input: pb::UpdateStatsIn) -> Result { + 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 { + 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 { + self.with_col(|col| col.counts_for_deck_today(input.did.into())) + } + + fn congrats_info(&self, _input: pb::Empty) -> Result { + self.with_col(|col| col.congrats_info()) + } + + fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result { + 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 { + 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 { + 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 { + self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into)) + } + + fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result { + self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) + } + + fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let state: CardState = input.into(); + Ok(state.leeched().into()) + } + + fn answer_card(&self, input: pb::AnswerCardIn) -> Result { + self.with_col(|col| col.answer_card(&input.into())) + .map(Into::into) + } + + fn upgrade_scheduler(&self, _input: pb::Empty) -> Result { + 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 { + self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only)) + } +} + +impl From 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, + } + } +} diff --git a/rslib/src/backend/search.rs b/rslib/src/backend/search.rs index d0b134c2a..6bda494ac 100644 --- a/rslib/src/backend/search.rs +++ b/rslib/src/backend/search.rs @@ -4,6 +4,7 @@ use itertools::Itertools; use std::convert::{TryFrom, TryInto}; +use super::Backend; use crate::{ backend_proto as pb, backend_proto::{ @@ -12,11 +13,83 @@ use crate::{ config::SortKind, prelude::*, search::{ - parse_search, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, - StateKind, TemplateKind, + concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node, + PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind, }, text::escape_anki_wildcards, }; +pub(super) use pb::search_service::Service as SearchService; + +impl SearchService for Backend { + fn build_search_string(&self, input: pb::SearchNode) -> Result { + let node: Node = input.try_into()?; + Ok(write_nodes(&node.into_node_list()).into()) + } + + fn search_cards(&self, input: pb::SearchCardsIn) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 for Node { type Error = AnkiError; diff --git a/rslib/src/backend/stats.rs b/rslib/src/backend/stats.rs new file mode 100644 index 000000000..09c4f3f0a --- /dev/null +++ b/rslib/src/backend/stats.rs @@ -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 { + self.with_col(|col| col.card_stats(input.into())) + .map(Into::into) + } + + fn graphs(&self, input: pb::GraphsIn) -> Result { + self.with_col(|col| col.graph_data_for_search(&input.search, input.days)) + } + + fn get_graph_preferences(&self, _input: pb::Empty) -> Result { + self.with_col(|col| col.get_graph_preferences()) + } + + fn set_graph_preferences(&self, input: pb::GraphPreferences) -> Result { + self.with_col(|col| col.set_graph_preferences(input)) + .map(Into::into) + } +} diff --git a/rslib/src/backend/sync.rs b/rslib/src/backend/sync/mod.rs similarity index 78% rename from rslib/src/backend/sync.rs rename to rslib/src/backend/sync/mod.rs index 17c1fd5b0..7d894ee0a 100644 --- a/rslib/src/backend/sync.rs +++ b/rslib/src/backend/sync/mod.rs @@ -1,6 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod server; + use std::sync::Arc; use futures::future::{AbortHandle, AbortRegistration, Abortable}; @@ -12,12 +14,20 @@ use crate::{ media::MediaManager, prelude::*, sync::{ - get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, - SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, + get_remote_sync_meta, http::SyncRequest, sync_abort, sync_login, FullSyncProgress, + LocalServer, NormalSyncProgress, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, }, }; use super::{progress::AbortHandleSlot, Backend}; +pub(super) use pb::sync_service::Service as SyncService; + +#[derive(Default)] +pub(super) struct SyncState { + remote_sync_status: RemoteSyncStatus, + media_sync_abort: Option, + http_sync_server: Option, +} #[derive(Default, Debug)] pub(super) struct RemoteSyncStatus { @@ -70,6 +80,59 @@ impl From for SyncAuth { } } +impl SyncService for Backend { + fn sync_media(&self, input: pb::SyncAuth) -> Result { + self.sync_media_inner(input).map(Into::into) + } + + fn abort_sync(&self, _input: pb::Empty) -> Result { + 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 { + 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 { + self.with_col(|col| col.before_upload().map(Into::into)) + } + + fn sync_login(&self, input: pb::SyncLoginIn) -> Result { + self.sync_login_inner(input) + } + + fn sync_status(&self, input: pb::SyncAuth) -> Result { + self.sync_status_inner(input) + } + + fn sync_collection(&self, input: pb::SyncAuth) -> Result { + self.sync_collection_inner(input) + } + + fn full_upload(&self, input: pb::SyncAuth) -> Result { + self.full_sync_inner(input, true)?; + Ok(().into()) + } + + fn full_download(&self, input: pb::SyncAuth) -> Result { + self.full_sync_inner(input, false)?; + Ok(().into()) + } + + fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> Result { + let req = SyncRequest::from_method_and_data(input.method(), input.data)?; + self.sync_server_method_inner(req).map(Into::into) + } +} + impl Backend { fn sync_abort_handle( &self, @@ -104,11 +167,11 @@ impl Backend { let (abort_handle, abort_reg) = AbortHandle::new_pair(); { let mut guard = self.state.lock().unwrap(); - if guard.media_sync_abort.is_some() { + if guard.sync.media_sync_abort.is_some() { // media sync is already active return Ok(()); } else { - guard.media_sync_abort = Some(abort_handle); + guard.sync.media_sync_abort = Some(abort_handle); } } @@ -131,7 +194,7 @@ impl Backend { let result = rt.block_on(abortable_sync); // mark inactive - self.state.lock().unwrap().media_sync_abort.take(); + self.state.lock().unwrap().sync.media_sync_abort.take(); // return result match result { @@ -146,14 +209,14 @@ impl Backend { /// Abort the media sync. Won't return until aborted. pub(super) fn abort_media_sync_and_wait(&self) { let guard = self.state.lock().unwrap(); - if let Some(handle) = &guard.media_sync_abort { + if let Some(handle) = &guard.sync.media_sync_abort { handle.abort(); self.progress_state.lock().unwrap().want_abort = true; } drop(guard); // block until it aborts - while self.state.lock().unwrap().media_sync_abort.is_some() { + while self.state.lock().unwrap().sync.media_sync_abort.is_some() { std::thread::sleep(std::time::Duration::from_millis(100)); self.progress_state.lock().unwrap().want_abort = true; } @@ -185,8 +248,8 @@ impl Backend { // return cached server response if only a short time has elapsed { let guard = self.state.lock().unwrap(); - if guard.remote_sync_status.last_check.elapsed_secs() < 300 { - return Ok(guard.remote_sync_status.last_response.into()); + if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 { + return Ok(guard.sync.remote_sync_status.last_response.into()); } } @@ -201,9 +264,9 @@ impl Backend { // On startup, the sync status check will block on network access, and then automatic syncing begins, // taking hold of the mutex. By the time we reach here, our network status may be out of date, // so we discard it if stale. - if guard.remote_sync_status.last_check < time_at_check_begin { - guard.remote_sync_status.last_check = time_at_check_begin; - guard.remote_sync_status.last_response = response; + if guard.sync.remote_sync_status.last_check < time_at_check_begin { + guard.sync.remote_sync_status.last_check = time_at_check_begin; + guard.sync.remote_sync_status.last_response = response; } } @@ -247,6 +310,7 @@ impl Backend { self.state .lock() .unwrap() + .sync .remote_sync_status .update(output.required.into()); Ok(output.into()) @@ -302,6 +366,7 @@ impl Backend { self.state .lock() .unwrap() + .sync .remote_sync_status .update(pb::sync_status_out::Required::NoChanges); } diff --git a/rslib/src/backend/http_sync_server.rs b/rslib/src/backend/sync/server.rs similarity index 94% rename from rslib/src/backend/http_sync_server.rs rename to rslib/src/backend/sync/server.rs index efdc9f3ab..8b4816e9f 100644 --- a/rslib/src/backend/http_sync_server.rs +++ b/rslib/src/backend/sync/server.rs @@ -4,7 +4,7 @@ use std::{path::PathBuf, sync::MutexGuard}; use tokio::runtime::Runtime; -use super::{Backend, BackendState}; +use crate::backend::{Backend, BackendState}; use crate::{ err::SyncErrorKind, prelude::*, @@ -24,16 +24,12 @@ impl Backend { F: FnOnce(&mut LocalServer) -> Result, { let mut state_guard = self.state.lock().unwrap(); - let out = - func( - state_guard - .http_sync_server - .as_mut() - .ok_or_else(|| AnkiError::SyncError { - kind: SyncErrorKind::SyncNotStarted, - info: Default::default(), - })?, - ); + let out = func(state_guard.sync.http_sync_server.as_mut().ok_or_else(|| { + AnkiError::SyncError { + kind: SyncErrorKind::SyncNotStarted, + info: Default::default(), + } + })?); if out.is_err() { self.abort_and_restore_collection(Some(state_guard)) } @@ -82,6 +78,7 @@ impl Backend { fn take_server(&self, state_guard: Option>) -> Result { let mut state_guard = state_guard.unwrap_or_else(|| self.state.lock().unwrap()); state_guard + .sync .http_sync_server .take() .ok_or_else(|| AnkiError::SyncError { @@ -94,7 +91,7 @@ impl Backend { // place col into new server let server = self.col_into_server()?; let mut state_guard = self.state.lock().unwrap(); - assert!(state_guard.http_sync_server.replace(server).is_none()); + assert!(state_guard.sync.http_sync_server.replace(server).is_none()); drop(state_guard); self.with_sync_server(|server| { diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs new file mode 100644 index 000000000..3d08e0f0d --- /dev/null +++ b/rslib/src/backend/tags.rs @@ -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 { + self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) + } + + fn all_tags(&self, _input: pb::Empty) -> Result { + 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 { + self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into)) + } + + fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result { + 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 { + 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 { + self.with_col(|col| col.tag_tree()) + } + + fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result { + 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) + } +} diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index 06f13e31e..ebb247d8b 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -18,6 +18,10 @@ pub enum BoolKey { CollapseTags, CollapseToday, FutureDueShowBacklog, + HideAudioPlayButtons, + InterruptAudioWhenAnswering, + PasteImagesAsPng, + PasteStripsFormatting, PreviewBothSides, Sched2021, @@ -50,7 +54,9 @@ impl Collection { } // some keys default to true - BoolKey::AddingDefaultsToCurrentDeck + BoolKey::InterruptAudioWhenAnswering + | BoolKey::ShowIntervalsAboveAnswerButtons + | BoolKey::AddingDefaultsToCurrentDeck | BoolKey::FutureDueShowBacklog | BoolKey::ShowRemainingDueCountsInStudy | BoolKey::CardCountsSeparateInactive diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 668de14a3..df16a066e 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -10,7 +10,10 @@ pub use crate::backend_proto::{ deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto, DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck, }; -use crate::{backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images}; +use crate::{ + backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images, + undo::UndoableOpKind, +}; use crate::{ collection::Collection, deckconf::DeckConfID, @@ -263,53 +266,77 @@ impl Collection { } /// Add or update an existing deck modified by the user. May add parents, - /// or rename children as required. + /// or rename children as required. Prefer add_deck() or update_deck() to + /// be explicit about your intentions; this function mainly exists so we + /// can integrate with older Python code that behaved this way. pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<()> { - self.state.deck_cache.clear(); + if deck.id.0 == 0 { + self.add_deck(deck) + } else { + self.update_deck(deck) + } + } - self.transact(None, |col| { + /// Add a new deck. The id must be 0, as it will be automatically assigned. + pub fn add_deck(&mut self, deck: &mut Deck) -> Result<()> { + if deck.id.0 != 0 { + return Err(AnkiError::invalid_input("deck to add must have id 0")); + } + + self.transact(Some(UndoableOpKind::AddDeck), |col| { let usn = col.usn()?; - col.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); - - if deck.id.0 == 0 { - // TODO: undo support - col.match_or_create_parents(deck, usn)?; - col.storage.add_deck(deck) - } else if let Some(existing_deck) = col.storage.get_deck(deck.id)? { - let name_changed = existing_deck.name != deck.name; - 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")) - } + col.match_or_create_parents(deck, usn)?; + col.add_deck_undoable(deck) }) } + pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> { + self.transact(Some(UndoableOpKind::UpdateDeck), |col| { + let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?; + col.update_deck_inner(deck, existing_deck, col.usn()?) + }) + } + + pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> { + self.transact(Some(UndoableOpKind::RenameDeck), |col| { + let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; + let mut deck = existing_deck.clone(); + deck.name = human_deck_name_to_native(new_human_name); + col.update_deck_inner(&mut deck, existing_deck, col.usn()?) + }) + } + + fn update_deck_inner(&mut self, deck: &mut Deck, original: Deck, usn: Usn) -> Result<()> { + self.prepare_deck_for_update(deck, usn)?; + deck.set_modified(usn); + let name_changed = original.name != deck.name; + if name_changed { + // match closest parent name + 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 /// & normalized, but does not check parents/children or update mtime /// (unless the name was changed). Caller must set up transaction. - /// TODO: undo support pub(crate) fn add_or_update_single_deck_with_existing_id( &mut self, deck: &mut Deck, usn: Usn, ) -> Result<()> { - self.state.deck_cache.clear(); self.prepare_deck_for_update(deck, usn)?; - self.storage.add_or_update_deck_with_existing_id(deck) + self.add_or_update_deck_with_existing_id_undoable(deck) } pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> { @@ -359,7 +386,7 @@ impl Collection { let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f")); child.name = new_name; child.set_modified(usn); - self.update_single_deck_undoable(&mut child, &original)?; + self.update_single_deck_undoable(&mut child, original)?; } Ok(()) @@ -367,12 +394,11 @@ impl Collection { /// Add a single, normal deck with the provided name for a child deck. /// Caller must have done necessarily validation on name. - fn add_parent_deck(&self, machine_name: &str, usn: Usn) -> Result<()> { + fn add_parent_deck(&mut self, machine_name: &str, usn: Usn) -> Result<()> { let mut deck = Deck::new_normal(); deck.name = machine_name.into(); deck.set_modified(usn); - // fixme: undo - self.storage.add_deck(&mut deck) + self.add_deck_undoable(&mut deck) } /// If parent deck(s) exist, rewrite name to match their case. @@ -404,7 +430,7 @@ impl Collection { } } - fn create_missing_parents(&self, mut machine_name: &str, usn: Usn) -> Result<()> { + fn create_missing_parents(&mut self, mut machine_name: &str, usn: Usn) -> Result<()> { while let Some(parent_name) = immediate_parent_name(machine_name) { if self.storage.get_deck_id(parent_name)?.is_none() { self.add_parent_deck(parent_name, usn)?; @@ -441,10 +467,7 @@ impl Collection { } pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result { - // fixme: vet cache clearing - self.state.deck_cache.clear(); let mut card_count = 0; - self.transact(None, |col| { let usn = col.usn()?; for did in dids { @@ -466,7 +489,6 @@ impl Collection { } pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result { - // fixme: undo let card_count = match deck.kind { DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, DeckKind::Filtered(_) => { @@ -476,14 +498,13 @@ impl Collection { }; self.clear_aux_config_for_deck(deck.id)?; if deck.id.0 == 1 { + // if deleting the default deck, ensure there's a new one, and avoid the grave let mut deck = deck.to_owned(); - // fixme: separate key deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into(); deck.set_modified(usn); self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?; } else { - self.storage.remove_deck(deck.id)?; - self.storage.add_deck_grave(deck.id, usn)?; + self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?; } Ok(card_count) } @@ -597,7 +618,7 @@ impl Collection { deck.reset_stats_if_day_changed(today); mutator(&mut deck.common); deck.set_modified(usn); - self.update_single_deck_undoable(deck, &original) + self.update_single_deck_undoable(deck, original) } pub fn drag_drop_decks( @@ -605,9 +626,8 @@ impl Collection { source_decks: &[DeckID], target: Option, ) -> Result<()> { - self.state.deck_cache.clear(); let usn = self.usn()?; - self.transact(None, |col| { + self.transact(Some(UndoableOpKind::RenameDeck), |col| { let target_deck; let mut target_name = None; if let Some(target) = target { @@ -622,18 +642,25 @@ impl Collection { for source in source_decks { if let Some(mut source) = col.storage.get_deck(*source)? { - let orig = source.clone(); let new_name = drag_drop_deck_name(&source.name, target_name); if new_name == source.name { continue; } + let orig = source.clone(); + + // this is basically update_deck_inner(), except: + // - we skip the normalization in prepare_for_update() + // - we skip the match_or_create_parents() step + + source.set_modified(usn); source.name = new_name; col.ensure_deck_name_unique(&mut source, usn)?; col.rename_child_decks(&orig, &source.name, usn)?; - source.set_modified(usn); - col.storage.update_deck(&source)?; + col.update_single_deck_undoable(&mut source, orig)?; + // after updating, we need to ensure all grandparents exist, which may not be the case // in the parent->child case + // FIXME: maybe we only need to do this once at the end of the loop? col.create_missing_parents(&source.name, usn)?; } } @@ -758,6 +785,23 @@ mod test { col.add_or_update_deck(&mut middle)?; assert_eq!(middle.name, "other+"); + // public function takes human name + col.rename_deck(middle.id, "one::two")?; + assert_eq!( + sorted_names(&col), + vec![ + "Default", + "one", + "one::two", + "one::two::baz", + "one::two::baz2", + "other", + "quux", + "quux::foo", + "quux::foo::baz", + ] + ); + Ok(()) } diff --git a/rslib/src/decks/undo.rs b/rslib/src/decks/undo.rs index 3251a0a91..8d019ffb2 100644 --- a/rslib/src/decks/undo.rs +++ b/rslib/src/decks/undo.rs @@ -6,32 +6,91 @@ use crate::prelude::*; #[derive(Debug)] pub(crate) enum UndoableDeckChange { + Added(Box), Updated(Box), + Removed(Box), + GraveAdded(Box<(DeckID, Usn)>), + GraveRemoved(Box<(DeckID, Usn)>), } impl Collection { pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> { match change { + UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck), UndoableDeckChange::Updated(mut deck) => { let current = self .storage .get_deck(deck.id)? .ok_or_else(|| AnkiError::invalid_input("deck disappeared"))?; - self.update_single_deck_undoable(&mut *deck, ¤t) + 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 /// is normalized, matches parents, is not a duplicate name, and bumping mtime. /// Clears deck cache. pub(super) fn update_single_deck_undoable( &mut self, deck: &mut Deck, - original: &Deck, + original: Deck, ) -> Result<()> { self.state.deck_cache.clear(); - self.save_undo(UndoableDeckChange::Updated(Box::new(original.clone()))); + self.save_undo(UndoableDeckChange::Updated(Box::new(original))); self.storage.update_deck(deck) } + + pub(crate) fn remove_deck_and_add_grave_undoable( + &mut self, + deck: Deck, + usn: Usn, + ) -> Result<()> { + self.state.deck_cache.clear(); + self.add_deck_grave_undoable(deck.id, usn)?; + self.storage.remove_deck(deck.id)?; + self.save_undo(UndoableDeckChange::Removed(Box::new(deck))); + Ok(()) + } + + fn restore_deleted_deck(&mut self, deck: Deck) -> Result<()> { + self.storage.add_or_update_deck_with_existing_id(&deck)?; + self.save_undo(UndoableDeckChange::Added(Box::new(deck))); + Ok(()) + } + + fn remove_deck_undoable(&mut self, deck: Deck) -> Result<()> { + self.state.deck_cache.clear(); + self.storage.remove_deck(deck.id)?; + self.save_undo(UndoableDeckChange::Removed(Box::new(deck))); + Ok(()) + } + + fn add_deck_grave_undoable(&mut self, did: DeckID, usn: Usn) -> Result<()> { + self.save_undo(UndoableDeckChange::GraveAdded(Box::new((did, usn)))); + self.storage.add_deck_grave(did, usn) + } + + fn remove_deck_grave(&mut self, did: DeckID, usn: Usn) -> Result<()> { + self.save_undo(UndoableDeckChange::GraveRemoved(Box::new((did, usn)))); + self.storage.remove_deck_grave(did) + } } diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index ae45fb9b9..e1b4eb80f 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -3,7 +3,8 @@ use crate::{ backend_proto::{ - collection_scheduling_settings::NewReviewMix as NewRevMixPB, CollectionSchedulingSettings, + preferences::scheduling::NewReviewMix as NewRevMixPB, + preferences::{Editing, Reviewing, Scheduling}, Preferences, }, collection::Collection, @@ -15,20 +16,37 @@ use crate::{ impl Collection { pub fn get_preferences(&self) -> Result { Ok(Preferences { - sched: Some(self.get_collection_scheduling_settings()?), + scheduling: Some(self.get_scheduling_preferences()?), + reviewing: Some(self.get_reviewing_preferences()?), + editing: Some(self.get_editing_preferences()?), }) } pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> { - if let Some(sched) = prefs.sched { - self.set_collection_scheduling_settings(sched)?; - } + self.transact( + Some(crate::undo::UndoableOpKind::UpdatePreferences), + |col| col.set_preferences_inner(prefs), + ) + } + fn set_preferences_inner( + &mut self, + prefs: Preferences, + ) -> Result<(), crate::prelude::AnkiError> { + if let Some(sched) = prefs.scheduling { + self.set_scheduling_preferences(sched)?; + } + if let Some(reviewing) = prefs.reviewing { + self.set_reviewing_preferences(reviewing)?; + } + if let Some(editing) = prefs.editing { + self.set_editing_preferences(editing)?; + } Ok(()) } - pub fn get_collection_scheduling_settings(&self) -> Result { - Ok(CollectionSchedulingSettings { + pub fn get_scheduling_preferences(&self) -> Result { + Ok(Scheduling { scheduler_version: match self.scheduler_version() { crate::config::SchedulerVersion::V1 => 1, crate::config::SchedulerVersion::V2 => 2, @@ -40,30 +58,15 @@ impl Collection { crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst, crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst, } as i32, - show_remaining_due_counts: self.get_bool(BoolKey::ShowRemainingDueCountsInStudy), - show_intervals_on_buttons: self.get_bool(BoolKey::ShowIntervalsAboveAnswerButtons), - time_limit_secs: self.get_answer_time_limit_secs(), new_timezone: self.get_creation_utc_offset().is_some(), day_learn_first: self.get_bool(BoolKey::ShowDayLearningCardsFirst), }) } - pub(crate) fn set_collection_scheduling_settings( - &mut self, - settings: CollectionSchedulingSettings, - ) -> Result<()> { + pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> { let s = settings; self.set_bool(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?; - self.set_bool( - BoolKey::ShowRemainingDueCountsInStudy, - s.show_remaining_due_counts, - )?; - self.set_bool( - BoolKey::ShowIntervalsAboveAnswerButtons, - s.show_intervals_on_buttons, - )?; - self.set_answer_time_limit_secs(s.time_limit_secs)?; self.set_learn_ahead_secs(s.learn_ahead_secs)?; self.set_new_review_mix(match s.new_review_mix() { @@ -88,4 +91,52 @@ impl Collection { Ok(()) } + + pub fn get_reviewing_preferences(&self) -> Result { + 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 { + 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(()) + } } diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index 14e50597c..e2f76e88a 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -138,8 +138,8 @@ impl SqliteStorage { } } - /// Used for syncing; will keep existing ID. Shouldn't be used to add new decks locally, - /// since it does not allocate an id. + /// Used for syncing&undo; will keep existing ID. Shouldn't be used to add + /// new decks locally, since it does not allocate an id. pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> { if deck.id.0 == 0 { return Err(AnkiError::invalid_input("deck with id 0")); diff --git a/rslib/src/storage/graves/mod.rs b/rslib/src/storage/graves/mod.rs index e9884126f..b8d9c0021 100644 --- a/rslib/src/storage/graves/mod.rs +++ b/rslib/src/storage/graves/mod.rs @@ -48,6 +48,10 @@ impl SqliteStorage { self.remove_grave(nid.0, GraveKind::Note) } + pub(crate) fn remove_deck_grave(&self, did: DeckID) -> Result<()> { + self.remove_grave(did.0, GraveKind::Deck) + } + pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result { let mut stmt = self.db.prepare(&format!( "select oid, type from graves where {}", diff --git a/rslib/src/undo/ops.rs b/rslib/src/undo/ops.rs index dea29e517..23d9019d3 100644 --- a/rslib/src/undo/ops.rs +++ b/rslib/src/undo/ops.rs @@ -5,15 +5,20 @@ use crate::prelude::*; #[derive(Debug, Clone, Copy, PartialEq)] pub enum UndoableOpKind { - UpdateCard, + AddDeck, + AddNote, AnswerCard, Bury, + RemoveDeck, + RemoveNote, + RenameDeck, Suspend, UnburyUnsuspend, - AddNote, - RemoveNote, - UpdateTag, + UpdateCard, + UpdateDeck, UpdateNote, + UpdatePreferences, + UpdateTag, } impl UndoableOpKind { @@ -25,15 +30,20 @@ impl UndoableOpKind { impl Collection { pub fn describe_op_kind(&self, op: UndoableOpKind) -> String { let key = match op { - UndoableOpKind::UpdateCard => TR::UndoUpdateCard, + UndoableOpKind::AddDeck => TR::UndoAddDeck, + UndoableOpKind::AddNote => TR::UndoAddNote, UndoableOpKind::AnswerCard => TR::UndoAnswerCard, UndoableOpKind::Bury => TR::StudyingBury, + UndoableOpKind::RemoveDeck => TR::DecksDeleteDeck, + UndoableOpKind::RemoveNote => TR::StudyingDeleteNote, + UndoableOpKind::RenameDeck => TR::ActionsRenameDeck, UndoableOpKind::Suspend => TR::StudyingSuspend, UndoableOpKind::UnburyUnsuspend => TR::UndoUnburyUnsuspend, - UndoableOpKind::AddNote => TR::UndoAddNote, - UndoableOpKind::RemoveNote => TR::StudyingDeleteNote, - UndoableOpKind::UpdateTag => TR::UndoUpdateTag, + UndoableOpKind::UpdateCard => TR::UndoUpdateCard, + UndoableOpKind::UpdateDeck => TR::UndoUpdateDeck, UndoableOpKind::UpdateNote => TR::UndoUpdateNote, + UndoableOpKind::UpdatePreferences => TR::PreferencesPreferences, + UndoableOpKind::UpdateTag => TR::UndoUpdateTag, }; self.i18n.tr(key).to_string() diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 3e02ffa43..0f0c87f2e 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -4,7 +4,7 @@ import { caretToEnd } from "./helpers"; import { saveField } from "./changeTimer"; import { filterHTML } from "./htmlFilter"; -import { updateButtonState } from "./toolbar"; +import { updateButtonState, disableButtons } from "./toolbar"; import { EditorField } from "./editorField"; import { LabelContainer } from "./labelContainer"; @@ -113,6 +113,11 @@ export function setFields(fields: [string, string][]): void { forEditorField(fields, (field, [name, fieldContent]) => field.initialize(name, color, fieldContent) ); + + if (!getCurrentField()) { + // when initial focus of the window is not on editor (e.g. browser) + disableButtons(); + } } export function setBackgrounds(cols: ("dupe" | "")[]): void {