Merge branch 'master' into sidebar-tools
This commit is contained in:
commit
f4c2fe6485
@ -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
|
||||
|
@ -36,7 +36,7 @@ py_binary(
|
||||
genrule(
|
||||
name = "rsbackend_gen",
|
||||
outs = ["generated.py"],
|
||||
cmd = "$(location genbackend) > $@",
|
||||
cmd = "$(location genbackend) $@",
|
||||
tools = ["genbackend"],
|
||||
)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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")
|
||||
|
||||
'''
|
||||
|
@ -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: ...
|
||||
|
@ -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
|
||||
#############################################################
|
||||
|
@ -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<Backend> {
|
||||
}
|
||||
}
|
||||
|
||||
fn want_release_gil(method: u32) -> bool {
|
||||
if let Ok(method) = BackendMethod::try_from(method) {
|
||||
!matches!(
|
||||
method,
|
||||
BackendMethod::ExtractAVTags
|
||||
| BackendMethod::ExtractLatex
|
||||
| BackendMethod::RenderExistingCard
|
||||
| BackendMethod::RenderUncommittedCard
|
||||
| BackendMethod::StripAVTags
|
||||
| BackendMethod::SchedTimingToday
|
||||
| BackendMethod::AddOrUpdateDeckLegacy
|
||||
| BackendMethod::NewDeckLegacy
|
||||
| BackendMethod::NewDeckConfigLegacy
|
||||
| BackendMethod::GetStockNotetypeLegacy
|
||||
| BackendMethod::StudiedToday
|
||||
| BackendMethod::TranslateString
|
||||
| BackendMethod::FormatTimespan
|
||||
| BackendMethod::LatestProgress
|
||||
| BackendMethod::SetWantsAbort
|
||||
| BackendMethod::I18nResources
|
||||
| BackendMethod::JoinSearchNodes
|
||||
| BackendMethod::ReplaceSearchNode
|
||||
| BackendMethod::BuildSearchString
|
||||
| BackendMethod::StateIsLeech
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl Backend {
|
||||
fn command(&self, py: Python, method: u32, input: &PyBytes) -> PyResult<PyObject> {
|
||||
fn command(
|
||||
&self,
|
||||
py: Python,
|
||||
service: u32,
|
||||
method: u32,
|
||||
input: &PyBytes,
|
||||
) -> PyResult<PyObject> {
|
||||
let in_bytes = input.as_bytes();
|
||||
if want_release_gil(method) {
|
||||
py.allow_threads(|| self.backend.run_command_bytes(method, in_bytes))
|
||||
} else {
|
||||
self.backend.run_command_bytes(method, in_bytes)
|
||||
}
|
||||
.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
|
||||
|
@ -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))
|
||||
|
@ -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:
|
||||
|
@ -80,7 +80,7 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="pasteInvert">
|
||||
<widget class="QCheckBox" name="paste_strips_formatting">
|
||||
<property name="text">
|
||||
<string>PREFERENCES_PASTE_WITHOUT_SHIFT_KEY_STRIPS_FORMATTING</string>
|
||||
</property>
|
||||
@ -589,7 +589,7 @@
|
||||
<tabstop>showPlayButtons</tabstop>
|
||||
<tabstop>interrupt_audio</tabstop>
|
||||
<tabstop>pastePNG</tabstop>
|
||||
<tabstop>pasteInvert</tabstop>
|
||||
<tabstop>paste_strips_formatting</tabstop>
|
||||
<tabstop>nightMode</tabstop>
|
||||
<tabstop>useCurrent</tabstop>
|
||||
<tabstop>recording_driver</tabstop>
|
||||
|
@ -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
|
||||
##########################################################################
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -6,32 +6,14 @@ use std::{env, fmt::Write};
|
||||
|
||||
struct CustomGenerator {}
|
||||
|
||||
fn write_method_enum(buf: &mut String, service: &prost_build::Service) {
|
||||
buf.push_str(
|
||||
r#"
|
||||
use num_enum::TryFromPrimitive;
|
||||
#[derive(PartialEq,TryFromPrimitive)]
|
||||
#[repr(u32)]
|
||||
pub enum BackendMethod {
|
||||
"#,
|
||||
);
|
||||
for (idx, method) in service.methods.iter().enumerate() {
|
||||
writeln!(buf, " {} = {},", method.proto_name, idx + 1).unwrap();
|
||||
}
|
||||
buf.push_str("}\n\n");
|
||||
}
|
||||
|
||||
fn write_method_trait(buf: &mut String, service: &prost_build::Service) {
|
||||
buf.push_str(
|
||||
r#"
|
||||
use prost::Message;
|
||||
pub type BackendResult<T> = std::result::Result<T, crate::err::AnkiError>;
|
||||
pub trait BackendService {
|
||||
fn run_command_bytes2_inner(&self, method: u32, input: &[u8]) -> std::result::Result<Vec<u8>, crate::err::AnkiError> {
|
||||
pub trait Service {
|
||||
fn run_method(&self, method: u32, input: &[u8]) -> Result<Vec<u8>> {
|
||||
match method {
|
||||
"#,
|
||||
);
|
||||
|
||||
for (idx, method) in service.methods.iter().enumerate() {
|
||||
write!(
|
||||
buf,
|
||||
@ -39,7 +21,7 @@ pub trait BackendService {
|
||||
"{idx} => {{ let input = {input_type}::decode(input)?;\n",
|
||||
"let output = self.{rust_method}(input)?;\n",
|
||||
"let mut out_bytes = Vec::new(); output.encode(&mut out_bytes)?; Ok(out_bytes) }}, "),
|
||||
idx = idx + 1,
|
||||
idx = idx,
|
||||
input_type = method.input_type,
|
||||
rust_method = method.name
|
||||
)
|
||||
@ -58,7 +40,7 @@ pub trait BackendService {
|
||||
buf,
|
||||
concat!(
|
||||
" fn {method_name}(&self, input: {input_type}) -> ",
|
||||
"BackendResult<{output_type}>;\n"
|
||||
"Result<{output_type}>;\n"
|
||||
),
|
||||
method_name = method.name,
|
||||
input_type = method.input_type,
|
||||
@ -71,8 +53,18 @@ pub trait BackendService {
|
||||
|
||||
impl prost_build::ServiceGenerator for CustomGenerator {
|
||||
fn generate(&mut self, service: prost_build::Service, buf: &mut String) {
|
||||
write_method_enum(buf, &service);
|
||||
write!(
|
||||
buf,
|
||||
"pub mod {name}_service {{
|
||||
use super::*;
|
||||
use prost::Message;
|
||||
use crate::err::Result;
|
||||
",
|
||||
name = service.name.replace("Service", "").to_ascii_lowercase()
|
||||
)
|
||||
.unwrap();
|
||||
write_method_trait(buf, &service);
|
||||
buf.push('}');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,60 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use std::convert::TryFrom;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
use super::Backend;
|
||||
use crate::prelude::*;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
card::{CardQueue, CardType},
|
||||
};
|
||||
pub(super) use pb::cards_service::Service as CardsService;
|
||||
|
||||
impl CardsService for Backend {
|
||||
fn get_card(&self, input: pb::CardId) -> Result<pb::Card> {
|
||||
self.with_col(|col| {
|
||||
col.storage
|
||||
.get_card(input.into())
|
||||
.and_then(|opt| opt.ok_or(AnkiError::NotFound))
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
let op = if input.skip_undo_entry {
|
||||
None
|
||||
} else {
|
||||
Some(UndoableOpKind::UpdateCard)
|
||||
};
|
||||
let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?;
|
||||
col.update_card_with_op(&mut card, op)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.remove_cards_and_orphaned_notes(
|
||||
&input
|
||||
.card_ids
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
Ok(().into())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::Empty> {
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
let deck_id = input.deck_id.into();
|
||||
self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::Card> for Card {
|
||||
type Error = AnkiError;
|
||||
@ -39,3 +86,28 @@ impl TryFrom<pb::Card> for Card {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Card> for pb::Card {
|
||||
fn from(c: Card) -> Self {
|
||||
pb::Card {
|
||||
id: c.id.0,
|
||||
note_id: c.note_id.0,
|
||||
deck_id: c.deck_id.0,
|
||||
template_idx: c.template_idx as u32,
|
||||
mtime_secs: c.mtime.0,
|
||||
usn: c.usn.0,
|
||||
ctype: c.ctype as u32,
|
||||
queue: c.queue as i32,
|
||||
due: c.due,
|
||||
interval: c.interval,
|
||||
ease_factor: c.ease_factor as u32,
|
||||
reps: c.reps,
|
||||
lapses: c.lapses,
|
||||
remaining_steps: c.remaining_steps,
|
||||
original_due: c.original_due,
|
||||
original_deck_id: c.original_deck_id.0,
|
||||
flags: c.flags as u32,
|
||||
data: c.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
163
rslib/src/backend/cardrendering.rs
Normal file
163
rslib/src/backend/cardrendering.rs
Normal file
@ -0,0 +1,163 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex},
|
||||
markdown::render_markdown,
|
||||
notetype::{CardTemplateSchema11, RenderCardOutput},
|
||||
prelude::*,
|
||||
template::RenderedNode,
|
||||
text::{extract_av_tags, sanitize_html_no_images, strip_av_tags, AVTag},
|
||||
};
|
||||
pub(super) use pb::cardrendering_service::Service as CardRenderingService;
|
||||
|
||||
impl CardRenderingService for Backend {
|
||||
fn extract_av_tags(&self, input: pb::ExtractAvTagsIn) -> Result<pb::ExtractAvTagsOut> {
|
||||
let (text, tags) = extract_av_tags(&input.text, input.question_side);
|
||||
let pt_tags = tags
|
||||
.into_iter()
|
||||
.map(|avtag| match avtag {
|
||||
AVTag::SoundOrVideo(file) => pb::AvTag {
|
||||
value: Some(pb::av_tag::Value::SoundOrVideo(file)),
|
||||
},
|
||||
AVTag::TextToSpeech {
|
||||
field_text,
|
||||
lang,
|
||||
voices,
|
||||
other_args,
|
||||
speed,
|
||||
} => pb::AvTag {
|
||||
value: Some(pb::av_tag::Value::Tts(pb::TtsTag {
|
||||
field_text,
|
||||
lang,
|
||||
voices,
|
||||
other_args,
|
||||
speed,
|
||||
})),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(pb::ExtractAvTagsOut {
|
||||
text: text.into(),
|
||||
av_tags: pt_tags,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_latex(&self, input: pb::ExtractLatexIn) -> Result<pb::ExtractLatexOut> {
|
||||
let func = if input.expand_clozes {
|
||||
extract_latex_expanding_clozes
|
||||
} else {
|
||||
extract_latex
|
||||
};
|
||||
let (text, extracted) = func(&input.text, input.svg);
|
||||
|
||||
Ok(pb::ExtractLatexOut {
|
||||
text,
|
||||
latex: extracted
|
||||
.into_iter()
|
||||
.map(|e: ExtractedLatex| pb::ExtractedLatex {
|
||||
filename: e.fname,
|
||||
latex_body: e.latex,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn get_empty_cards(&self, _input: pb::Empty) -> Result<pb::EmptyCardsReport> {
|
||||
self.with_col(|col| {
|
||||
let mut empty = col.empty_cards()?;
|
||||
let report = col.empty_cards_report(&mut empty)?;
|
||||
|
||||
let mut outnotes = vec![];
|
||||
for (_ntid, notes) in empty {
|
||||
outnotes.extend(notes.into_iter().map(|e| {
|
||||
pb::empty_cards_report::NoteWithEmptyCards {
|
||||
note_id: e.nid.0,
|
||||
will_delete_note: e.empty.len() == e.current_count,
|
||||
card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
Ok(pb::EmptyCardsReport {
|
||||
report,
|
||||
notes: outnotes,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_existing_card(&self, input: pb::RenderExistingCardIn) -> Result<pb::RenderCardOut> {
|
||||
self.with_col(|col| {
|
||||
col.render_existing_card(CardID(input.card_id), input.browser)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_uncommitted_card(
|
||||
&self,
|
||||
input: pb::RenderUncommittedCardIn,
|
||||
) -> Result<pb::RenderCardOut> {
|
||||
let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?;
|
||||
let template = schema11.into();
|
||||
let mut note = input
|
||||
.note
|
||||
.ok_or_else(|| AnkiError::invalid_input("missing note"))?
|
||||
.into();
|
||||
let ord = input.card_ord as u16;
|
||||
let fill_empty = input.fill_empty;
|
||||
self.with_col(|col| {
|
||||
col.render_uncommitted_card(&mut note, &template, ord, fill_empty)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_av_tags(&self, input: pb::String) -> Result<pb::String> {
|
||||
Ok(pb::String {
|
||||
val: strip_av_tags(&input.val).into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render_markdown(&self, input: pb::RenderMarkdownIn) -> Result<pb::String> {
|
||||
let mut text = render_markdown(&input.markdown);
|
||||
if input.sanitize {
|
||||
// currently no images
|
||||
text = sanitize_html_no_images(&text);
|
||||
}
|
||||
Ok(text.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_nodes_to_proto(nodes: Vec<RenderedNode>) -> Vec<pb::RenderedTemplateNode> {
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|n| pb::RenderedTemplateNode {
|
||||
value: Some(rendered_node_to_proto(n)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rendered_node_to_proto(node: RenderedNode) -> pb::rendered_template_node::Value {
|
||||
match node {
|
||||
RenderedNode::Text { text } => pb::rendered_template_node::Value::Text(text),
|
||||
RenderedNode::Replacement {
|
||||
field_name,
|
||||
current_text,
|
||||
filters,
|
||||
} => pb::rendered_template_node::Value::Replacement(pb::RenderedTemplateReplacement {
|
||||
field_name,
|
||||
current_text,
|
||||
filters,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RenderCardOutput> for pb::RenderCardOut {
|
||||
fn from(o: RenderCardOutput) -> Self {
|
||||
pb::RenderCardOut {
|
||||
question_nodes: rendered_nodes_to_proto(o.qnodes),
|
||||
answer_nodes: rendered_nodes_to_proto(o.anodes),
|
||||
}
|
||||
}
|
||||
}
|
104
rslib/src/backend/collection.rs
Normal file
104
rslib/src/backend/collection.rs
Normal file
@ -0,0 +1,104 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::{progress::Progress, Backend};
|
||||
use crate::{
|
||||
backend::progress::progress_to_proto,
|
||||
backend_proto as pb,
|
||||
collection::open_collection,
|
||||
log::{self, default_logger},
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::collection_service::Service as CollectionService;
|
||||
use slog::error;
|
||||
|
||||
impl CollectionService for Backend {
|
||||
fn latest_progress(&self, _input: pb::Empty) -> Result<pb::Progress> {
|
||||
let progress = self.progress_state.lock().unwrap().last_progress;
|
||||
Ok(progress_to_proto(progress, &self.i18n))
|
||||
}
|
||||
|
||||
fn set_wants_abort(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
self.progress_state.lock().unwrap().want_abort = true;
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn open_collection(&self, input: pb::OpenCollectionIn) -> Result<pb::Empty> {
|
||||
let mut col = self.col.lock().unwrap();
|
||||
if col.is_some() {
|
||||
return Err(AnkiError::CollectionAlreadyOpen);
|
||||
}
|
||||
|
||||
let mut path = input.collection_path.clone();
|
||||
path.push_str(".log");
|
||||
|
||||
let log_path = match input.log_path.as_str() {
|
||||
"" => None,
|
||||
path => Some(path),
|
||||
};
|
||||
let logger = default_logger(log_path)?;
|
||||
|
||||
let new_col = open_collection(
|
||||
input.collection_path,
|
||||
input.media_folder_path,
|
||||
input.media_db_path,
|
||||
self.server,
|
||||
self.i18n.clone(),
|
||||
logger,
|
||||
)?;
|
||||
|
||||
*col = Some(new_col);
|
||||
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn close_collection(&self, input: pb::CloseCollectionIn) -> Result<pb::Empty> {
|
||||
self.abort_media_sync_and_wait();
|
||||
|
||||
let mut col = self.col.lock().unwrap();
|
||||
if col.is_none() {
|
||||
return Err(AnkiError::CollectionNotOpen);
|
||||
}
|
||||
|
||||
let col_inner = col.take().unwrap();
|
||||
if input.downgrade_to_schema11 {
|
||||
let log = log::terminal();
|
||||
if let Err(e) = col_inner.close(input.downgrade_to_schema11) {
|
||||
error!(log, " failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn check_database(&self, _input: pb::Empty) -> Result<pb::CheckDatabaseOut> {
|
||||
let mut handler = self.new_progress_handler();
|
||||
let progress_fn = move |progress, throttle| {
|
||||
handler.update(Progress::DatabaseCheck(progress), throttle);
|
||||
};
|
||||
self.with_col(|col| {
|
||||
col.check_database(progress_fn)
|
||||
.map(|problems| pb::CheckDatabaseOut {
|
||||
problems: problems.to_i18n_strings(&col.i18n),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
|
||||
self.with_col(|col| Ok(col.undo_status()))
|
||||
}
|
||||
|
||||
fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
|
||||
self.with_col(|col| {
|
||||
col.undo()?;
|
||||
Ok(col.undo_status())
|
||||
})
|
||||
}
|
||||
|
||||
fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
|
||||
self.with_col(|col| {
|
||||
col.redo()?;
|
||||
Ok(col.undo_status())
|
||||
})
|
||||
}
|
||||
}
|
@ -1,12 +1,16 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
config::{BoolKey, StringKey},
|
||||
prelude::*,
|
||||
};
|
||||
use pb::config::bool::Key as BoolKeyProto;
|
||||
use pb::config::string::Key as StringKeyProto;
|
||||
pub(super) use pb::config_service::Service as ConfigService;
|
||||
use serde_json::Value;
|
||||
|
||||
impl From<BoolKeyProto> for BoolKey {
|
||||
fn from(k: BoolKeyProto) -> Self {
|
||||
@ -22,6 +26,10 @@ impl From<BoolKeyProto> for BoolKey {
|
||||
BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags,
|
||||
BoolKeyProto::Sched2021 => BoolKey::Sched2021,
|
||||
BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck,
|
||||
BoolKeyProto::HideAudioPlayButtons => BoolKey::HideAudioPlayButtons,
|
||||
BoolKeyProto::InterruptAudioWhenAnswering => BoolKey::InterruptAudioWhenAnswering,
|
||||
BoolKeyProto::PasteImagesAsPng => BoolKey::PasteImagesAsPng,
|
||||
BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,3 +42,75 @@ impl From<StringKeyProto> for StringKey {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigService for Backend {
|
||||
fn get_config_json(&self, input: pb::String) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let val: Option<Value> = col.get_config_optional(input.val.as_str());
|
||||
val.ok_or(AnkiError::NotFound)
|
||||
.and_then(|v| serde_json::to_vec(&v).map_err(Into::into))
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
// ensure it's a well-formed object
|
||||
let val: Value = serde_json::from_slice(&input.value_json)?;
|
||||
col.set_config(input.key.as_str(), &val)
|
||||
})
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn remove_config(&self, input: pb::String) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.remove_config(input.val.as_str())))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_all_config(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let conf = col.storage.get_all_config()?;
|
||||
serde_json::to_vec(&conf).map_err(Into::into)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_config_bool(&self, input: pb::config::Bool) -> Result<pb::Bool> {
|
||||
self.with_col(|col| {
|
||||
Ok(pb::Bool {
|
||||
val: col.get_bool(input.key().into()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.set_bool(input.key().into(), input.value)))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_config_string(&self, input: pb::config::String) -> Result<pb::String> {
|
||||
self.with_col(|col| {
|
||||
Ok(pb::String {
|
||||
val: col.get_string(input.key().into()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| col.set_string(input.key().into(), &input.value))
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_preferences(&self, _input: pb::Empty) -> Result<pb::Preferences> {
|
||||
self.with_col(|col| col.get_preferences())
|
||||
}
|
||||
|
||||
fn set_preferences(&self, input: pb::Preferences) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.set_preferences(input))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
|
60
rslib/src/backend/deckconfig.rs
Normal file
60
rslib/src/backend/deckconfig.rs
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
deckconf::{DeckConf, DeckConfSchema11},
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::deckconfig_service::Service as DeckConfigService;
|
||||
|
||||
impl DeckConfigService for Backend {
|
||||
fn add_or_update_deck_config_legacy(
|
||||
&self,
|
||||
input: pb::AddOrUpdateDeckConfigLegacyIn,
|
||||
) -> Result<pb::DeckConfigId> {
|
||||
let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?;
|
||||
let mut conf: DeckConf = conf.into();
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?;
|
||||
Ok(pb::DeckConfigId { dcid: conf.id.0 })
|
||||
})
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn all_deck_config_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let conf: Vec<DeckConfSchema11> = col
|
||||
.storage
|
||||
.all_deck_config()?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
serde_json::to_vec(&conf).map_err(Into::into)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_deck_config_legacy(&self, input: pb::DeckConfigId) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let conf = col.get_deck_config(input.into(), true)?.unwrap();
|
||||
let conf: DeckConfSchema11 = conf.into();
|
||||
Ok(serde_json::to_vec(&conf)?)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn new_deck_config_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
serde_json::to_vec(&DeckConfSchema11::default())
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into())))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
150
rslib/src/backend/decks.rs
Normal file
150
rslib/src/backend/decks.rs
Normal file
@ -0,0 +1,150 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto::{self as pb},
|
||||
decks::{Deck, DeckID, DeckSchema11},
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::decks_service::Service as DecksService;
|
||||
|
||||
impl DecksService for Backend {
|
||||
fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result<pb::DeckId> {
|
||||
self.with_col(|col| {
|
||||
let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?;
|
||||
let mut deck: Deck = schema11.into();
|
||||
if input.preserve_usn_and_mtime {
|
||||
col.transact(None, |col| {
|
||||
let usn = col.usn()?;
|
||||
col.add_or_update_single_deck_with_existing_id(&mut deck, usn)
|
||||
})?;
|
||||
} else {
|
||||
col.add_or_update_deck(&mut deck)?;
|
||||
}
|
||||
Ok(pb::DeckId { did: deck.id.0 })
|
||||
})
|
||||
}
|
||||
|
||||
fn deck_tree(&self, input: pb::DeckTreeIn) -> Result<pb::DeckTreeNode> {
|
||||
let lim = if input.top_deck_id > 0 {
|
||||
Some(DeckID(input.top_deck_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.with_col(|col| {
|
||||
let now = if input.now == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(TimestampSecs(input.now))
|
||||
};
|
||||
col.deck_tree(now, lim)
|
||||
})
|
||||
}
|
||||
|
||||
fn deck_tree_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let tree = col.legacy_deck_tree()?;
|
||||
serde_json::to_vec(&tree)
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_all_decks_legacy(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let decks = col.storage.get_all_decks_as_schema11()?;
|
||||
serde_json::to_vec(&decks).map_err(Into::into)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_deck_id_by_name(&self, input: pb::String) -> Result<pb::DeckId> {
|
||||
self.with_col(|col| {
|
||||
col.get_deck_id(&input.val).and_then(|d| {
|
||||
d.ok_or(AnkiError::NotFound)
|
||||
.map(|d| pb::DeckId { did: d.0 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn get_deck_legacy(&self, input: pb::DeckId) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let deck: DeckSchema11 = col
|
||||
.storage
|
||||
.get_deck(input.into())?
|
||||
.ok_or(AnkiError::NotFound)?
|
||||
.into();
|
||||
serde_json::to_vec(&deck)
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_deck_names(&self, input: pb::GetDeckNamesIn) -> Result<pb::DeckNames> {
|
||||
self.with_col(|col| {
|
||||
let names = if input.include_filtered {
|
||||
col.get_all_deck_names(input.skip_empty_default)?
|
||||
} else {
|
||||
col.get_all_normal_deck_names()?
|
||||
};
|
||||
Ok(pb::DeckNames {
|
||||
entries: names
|
||||
.into_iter()
|
||||
.map(|(id, name)| pb::DeckNameId { id: id.0, name })
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn new_deck_legacy(&self, input: pb::Bool) -> Result<pb::Json> {
|
||||
let deck = if input.val {
|
||||
Deck::new_filtered()
|
||||
} else {
|
||||
Deck::new_normal()
|
||||
};
|
||||
let schema11: DeckSchema11 = deck.into();
|
||||
serde_json::to_vec(&schema11)
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::UInt32> {
|
||||
self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::Empty> {
|
||||
let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect();
|
||||
let target_did = if input.target_deck_id == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(input.target_deck_id.into())
|
||||
};
|
||||
self.with_col(|col| col.drag_drop_decks(&source_dids, target_did))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn rename_deck(&self, input: pb::RenameDeckIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckId> for DeckID {
|
||||
fn from(did: pb::DeckId) -> Self {
|
||||
DeckID(did.did)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckIDs> for Vec<DeckID> {
|
||||
fn from(dids: pb::DeckIDs) -> Self {
|
||||
dids.dids.into_iter().map(DeckID).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeckID> for pb::DeckId {
|
||||
fn from(did: DeckID) -> Self {
|
||||
pb::DeckId { did: did.0 }
|
||||
}
|
||||
}
|
@ -69,24 +69,6 @@ impl From<pb::NoteTypeId> for NoteTypeID {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckId> for DeckID {
|
||||
fn from(did: pb::DeckId) -> Self {
|
||||
DeckID(did.did)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckIDs> for Vec<DeckID> {
|
||||
fn from(dids: pb::DeckIDs) -> Self {
|
||||
dids.dids.into_iter().map(DeckID).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeckID> for pb::DeckId {
|
||||
fn from(did: DeckID) -> Self {
|
||||
pb::DeckId { did: did.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckConfigId> for DeckConfID {
|
||||
fn from(dcid: pb::DeckConfigId) -> Self {
|
||||
DeckConfID(dcid.dcid)
|
||||
|
55
rslib/src/backend/i18n.rs
Normal file
55
rslib/src/backend/i18n.rs
Normal file
@ -0,0 +1,55 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
prelude::*,
|
||||
scheduler::timespan::{answer_button_time, time_span},
|
||||
};
|
||||
use fluent::FluentValue;
|
||||
pub(super) use pb::i18n_service::Service as I18nService;
|
||||
|
||||
impl I18nService for Backend {
|
||||
fn translate_string(&self, input: pb::TranslateStringIn) -> Result<pb::String> {
|
||||
let key = match crate::fluent_proto::FluentString::from_i32(input.key) {
|
||||
Some(key) => key,
|
||||
None => return Ok("invalid key".to_string().into()),
|
||||
};
|
||||
|
||||
let map = input
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), translate_arg_to_fluent_val(&v)))
|
||||
.collect();
|
||||
|
||||
Ok(self.i18n.trn(key, map).into())
|
||||
}
|
||||
|
||||
fn format_timespan(&self, input: pb::FormatTimespanIn) -> Result<pb::String> {
|
||||
use pb::format_timespan_in::Context;
|
||||
Ok(match input.context() {
|
||||
Context::Precise => time_span(input.seconds, &self.i18n, true),
|
||||
Context::Intervals => time_span(input.seconds, &self.i18n, false),
|
||||
Context::AnswerButtons => answer_button_time(input.seconds, &self.i18n),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
fn i18n_resources(&self, _input: pb::Empty) -> Result<pb::Json> {
|
||||
serde_json::to_vec(&self.i18n.resources_for_js())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue {
|
||||
use pb::translate_arg_value::Value as V;
|
||||
match &arg.value {
|
||||
Some(val) => match val {
|
||||
V::Str(s) => FluentValue::String(s.into()),
|
||||
V::Number(f) => FluentValue::Number(f.into()),
|
||||
},
|
||||
None => FluentValue::String("".into()),
|
||||
}
|
||||
}
|
89
rslib/src/backend/media.rs
Normal file
89
rslib/src/backend/media.rs
Normal file
@ -0,0 +1,89 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::{progress::Progress, Backend};
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
media::{check::MediaChecker, MediaManager},
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::media_service::Service as MediaService;
|
||||
|
||||
impl MediaService for Backend {
|
||||
// media
|
||||
//-----------------------------------------------
|
||||
|
||||
fn check_media(&self, _input: pb::Empty) -> Result<pb::CheckMediaOut> {
|
||||
let mut handler = self.new_progress_handler();
|
||||
let progress_fn =
|
||||
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
|
||||
self.with_col(|col| {
|
||||
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
|
||||
col.transact(None, |ctx| {
|
||||
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
|
||||
let mut output = checker.check()?;
|
||||
|
||||
let report = checker.summarize_output(&mut output);
|
||||
|
||||
Ok(pb::CheckMediaOut {
|
||||
unused: output.unused,
|
||||
missing: output.missing,
|
||||
report,
|
||||
have_trash: output.trash_count > 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn trash_media_files(&self, input: pb::TrashMediaFilesIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
|
||||
let mut ctx = mgr.dbctx();
|
||||
mgr.remove_files(&mut ctx, &input.fnames)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn add_media_file(&self, input: pb::AddMediaFileIn) -> Result<pb::String> {
|
||||
self.with_col(|col| {
|
||||
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
|
||||
let mut ctx = mgr.dbctx();
|
||||
Ok(mgr
|
||||
.add_file(&mut ctx, &input.desired_name, &input.data)?
|
||||
.to_string()
|
||||
.into())
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_trash(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
let mut handler = self.new_progress_handler();
|
||||
let progress_fn =
|
||||
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
|
||||
|
||||
self.with_col(|col| {
|
||||
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
|
||||
col.transact(None, |ctx| {
|
||||
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
|
||||
|
||||
checker.empty_trash()
|
||||
})
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn restore_trash(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
let mut handler = self.new_progress_handler();
|
||||
let progress_fn =
|
||||
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
|
||||
self.with_col(|col| {
|
||||
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
|
||||
|
||||
col.transact(None, |ctx| {
|
||||
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
|
||||
|
||||
checker.restore_trash()
|
||||
})
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
172
rslib/src/backend/notes.rs
Normal file
172
rslib/src/backend/notes.rs
Normal file
@ -0,0 +1,172 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto::{self as pb},
|
||||
cloze::add_cloze_numbers_in_string,
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::notes_service::Service as NotesService;
|
||||
|
||||
impl NotesService for Backend {
|
||||
// notes
|
||||
//-------------------------------------------------------------------
|
||||
|
||||
fn new_note(&self, input: pb::NoteTypeId) -> Result<pb::Note> {
|
||||
self.with_col(|col| {
|
||||
let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?;
|
||||
Ok(nt.new_note().into())
|
||||
})
|
||||
}
|
||||
|
||||
fn add_note(&self, input: pb::AddNoteIn) -> Result<pb::NoteId> {
|
||||
self.with_col(|col| {
|
||||
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
|
||||
col.add_note(&mut note, DeckID(input.deck_id))
|
||||
.map(|_| pb::NoteId { nid: note.id.0 })
|
||||
})
|
||||
}
|
||||
|
||||
fn defaults_for_adding(&self, input: pb::DefaultsForAddingIn) -> Result<pb::DeckAndNotetype> {
|
||||
self.with_col(|col| {
|
||||
let home_deck: DeckID = input.home_deck_of_current_review_card.into();
|
||||
col.defaults_for_adding(home_deck).map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn default_deck_for_notetype(&self, input: pb::NoteTypeId) -> Result<pb::DeckId> {
|
||||
self.with_col(|col| {
|
||||
Ok(col
|
||||
.default_deck_for_notetype(input.into())?
|
||||
.unwrap_or(DeckID(0))
|
||||
.into())
|
||||
})
|
||||
}
|
||||
|
||||
fn update_note(&self, input: pb::UpdateNoteIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
let op = if input.skip_undo_entry {
|
||||
None
|
||||
} else {
|
||||
Some(UndoableOpKind::UpdateNote)
|
||||
};
|
||||
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
|
||||
col.update_note_with_op(&mut note, op)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_note(&self, input: pb::NoteId) -> Result<pb::Note> {
|
||||
self.with_col(|col| {
|
||||
col.storage
|
||||
.get_note(input.into())?
|
||||
.ok_or(AnkiError::NotFound)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
if !input.note_ids.is_empty() {
|
||||
col.remove_notes(
|
||||
&input
|
||||
.note_ids
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
}
|
||||
if !input.card_ids.is_empty() {
|
||||
let nids = col.storage.note_ids_of_cards(
|
||||
&input
|
||||
.card_ids
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
col.remove_notes(&nids.into_iter().collect::<Vec<_>>())?
|
||||
}
|
||||
Ok(().into())
|
||||
})
|
||||
}
|
||||
|
||||
fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::UInt32> {
|
||||
self.with_col(|col| {
|
||||
col.add_tags_to_notes(&to_nids(input.nids), &input.tags)
|
||||
.map(|n| n as u32)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::UInt32> {
|
||||
self.with_col(|col| {
|
||||
col.replace_tags_for_notes(
|
||||
&to_nids(input.nids),
|
||||
&input.tags,
|
||||
&input.replacement,
|
||||
input.regex,
|
||||
)
|
||||
.map(|n| (n as u32).into())
|
||||
})
|
||||
}
|
||||
|
||||
fn cloze_numbers_in_note(&self, note: pb::Note) -> Result<pb::ClozeNumbersInNoteOut> {
|
||||
let mut set = HashSet::with_capacity(4);
|
||||
for field in ¬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<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.after_note_updates(
|
||||
&to_nids(input.nids),
|
||||
input.generate_cards,
|
||||
input.mark_notes_modified,
|
||||
)?;
|
||||
Ok(pb::Empty {})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn field_names_for_notes(
|
||||
&self,
|
||||
input: pb::FieldNamesForNotesIn,
|
||||
) -> Result<pb::FieldNamesForNotesOut> {
|
||||
self.with_col(|col| {
|
||||
let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect();
|
||||
col.storage
|
||||
.field_names_for_notes(&nids)
|
||||
.map(|fields| pb::FieldNamesForNotesOut { fields })
|
||||
})
|
||||
}
|
||||
|
||||
fn note_is_duplicate_or_empty(&self, input: pb::Note) -> Result<pb::NoteIsDuplicateOrEmptyOut> {
|
||||
let note: Note = input.into();
|
||||
self.with_col(|col| {
|
||||
col.note_is_duplicate_or_empty(¬e)
|
||||
.map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 })
|
||||
})
|
||||
}
|
||||
|
||||
fn cards_of_note(&self, input: pb::NoteId) -> Result<pb::CardIDs> {
|
||||
self.with_col(|col| {
|
||||
col.storage
|
||||
.all_card_ids_of_note(NoteID(input.nid))
|
||||
.map(|v| pb::CardIDs {
|
||||
cids: v.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn to_nids(ids: Vec<i64>) -> Vec<NoteID> {
|
||||
ids.into_iter().map(NoteID).collect()
|
||||
}
|
89
rslib/src/backend/notetypes.rs
Normal file
89
rslib/src/backend/notetypes.rs
Normal file
@ -0,0 +1,89 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
notetype::{all_stock_notetypes, NoteType, NoteTypeSchema11},
|
||||
prelude::*,
|
||||
};
|
||||
pub(super) use pb::notetypes_service::Service as NoteTypesService;
|
||||
|
||||
impl NoteTypesService for Backend {
|
||||
fn add_or_update_notetype(&self, input: pb::AddOrUpdateNotetypeIn) -> Result<pb::NoteTypeId> {
|
||||
self.with_col(|col| {
|
||||
let legacy: NoteTypeSchema11 = serde_json::from_slice(&input.json)?;
|
||||
let mut nt: NoteType = legacy.into();
|
||||
if nt.id.0 == 0 {
|
||||
col.add_notetype(&mut nt)?;
|
||||
} else {
|
||||
col.update_notetype(&mut nt, input.preserve_usn_and_mtime)?;
|
||||
}
|
||||
Ok(pb::NoteTypeId { ntid: nt.id.0 })
|
||||
})
|
||||
}
|
||||
|
||||
fn get_stock_notetype_legacy(&self, input: pb::StockNoteType) -> Result<pb::Json> {
|
||||
// fixme: use individual functions instead of full vec
|
||||
let mut all = all_stock_notetypes(&self.i18n);
|
||||
let idx = (input.kind as usize).min(all.len() - 1);
|
||||
let nt = all.swap_remove(idx);
|
||||
let schema11: NoteTypeSchema11 = nt.into();
|
||||
serde_json::to_vec(&schema11)
|
||||
.map_err(Into::into)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_notetype_legacy(&self, input: pb::NoteTypeId) -> Result<pb::Json> {
|
||||
self.with_col(|col| {
|
||||
let schema11: NoteTypeSchema11 = col
|
||||
.storage
|
||||
.get_notetype(input.into())?
|
||||
.ok_or(AnkiError::NotFound)?
|
||||
.into();
|
||||
Ok(serde_json::to_vec(&schema11)?).map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_notetype_names(&self, _input: pb::Empty) -> Result<pb::NoteTypeNames> {
|
||||
self.with_col(|col| {
|
||||
let entries: Vec<_> = col
|
||||
.storage
|
||||
.get_all_notetype_names()?
|
||||
.into_iter()
|
||||
.map(|(id, name)| pb::NoteTypeNameId { id: id.0, name })
|
||||
.collect();
|
||||
Ok(pb::NoteTypeNames { entries })
|
||||
})
|
||||
}
|
||||
|
||||
fn get_notetype_names_and_counts(&self, _input: pb::Empty) -> Result<pb::NoteTypeUseCounts> {
|
||||
self.with_col(|col| {
|
||||
let entries: Vec<_> = col
|
||||
.storage
|
||||
.get_notetype_use_counts()?
|
||||
.into_iter()
|
||||
.map(|(id, name, use_count)| pb::NoteTypeNameIdUseCount {
|
||||
id: id.0,
|
||||
name,
|
||||
use_count,
|
||||
})
|
||||
.collect();
|
||||
Ok(pb::NoteTypeUseCounts { entries })
|
||||
})
|
||||
}
|
||||
|
||||
fn get_notetype_id_by_name(&self, input: pb::String) -> Result<pb::NoteTypeId> {
|
||||
self.with_col(|col| {
|
||||
col.storage
|
||||
.get_notetype_id(&input.val)
|
||||
.and_then(|nt| nt.ok_or(AnkiError::NotFound))
|
||||
.map(|ntid| pb::NoteTypeId { ntid: ntid.0 })
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_notetype(&self, input: pb::NoteTypeId) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.remove_notetype(input.into()))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
@ -3,3 +3,184 @@
|
||||
|
||||
mod answering;
|
||||
mod states;
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto::{self as pb},
|
||||
prelude::*,
|
||||
scheduler::{
|
||||
new::NewCardSortOrder,
|
||||
parse_due_date_str,
|
||||
states::{CardState, NextCardStates},
|
||||
},
|
||||
stats::studied_today,
|
||||
};
|
||||
pub(super) use pb::scheduling_service::Service as SchedulingService;
|
||||
|
||||
impl SchedulingService for Backend {
|
||||
/// This behaves like _updateCutoff() in older code - it also unburies at the start of
|
||||
/// a new day.
|
||||
fn sched_timing_today(&self, _input: pb::Empty) -> Result<pb::SchedTimingTodayOut> {
|
||||
self.with_col(|col| {
|
||||
let timing = col.timing_today()?;
|
||||
col.unbury_if_day_rolled_over(timing)?;
|
||||
Ok(timing.into())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch data from DB and return rendered string.
|
||||
fn studied_today(&self, _input: pb::Empty) -> Result<pb::String> {
|
||||
self.with_col(|col| col.studied_today().map(Into::into))
|
||||
}
|
||||
|
||||
/// Message rendering only, for old graphs.
|
||||
fn studied_today_message(&self, input: pb::StudiedTodayMessageIn) -> Result<pb::String> {
|
||||
Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into())
|
||||
}
|
||||
|
||||
fn update_stats(&self, input: pb::UpdateStatsIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
let today = col.current_due_day(0)?;
|
||||
let usn = col.usn()?;
|
||||
col.update_deck_stats(today, usn, input).map(Into::into)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
let today = col.current_due_day(0)?;
|
||||
let usn = col.usn()?;
|
||||
col.extend_limits(
|
||||
today,
|
||||
usn,
|
||||
input.deck_id.into(),
|
||||
input.new_delta,
|
||||
input.review_delta,
|
||||
)
|
||||
.map(Into::into)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn counts_for_deck_today(&self, input: pb::DeckId) -> Result<pb::CountsForDeckTodayOut> {
|
||||
self.with_col(|col| col.counts_for_deck_today(input.did.into()))
|
||||
}
|
||||
|
||||
fn congrats_info(&self, _input: pb::Empty) -> Result<pb::CongratsInfoOut> {
|
||||
self.with_col(|col| col.congrats_info())
|
||||
}
|
||||
|
||||
fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result<pb::Empty> {
|
||||
let cids: Vec<_> = input.into();
|
||||
self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into))
|
||||
}
|
||||
|
||||
fn unbury_cards_in_current_deck(
|
||||
&self,
|
||||
input: pb::UnburyCardsInCurrentDeckIn,
|
||||
) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.unbury_cards_in_current_deck(input.mode())
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
let mode = input.mode();
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
col.bury_or_suspend_cards(&cids, mode).map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_filtered_deck(&self, input: pb::DeckId) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into))
|
||||
}
|
||||
|
||||
fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result<pb::UInt32> {
|
||||
self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into))
|
||||
}
|
||||
|
||||
fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
let log = input.log;
|
||||
col.reschedule_cards_as_new(&cids, log).map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::Empty> {
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
let spec = parse_due_date_str(&input.days)?;
|
||||
self.with_col(|col| col.set_due_date(&cids, spec).map(Into::into))
|
||||
}
|
||||
|
||||
fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::Empty> {
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
let (start, step, random, shift) = (
|
||||
input.starting_from,
|
||||
input.step_size,
|
||||
input.randomize,
|
||||
input.shift_existing,
|
||||
);
|
||||
let order = if random {
|
||||
NewCardSortOrder::Random
|
||||
} else {
|
||||
NewCardSortOrder::Preserve
|
||||
};
|
||||
self.with_col(|col| {
|
||||
col.sort_cards(&cids, start, step, order, shift)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.sort_deck(input.deck_id.into(), input.randomize)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_next_card_states(&self, input: pb::CardId) -> Result<pb::NextCardStates> {
|
||||
let cid: CardID = input.into();
|
||||
self.with_col(|col| col.get_next_card_states(cid))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn describe_next_states(&self, input: pb::NextCardStates) -> Result<pb::StringList> {
|
||||
let states: NextCardStates = input.into();
|
||||
self.with_col(|col| col.describe_next_states(states))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn state_is_leech(&self, input: pb::SchedulingState) -> Result<pb::Bool> {
|
||||
let state: CardState = input.into();
|
||||
Ok(state.leeched().into())
|
||||
}
|
||||
|
||||
fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.answer_card(&input.into()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn upgrade_scheduler(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn get_queued_cards(&self, input: pb::GetQueuedCardsIn) -> Result<pb::GetQueuedCardsOut> {
|
||||
self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::scheduler::timing::SchedTimingToday> for pb::SchedTimingTodayOut {
|
||||
fn from(t: crate::scheduler::timing::SchedTimingToday) -> pb::SchedTimingTodayOut {
|
||||
pb::SchedTimingTodayOut {
|
||||
days_elapsed: t.days_elapsed,
|
||||
next_day_at: t.next_day_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
use itertools::Itertools;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
use super::Backend;
|
||||
use crate::{
|
||||
backend_proto as pb,
|
||||
backend_proto::{
|
||||
@ -12,11 +13,83 @@ use crate::{
|
||||
config::SortKind,
|
||||
prelude::*,
|
||||
search::{
|
||||
parse_search, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode,
|
||||
StateKind, TemplateKind,
|
||||
concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node,
|
||||
PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind,
|
||||
},
|
||||
text::escape_anki_wildcards,
|
||||
};
|
||||
pub(super) use pb::search_service::Service as SearchService;
|
||||
|
||||
impl SearchService for Backend {
|
||||
fn build_search_string(&self, input: pb::SearchNode) -> Result<pb::String> {
|
||||
let node: Node = input.try_into()?;
|
||||
Ok(write_nodes(&node.into_node_list()).into())
|
||||
}
|
||||
|
||||
fn search_cards(&self, input: pb::SearchCardsIn) -> Result<pb::SearchCardsOut> {
|
||||
self.with_col(|col| {
|
||||
let order = input.order.unwrap_or_default().value.into();
|
||||
let cids = col.search_cards(&input.search, order)?;
|
||||
Ok(pb::SearchCardsOut {
|
||||
card_ids: cids.into_iter().map(|v| v.0).collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn search_notes(&self, input: pb::SearchNotesIn) -> Result<pb::SearchNotesOut> {
|
||||
self.with_col(|col| {
|
||||
let nids = col.search_notes(&input.search)?;
|
||||
Ok(pb::SearchNotesOut {
|
||||
note_ids: nids.into_iter().map(|v| v.0).collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn join_search_nodes(&self, input: pb::JoinSearchNodesIn) -> Result<pb::String> {
|
||||
let sep = input.joiner().into();
|
||||
let existing_nodes = {
|
||||
let node: Node = input.existing_node.unwrap_or_default().try_into()?;
|
||||
node.into_node_list()
|
||||
};
|
||||
let additional_node = input.additional_node.unwrap_or_default().try_into()?;
|
||||
Ok(concatenate_searches(sep, existing_nodes, additional_node).into())
|
||||
}
|
||||
|
||||
fn replace_search_node(&self, input: pb::ReplaceSearchNodeIn) -> Result<pb::String> {
|
||||
let existing = {
|
||||
let node = input.existing_node.unwrap_or_default().try_into()?;
|
||||
if let Node::Group(nodes) = node {
|
||||
nodes
|
||||
} else {
|
||||
vec![node]
|
||||
}
|
||||
};
|
||||
let replacement = input.replacement_node.unwrap_or_default().try_into()?;
|
||||
Ok(replace_search_node(existing, replacement).into())
|
||||
}
|
||||
|
||||
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::UInt32> {
|
||||
let mut search = if input.regex {
|
||||
input.search
|
||||
} else {
|
||||
regex::escape(&input.search)
|
||||
};
|
||||
if !input.match_case {
|
||||
search = format!("(?i){}", search);
|
||||
}
|
||||
let nids = input.nids.into_iter().map(NoteID).collect();
|
||||
let field_name = if input.field_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(input.field_name)
|
||||
};
|
||||
let repl = input.replacement;
|
||||
self.with_col(|col| {
|
||||
col.find_and_replace(nids, &search, &repl, field_name)
|
||||
.map(|cnt| pb::UInt32 { val: cnt as u32 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<pb::SearchNode> for Node {
|
||||
type Error = AnkiError;
|
||||
|
26
rslib/src/backend/stats.rs
Normal file
26
rslib/src/backend/stats.rs
Normal file
@ -0,0 +1,26 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{backend_proto as pb, prelude::*};
|
||||
pub(super) use pb::stats_service::Service as StatsService;
|
||||
|
||||
impl StatsService for Backend {
|
||||
fn card_stats(&self, input: pb::CardId) -> Result<pb::String> {
|
||||
self.with_col(|col| col.card_stats(input.into()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn graphs(&self, input: pb::GraphsIn) -> Result<pb::GraphsOut> {
|
||||
self.with_col(|col| col.graph_data_for_search(&input.search, input.days))
|
||||
}
|
||||
|
||||
fn get_graph_preferences(&self, _input: pb::Empty) -> Result<pb::GraphPreferences> {
|
||||
self.with_col(|col| col.get_graph_preferences())
|
||||
}
|
||||
|
||||
fn set_graph_preferences(&self, input: pb::GraphPreferences) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.set_graph_preferences(input))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
mod server;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::{AbortHandle, AbortRegistration, Abortable};
|
||||
@ -12,12 +14,20 @@ use crate::{
|
||||
media::MediaManager,
|
||||
prelude::*,
|
||||
sync::{
|
||||
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
|
||||
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput,
|
||||
get_remote_sync_meta, http::SyncRequest, sync_abort, sync_login, FullSyncProgress,
|
||||
LocalServer, NormalSyncProgress, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{progress::AbortHandleSlot, Backend};
|
||||
pub(super) use pb::sync_service::Service as SyncService;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SyncState {
|
||||
remote_sync_status: RemoteSyncStatus,
|
||||
media_sync_abort: Option<AbortHandle>,
|
||||
http_sync_server: Option<LocalServer>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub(super) struct RemoteSyncStatus {
|
||||
@ -70,6 +80,59 @@ impl From<pb::SyncAuth> for SyncAuth {
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncService for Backend {
|
||||
fn sync_media(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
|
||||
self.sync_media_inner(input).map(Into::into)
|
||||
}
|
||||
|
||||
fn abort_sync(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
if let Some(handle) = self.sync_abort.lock().unwrap().take() {
|
||||
handle.abort();
|
||||
}
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
/// Abort the media sync. Does not wait for completion.
|
||||
fn abort_media_sync(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
let guard = self.state.lock().unwrap();
|
||||
if let Some(handle) = &guard.sync.media_sync_abort {
|
||||
handle.abort();
|
||||
}
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn before_upload(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.before_upload().map(Into::into))
|
||||
}
|
||||
|
||||
fn sync_login(&self, input: pb::SyncLoginIn) -> Result<pb::SyncAuth> {
|
||||
self.sync_login_inner(input)
|
||||
}
|
||||
|
||||
fn sync_status(&self, input: pb::SyncAuth) -> Result<pb::SyncStatusOut> {
|
||||
self.sync_status_inner(input)
|
||||
}
|
||||
|
||||
fn sync_collection(&self, input: pb::SyncAuth) -> Result<pb::SyncCollectionOut> {
|
||||
self.sync_collection_inner(input)
|
||||
}
|
||||
|
||||
fn full_upload(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
|
||||
self.full_sync_inner(input, true)?;
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn full_download(&self, input: pb::SyncAuth) -> Result<pb::Empty> {
|
||||
self.full_sync_inner(input, false)?;
|
||||
Ok(().into())
|
||||
}
|
||||
|
||||
fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> Result<pb::Json> {
|
||||
let req = SyncRequest::from_method_and_data(input.method(), input.data)?;
|
||||
self.sync_server_method_inner(req).map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn sync_abort_handle(
|
||||
&self,
|
||||
@ -104,11 +167,11 @@ impl Backend {
|
||||
let (abort_handle, abort_reg) = AbortHandle::new_pair();
|
||||
{
|
||||
let mut guard = self.state.lock().unwrap();
|
||||
if guard.media_sync_abort.is_some() {
|
||||
if guard.sync.media_sync_abort.is_some() {
|
||||
// media sync is already active
|
||||
return Ok(());
|
||||
} else {
|
||||
guard.media_sync_abort = Some(abort_handle);
|
||||
guard.sync.media_sync_abort = Some(abort_handle);
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,7 +194,7 @@ impl Backend {
|
||||
let result = rt.block_on(abortable_sync);
|
||||
|
||||
// mark inactive
|
||||
self.state.lock().unwrap().media_sync_abort.take();
|
||||
self.state.lock().unwrap().sync.media_sync_abort.take();
|
||||
|
||||
// return result
|
||||
match result {
|
||||
@ -146,14 +209,14 @@ impl Backend {
|
||||
/// Abort the media sync. Won't return until aborted.
|
||||
pub(super) fn abort_media_sync_and_wait(&self) {
|
||||
let guard = self.state.lock().unwrap();
|
||||
if let Some(handle) = &guard.media_sync_abort {
|
||||
if let Some(handle) = &guard.sync.media_sync_abort {
|
||||
handle.abort();
|
||||
self.progress_state.lock().unwrap().want_abort = true;
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
// block until it aborts
|
||||
while self.state.lock().unwrap().media_sync_abort.is_some() {
|
||||
while self.state.lock().unwrap().sync.media_sync_abort.is_some() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
self.progress_state.lock().unwrap().want_abort = true;
|
||||
}
|
||||
@ -185,8 +248,8 @@ impl Backend {
|
||||
// return cached server response if only a short time has elapsed
|
||||
{
|
||||
let guard = self.state.lock().unwrap();
|
||||
if guard.remote_sync_status.last_check.elapsed_secs() < 300 {
|
||||
return Ok(guard.remote_sync_status.last_response.into());
|
||||
if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 {
|
||||
return Ok(guard.sync.remote_sync_status.last_response.into());
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,9 +264,9 @@ impl Backend {
|
||||
// On startup, the sync status check will block on network access, and then automatic syncing begins,
|
||||
// taking hold of the mutex. By the time we reach here, our network status may be out of date,
|
||||
// so we discard it if stale.
|
||||
if guard.remote_sync_status.last_check < time_at_check_begin {
|
||||
guard.remote_sync_status.last_check = time_at_check_begin;
|
||||
guard.remote_sync_status.last_response = response;
|
||||
if guard.sync.remote_sync_status.last_check < time_at_check_begin {
|
||||
guard.sync.remote_sync_status.last_check = time_at_check_begin;
|
||||
guard.sync.remote_sync_status.last_response = response;
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,6 +310,7 @@ impl Backend {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync
|
||||
.remote_sync_status
|
||||
.update(output.required.into());
|
||||
Ok(output.into())
|
||||
@ -302,6 +366,7 @@ impl Backend {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync
|
||||
.remote_sync_status
|
||||
.update(pb::sync_status_out::Required::NoChanges);
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
use std::{path::PathBuf, sync::MutexGuard};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use super::{Backend, BackendState};
|
||||
use crate::backend::{Backend, BackendState};
|
||||
use crate::{
|
||||
err::SyncErrorKind,
|
||||
prelude::*,
|
||||
@ -24,16 +24,12 @@ impl Backend {
|
||||
F: FnOnce(&mut LocalServer) -> Result<T>,
|
||||
{
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
let out =
|
||||
func(
|
||||
state_guard
|
||||
.http_sync_server
|
||||
.as_mut()
|
||||
.ok_or_else(|| AnkiError::SyncError {
|
||||
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<MutexGuard<BackendState>>) -> Result<LocalServer> {
|
||||
let mut state_guard = state_guard.unwrap_or_else(|| self.state.lock().unwrap());
|
||||
state_guard
|
||||
.sync
|
||||
.http_sync_server
|
||||
.take()
|
||||
.ok_or_else(|| AnkiError::SyncError {
|
||||
@ -94,7 +91,7 @@ impl Backend {
|
||||
// place col into new server
|
||||
let server = self.col_into_server()?;
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
assert!(state_guard.http_sync_server.replace(server).is_none());
|
||||
assert!(state_guard.sync.http_sync_server.replace(server).is_none());
|
||||
drop(state_guard);
|
||||
|
||||
self.with_sync_server(|server| {
|
62
rslib/src/backend/tags.rs
Normal file
62
rslib/src/backend/tags.rs
Normal file
@ -0,0 +1,62 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use super::Backend;
|
||||
use crate::{backend_proto as pb, prelude::*};
|
||||
pub(super) use pb::tags_service::Service as TagsService;
|
||||
|
||||
impl TagsService for Backend {
|
||||
fn clear_unused_tags(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
|
||||
}
|
||||
|
||||
fn all_tags(&self, _input: pb::Empty) -> Result<pb::StringList> {
|
||||
Ok(pb::StringList {
|
||||
vals: self.with_col(|col| {
|
||||
Ok(col
|
||||
.storage
|
||||
.all_tags()?
|
||||
.into_iter()
|
||||
.map(|t| t.name)
|
||||
.collect())
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
fn expunge_tags(&self, tags: pb::String) -> Result<pb::UInt32> {
|
||||
self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into))
|
||||
}
|
||||
|
||||
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.set_tag_expanded(&input.name, input.expanded)?;
|
||||
Ok(().into())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_tag(&self, tag: pb::String) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.storage.clear_tag_and_children(tag.val.as_str())?;
|
||||
Ok(().into())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn tag_tree(&self, _input: pb::Empty) -> Result<pb::TagTreeNode> {
|
||||
self.with_col(|col| col.tag_tree())
|
||||
}
|
||||
|
||||
fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result<pb::Empty> {
|
||||
let source_tags = input.source_tags;
|
||||
let target_tag = if input.target_tag.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(input.target_tag)
|
||||
};
|
||||
self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag))
|
||||
.map(Into::into)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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<usize> {
|
||||
// fixme: vet cache clearing
|
||||
self.state.deck_cache.clear();
|
||||
let mut card_count = 0;
|
||||
|
||||
self.transact(None, |col| {
|
||||
let usn = col.usn()?;
|
||||
for did in dids {
|
||||
@ -466,7 +489,6 @@ impl Collection {
|
||||
}
|
||||
|
||||
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
|
||||
// fixme: undo
|
||||
let card_count = match deck.kind {
|
||||
DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?,
|
||||
DeckKind::Filtered(_) => {
|
||||
@ -476,14 +498,13 @@ impl Collection {
|
||||
};
|
||||
self.clear_aux_config_for_deck(deck.id)?;
|
||||
if deck.id.0 == 1 {
|
||||
// if deleting the default deck, ensure there's a new one, and avoid the grave
|
||||
let mut deck = deck.to_owned();
|
||||
// fixme: separate key
|
||||
deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into();
|
||||
deck.set_modified(usn);
|
||||
self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?;
|
||||
} else {
|
||||
self.storage.remove_deck(deck.id)?;
|
||||
self.storage.add_deck_grave(deck.id, usn)?;
|
||||
self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?;
|
||||
}
|
||||
Ok(card_count)
|
||||
}
|
||||
@ -597,7 +618,7 @@ impl Collection {
|
||||
deck.reset_stats_if_day_changed(today);
|
||||
mutator(&mut deck.common);
|
||||
deck.set_modified(usn);
|
||||
self.update_single_deck_undoable(deck, &original)
|
||||
self.update_single_deck_undoable(deck, original)
|
||||
}
|
||||
|
||||
pub fn drag_drop_decks(
|
||||
@ -605,9 +626,8 @@ impl Collection {
|
||||
source_decks: &[DeckID],
|
||||
target: Option<DeckID>,
|
||||
) -> Result<()> {
|
||||
self.state.deck_cache.clear();
|
||||
let usn = self.usn()?;
|
||||
self.transact(None, |col| {
|
||||
self.transact(Some(UndoableOpKind::RenameDeck), |col| {
|
||||
let target_deck;
|
||||
let mut target_name = None;
|
||||
if let Some(target) = target {
|
||||
@ -622,18 +642,25 @@ impl Collection {
|
||||
|
||||
for source in source_decks {
|
||||
if let Some(mut source) = col.storage.get_deck(*source)? {
|
||||
let orig = source.clone();
|
||||
let new_name = drag_drop_deck_name(&source.name, target_name);
|
||||
if new_name == source.name {
|
||||
continue;
|
||||
}
|
||||
let orig = source.clone();
|
||||
|
||||
// this is basically update_deck_inner(), except:
|
||||
// - we skip the normalization in prepare_for_update()
|
||||
// - we skip the match_or_create_parents() step
|
||||
|
||||
source.set_modified(usn);
|
||||
source.name = new_name;
|
||||
col.ensure_deck_name_unique(&mut source, usn)?;
|
||||
col.rename_child_decks(&orig, &source.name, usn)?;
|
||||
source.set_modified(usn);
|
||||
col.storage.update_deck(&source)?;
|
||||
col.update_single_deck_undoable(&mut source, orig)?;
|
||||
|
||||
// after updating, we need to ensure all grandparents exist, which may not be the case
|
||||
// in the parent->child case
|
||||
// FIXME: maybe we only need to do this once at the end of the loop?
|
||||
col.create_missing_parents(&source.name, usn)?;
|
||||
}
|
||||
}
|
||||
@ -758,6 +785,23 @@ mod test {
|
||||
col.add_or_update_deck(&mut middle)?;
|
||||
assert_eq!(middle.name, "other+");
|
||||
|
||||
// public function takes human name
|
||||
col.rename_deck(middle.id, "one::two")?;
|
||||
assert_eq!(
|
||||
sorted_names(&col),
|
||||
vec![
|
||||
"Default",
|
||||
"one",
|
||||
"one::two",
|
||||
"one::two::baz",
|
||||
"one::two::baz2",
|
||||
"other",
|
||||
"quux",
|
||||
"quux::foo",
|
||||
"quux::foo::baz",
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -6,32 +6,91 @@ use crate::prelude::*;
|
||||
#[derive(Debug)]
|
||||
|
||||
pub(crate) enum UndoableDeckChange {
|
||||
Added(Box<Deck>),
|
||||
Updated(Box<Deck>),
|
||||
Removed(Box<Deck>),
|
||||
GraveAdded(Box<(DeckID, Usn)>),
|
||||
GraveRemoved(Box<(DeckID, Usn)>),
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> {
|
||||
match change {
|
||||
UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck),
|
||||
UndoableDeckChange::Updated(mut deck) => {
|
||||
let current = self
|
||||
.storage
|
||||
.get_deck(deck.id)?
|
||||
.ok_or_else(|| AnkiError::invalid_input("deck disappeared"))?;
|
||||
self.update_single_deck_undoable(&mut *deck, ¤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)
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,8 @@
|
||||
|
||||
use crate::{
|
||||
backend_proto::{
|
||||
collection_scheduling_settings::NewReviewMix as NewRevMixPB, CollectionSchedulingSettings,
|
||||
preferences::scheduling::NewReviewMix as NewRevMixPB,
|
||||
preferences::{Editing, Reviewing, Scheduling},
|
||||
Preferences,
|
||||
},
|
||||
collection::Collection,
|
||||
@ -15,20 +16,37 @@ use crate::{
|
||||
impl Collection {
|
||||
pub fn get_preferences(&self) -> Result<Preferences> {
|
||||
Ok(Preferences {
|
||||
sched: Some(self.get_collection_scheduling_settings()?),
|
||||
scheduling: Some(self.get_scheduling_preferences()?),
|
||||
reviewing: Some(self.get_reviewing_preferences()?),
|
||||
editing: Some(self.get_editing_preferences()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
|
||||
if let Some(sched) = prefs.sched {
|
||||
self.set_collection_scheduling_settings(sched)?;
|
||||
}
|
||||
self.transact(
|
||||
Some(crate::undo::UndoableOpKind::UpdatePreferences),
|
||||
|col| col.set_preferences_inner(prefs),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_preferences_inner(
|
||||
&mut self,
|
||||
prefs: Preferences,
|
||||
) -> Result<(), crate::prelude::AnkiError> {
|
||||
if let Some(sched) = prefs.scheduling {
|
||||
self.set_scheduling_preferences(sched)?;
|
||||
}
|
||||
if let Some(reviewing) = prefs.reviewing {
|
||||
self.set_reviewing_preferences(reviewing)?;
|
||||
}
|
||||
if let Some(editing) = prefs.editing {
|
||||
self.set_editing_preferences(editing)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> {
|
||||
Ok(CollectionSchedulingSettings {
|
||||
pub fn get_scheduling_preferences(&self) -> Result<Scheduling> {
|
||||
Ok(Scheduling {
|
||||
scheduler_version: match self.scheduler_version() {
|
||||
crate::config::SchedulerVersion::V1 => 1,
|
||||
crate::config::SchedulerVersion::V2 => 2,
|
||||
@ -40,30 +58,15 @@ impl Collection {
|
||||
crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst,
|
||||
crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst,
|
||||
} as i32,
|
||||
show_remaining_due_counts: self.get_bool(BoolKey::ShowRemainingDueCountsInStudy),
|
||||
show_intervals_on_buttons: self.get_bool(BoolKey::ShowIntervalsAboveAnswerButtons),
|
||||
time_limit_secs: self.get_answer_time_limit_secs(),
|
||||
new_timezone: self.get_creation_utc_offset().is_some(),
|
||||
day_learn_first: self.get_bool(BoolKey::ShowDayLearningCardsFirst),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_collection_scheduling_settings(
|
||||
&mut self,
|
||||
settings: CollectionSchedulingSettings,
|
||||
) -> Result<()> {
|
||||
pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> {
|
||||
let s = settings;
|
||||
|
||||
self.set_bool(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?;
|
||||
self.set_bool(
|
||||
BoolKey::ShowRemainingDueCountsInStudy,
|
||||
s.show_remaining_due_counts,
|
||||
)?;
|
||||
self.set_bool(
|
||||
BoolKey::ShowIntervalsAboveAnswerButtons,
|
||||
s.show_intervals_on_buttons,
|
||||
)?;
|
||||
self.set_answer_time_limit_secs(s.time_limit_secs)?;
|
||||
self.set_learn_ahead_secs(s.learn_ahead_secs)?;
|
||||
|
||||
self.set_new_review_mix(match s.new_review_mix() {
|
||||
@ -88,4 +91,52 @@ impl Collection {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_reviewing_preferences(&self) -> Result<Reviewing> {
|
||||
Ok(Reviewing {
|
||||
hide_audio_play_buttons: self.get_bool(BoolKey::HideAudioPlayButtons),
|
||||
interrupt_audio_when_answering: self.get_bool(BoolKey::InterruptAudioWhenAnswering),
|
||||
show_remaining_due_counts: self.get_bool(BoolKey::ShowRemainingDueCountsInStudy),
|
||||
show_intervals_on_buttons: self.get_bool(BoolKey::ShowIntervalsAboveAnswerButtons),
|
||||
time_limit_secs: self.get_answer_time_limit_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_reviewing_preferences(&mut self, settings: Reviewing) -> Result<()> {
|
||||
let s = settings;
|
||||
self.set_bool(BoolKey::HideAudioPlayButtons, s.hide_audio_play_buttons)?;
|
||||
self.set_bool(
|
||||
BoolKey::InterruptAudioWhenAnswering,
|
||||
s.interrupt_audio_when_answering,
|
||||
)?;
|
||||
self.set_bool(
|
||||
BoolKey::ShowRemainingDueCountsInStudy,
|
||||
s.show_remaining_due_counts,
|
||||
)?;
|
||||
self.set_bool(
|
||||
BoolKey::ShowIntervalsAboveAnswerButtons,
|
||||
s.show_intervals_on_buttons,
|
||||
)?;
|
||||
self.set_answer_time_limit_secs(s.time_limit_secs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_editing_preferences(&self) -> Result<Editing> {
|
||||
Ok(Editing {
|
||||
adding_defaults_to_current_deck: self.get_bool(BoolKey::AddingDefaultsToCurrentDeck),
|
||||
paste_images_as_png: self.get_bool(BoolKey::PasteImagesAsPng),
|
||||
paste_strips_formatting: self.get_bool(BoolKey::PasteStripsFormatting),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_editing_preferences(&mut self, settings: Editing) -> Result<()> {
|
||||
let s = settings;
|
||||
self.set_bool(
|
||||
BoolKey::AddingDefaultsToCurrentDeck,
|
||||
s.adding_defaults_to_current_deck,
|
||||
)?;
|
||||
self.set_bool(BoolKey::PasteImagesAsPng, s.paste_images_as_png)?;
|
||||
self.set_bool(BoolKey::PasteStripsFormatting, s.paste_strips_formatting)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -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"));
|
||||
|
@ -48,6 +48,10 @@ impl SqliteStorage {
|
||||
self.remove_grave(nid.0, GraveKind::Note)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_deck_grave(&self, did: DeckID) -> Result<()> {
|
||||
self.remove_grave(did.0, GraveKind::Deck)
|
||||
}
|
||||
|
||||
pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result<Graves> {
|
||||
let mut stmt = self.db.prepare(&format!(
|
||||
"select oid, type from graves where {}",
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user