Add _raw methods for all methods in the backend (#1594)

* Add _bytes methods for all methods in the backend

Expose get_note in qt/aqt/mediasrv.py

* Satisfy formatter

* Rename _bytes function to _raw and have them bytes as input

* Fix backend generation

* Use lib/proto/deckOptions in deck-options

* Add exposed_backend to qt/aqt/mediasrv.py

* Move some more backend methods to exposed_backend_list

* Use protobufjs for congrats and i18n

* Use protobufjs for completeTag

* Use protobufjs services in change-notetype

* Reorder post handlers in alphabetical manner

* Satisfy tests

* Remove unused collection methods

* Rename access_backend to raw_backend_request

* Use _vendor.stringcase instead of creating a new function

* Remove SKIP_UNROLL_OUTPUT

* Directly call _run_command in non _raw methods

* Remove TranslateString, ChangeNotetype and CompleteTag from SKIP_UNROLL_INPUT

* Remove UpdateDeckConfigs from SKIP_UNROLL_INPUT

* Remove ChangeNotetype from SKIP_UNROLL_INPUT

* Remove SKIP_UNROLL_INPUT

* Fix typing issue with translate_string

- Adds typing support for Protobuf maps in genbackend.py

* Do not emit convenience method for protobuf TranslateString
This commit is contained in:
Henrik Giesel 2022-01-21 12:32:39 +01:00 committed by GitHub
parent 578ef6b2bc
commit a8d4774cdb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 256 additions and 344 deletions

View file

@ -106,12 +106,22 @@ class RustBackend(RustBackendGenerated):
def translate( def translate(
self, module_index: int, message_index: int, **kwargs: str | int | float self, module_index: int, message_index: int, **kwargs: str | int | float
) -> str: ) -> str:
return self.translate_string( args = {
translate_string_in( k: i18n_pb2.TranslateArgValue(str=v)
module_index=module_index, message_index=message_index, **kwargs if isinstance(v, str)
) else i18n_pb2.TranslateArgValue(number=v)
for k, v in kwargs.items()
}
input = i18n_pb2.TranslateStringRequest(
module_index=module_index,
message_index=message_index,
args=args,
) )
output_bytes = self.translate_string_raw(input.SerializeToString())
return anki.generic_pb2.String.FromString(output_bytes).val
def format_time_span( def format_time_span(
self, self,
seconds: Any, seconds: Any,
@ -123,31 +133,17 @@ class RustBackend(RustBackendGenerated):
) )
return self.format_timespan(seconds=seconds, context=context) return self.format_timespan(seconds=seconds, context=context)
def _run_command(self, service: int, method: int, input: Any) -> bytes: def _run_command(self, service: int, method: int, input: bytes) -> bytes:
input_bytes = input.SerializeToString()
try: try:
return self._backend.command(service, method, input_bytes) return self._backend.command(service, method, input)
except Exception as error: except Exception as error:
err_bytes = bytes(error.args[0]) error_bytes = bytes(error.args[0])
err = backend_pb2.BackendError() err = backend_pb2.BackendError()
err.ParseFromString(err_bytes) err.ParseFromString(error_bytes)
raise backend_exception_to_pylib(err) raise backend_exception_to_pylib(err)
def translate_string_in(
module_index: int, message_index: int, **kwargs: str | int | float
) -> i18n_pb2.TranslateStringRequest:
args = {
k: i18n_pb2.TranslateArgValue(str=v)
if isinstance(v, str)
else i18n_pb2.TranslateArgValue(number=v)
for k, v in kwargs.items()
}
return i18n_pb2.TranslateStringRequest(
module_index=module_index, message_index=message_index, args=args
)
class Translations(GeneratedTranslations): class Translations(GeneratedTranslations):
def __init__(self, backend: ref[RustBackend] | None): def __init__(self, backend: ref[RustBackend] | None):
self.backend = backend self.backend = backend

View file

@ -51,24 +51,7 @@ LABEL_OPTIONAL = 1
LABEL_REQUIRED = 2 LABEL_REQUIRED = 2
LABEL_REPEATED = 3 LABEL_REPEATED = 3
# messages we don't want to unroll in codegen RAW_ONLY = {"TranslateString"}
SKIP_UNROLL_INPUT = {
"TranslateString",
"SetPreferences",
"UpdateDeckConfigs",
"AnswerCard",
"ChangeNotetype",
"CompleteTag",
}
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
SKIP_DECODE = {
"Graphs",
"GetGraphPreferences",
"GetChangeNotetypeInfo",
"CompleteTag",
"CardStats",
}
def python_type(field): def python_type(field):
@ -116,62 +99,59 @@ def fix_snakecase(name):
return name return name
def get_input_args(msg): def get_input_args(input_type):
fields = sorted(msg.fields, key=lambda x: x.number) fields = sorted(input_type.fields, key=lambda x: x.number)
self_star = ["self"] self_star = ["self"]
if len(fields) >= 2: if len(fields) >= 2:
self_star.append("*") self_star.append("*")
return ", ".join(self_star + [f"{f.name}: {python_type(f)}" for f in fields]) return ", ".join(self_star + [f"{f.name}: {python_type(f)}" for f in fields])
def get_input_assign(msg): def get_input_assign(input_type):
fields = sorted(msg.fields, key=lambda x: x.number) fields = sorted(input_type.fields, key=lambda x: x.number)
return ", ".join(f"{f.name}={f.name}" for f in fields) return ", ".join(f"{f.name}={f.name}" for f in fields)
def render_method(service_idx, method_idx, method): def render_method(service_idx, method_idx, method):
input_name = method.input_type.name
if (
(input_name.endswith("Request") or len(method.input_type.fields) < 2)
and not method.input_type.oneofs
and not method.name in SKIP_UNROLL_INPUT
):
input_args = get_input_args(method.input_type)
input_assign = get_input_assign(method.input_type)
input_assign_outer = (
f"input = {fullname(method.input_type.full_name)}({input_assign})\n "
)
else:
input_args = f"self, input: {fullname(method.input_type.full_name)}"
input_assign_outer = ""
name = fix_snakecase(stringcase.snakecase(method.name)) name = fix_snakecase(stringcase.snakecase(method.name))
input_name = method.input_type.name
if (
input_name.endswith("Request") or len(method.input_type.fields) < 2
) and not method.input_type.oneofs:
input_params = get_input_args(method.input_type)
input_assign_full = f"message = {fullname(method.input_type.full_name)}({get_input_assign(method.input_type)})"
else:
input_params = f"self, message: {fullname(method.input_type.full_name)}"
input_assign_full = ""
if ( if (
len(method.output_type.fields) == 1 len(method.output_type.fields) == 1
and method.output_type.fields[0].type != TYPE_ENUM and method.output_type.fields[0].type != TYPE_ENUM
and method.name not in SKIP_UNROLL_OUTPUT
): ):
# unwrap single return arg # unwrap single return arg
f = method.output_type.fields[0] f = method.output_type.fields[0]
single_field = f".{f.name}"
return_type = python_type(f) return_type = python_type(f)
single_attribute = f".{f.name}"
else: else:
single_field = ""
return_type = fullname(method.output_type.full_name) return_type = fullname(method.output_type.full_name)
single_attribute = ""
if method.name in SKIP_DECODE:
return_type = "bytes"
buf = f"""\ buf = f"""\
def {name}({input_args}) -> {return_type}: def {name}_raw(self, message: bytes) -> bytes:
{input_assign_outer}""" return self._run_command({service_idx}, {method_idx}, message)
if method.name in SKIP_DECODE:
buf += f"""return self._run_command({service_idx}, {method_idx}, input)
""" """
else:
buf += f"""output = {fullname(method.output_type.full_name)}() if not method.name in RAW_ONLY:
output.ParseFromString(self._run_command({service_idx}, {method_idx}, input)) buf += f"""\
return output{single_field} def {name}({input_params}) -> {return_type}:
{input_assign_full}
raw_bytes = self._run_command({service_idx}, {method_idx}, message.SerializeToString())
output = {fullname(method.output_type.full_name)}()
output.ParseFromString(raw_bytes)
return output{single_attribute}
""" """
return buf return buf
@ -235,7 +215,7 @@ or removed at any time. Instead, please use the methods on the collection
instead. Eg, don't use col.backend.all_deck_config(), instead use instead. Eg, don't use col.backend.all_deck_config(), instead use
col.decks.all_config() col.decks.all_config()
""" """
from typing import * from typing import *
import anki import anki

View file

@ -804,25 +804,12 @@ class Collection(DeprecatedNamesMixin):
return CollectionStats(self) return CollectionStats(self)
def card_stats_data(self, card_id: CardId) -> bytes: def card_stats_data(self, card_id: CardId) -> stats_pb2.CardStatsResponse:
return self._backend.card_stats(card_id) return self._backend.card_stats(card_id)
def studied_today(self) -> str: def studied_today(self) -> str:
return self._backend.studied_today() return self._backend.studied_today()
def graph_data(self, search: str, days: int) -> bytes:
return self._backend.graphs(search=search, days=days)
def get_graph_preferences(self) -> bytes:
return self._backend.get_graph_preferences()
def set_graph_preferences(self, prefs: GraphPreferences) -> None:
self._backend.set_graph_preferences(input=prefs)
def congrats_info(self) -> bytes:
"Don't use this, it will likely go away in the future."
return self._backend.congrats_info().SerializeToString()
# Undo # Undo
########################################################################## ##########################################################################

View file

@ -285,7 +285,8 @@ class DeckManager(DeprecatedNamesMixin):
return self.col._backend.get_deck_configs_for_update(deck_id) return self.col._backend.get_deck_configs_for_update(deck_id)
def update_deck_configs(self, input: UpdateDeckConfigs) -> OpChanges: def update_deck_configs(self, input: UpdateDeckConfigs) -> OpChanges:
return self.col._backend.update_deck_configs(input=input) op_bytes = self.col._backend.update_deck_configs_raw(input.SerializeToString())
return OpChanges.FromString(op_bytes)
def all_config(self) -> list[DeckConfigDict]: def all_config(self) -> list[DeckConfigDict]:
"A list of all deck config." "A list of all deck config."

View file

@ -370,7 +370,7 @@ and notes.mid = ? and cards.ord = ?""",
def change_notetype_info( def change_notetype_info(
self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId
) -> bytes: ) -> ChangeNotetypeInfo:
return self.col._backend.get_change_notetype_info( return self.col._backend.get_change_notetype_info(
old_notetype_id=old_notetype_id, new_notetype_id=new_notetype_id old_notetype_id=old_notetype_id, new_notetype_id=new_notetype_id
) )
@ -388,7 +388,8 @@ and notes.mid = ? and cards.ord = ?""",
field/template count. Each value represents the index in the previous field/template count. Each value represents the index in the previous
notetype. -1 indicates the original value will be discarded. notetype. -1 indicates the original value will be discarded.
""" """
return self.col._backend.change_notetype(input) op_bytes = self.col._backend.change_notetype_raw(input.SerializeToString())
return OpChanges.FromString(op_bytes)
# legacy API - used by unit tests and add-ons # legacy API - used by unit tests and add-ons
@ -414,15 +415,13 @@ and notes.mid = ? and cards.ord = ?""",
template_map = self._convert_legacy_map(cmap, len(newModel["tmpls"])) template_map = self._convert_legacy_map(cmap, len(newModel["tmpls"]))
self.col._backend.change_notetype( self.col._backend.change_notetype(
ChangeNotetypeRequest( note_ids=nids,
note_ids=nids, new_fields=field_map,
new_fields=field_map, new_templates=template_map,
new_templates=template_map, old_notetype_name=notetype["name"],
old_notetype_name=notetype["name"], old_notetype_id=notetype["id"],
old_notetype_id=notetype["id"], new_notetype_id=newModel["id"],
new_notetype_id=newModel["id"], current_schema=self.col.db.scalar("select scm from col"),
current_schema=self.col.db.scalar("select scm from col"),
)
) )
def _convert_legacy_map( def _convert_legacy_map(

View file

@ -86,7 +86,8 @@ class Scheduler(SchedulerBaseWithLegacy):
def answer_card(self, input: CardAnswer) -> OpChanges: def answer_card(self, input: CardAnswer) -> OpChanges:
"Update card to provided state, and remove it from queue." "Update card to provided state, and remove it from queue."
self.reps += 1 self.reps += 1
return self.col._backend.answer_card(input=input) op_bytes = self.col._backend.answer_card_raw(input.SerializeToString())
return OpChanges.FromString(op_bytes)
def state_is_leech(self, new_state: SchedulingState) -> bool: def state_is_leech(self, new_state: SchedulingState) -> bool:
"True if new state marks the card as a leech." "True if new state marks the card as a leech."

View file

@ -26,6 +26,7 @@ from anki.utils import ids2str
# public exports # public exports
TagTreeNode = tags_pb2.TagTreeNode TagTreeNode = tags_pb2.TagTreeNode
CompleteTagRequest = tags_pb2.CompleteTagRequest
MARKED_TAG = "marked" MARKED_TAG = "marked"
@ -68,11 +69,6 @@ class TagManager(DeprecatedNamesMixin):
"Set browser expansion state for tag, registering the tag if missing." "Set browser expansion state for tag, registering the tag if missing."
return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed) return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed)
def complete_tag(self, input_bytes: bytes) -> bytes:
input = tags_pb2.CompleteTagRequest()
input.ParseFromString(input_bytes)
return self.col._backend.complete_tag(input)
# Bulk addition/removal from specific notes # Bulk addition/removal from specific notes
############################################################# #############################################################

View file

@ -15,14 +15,13 @@ def test_stats():
col.addNote(note) col.addNote(note)
c = note.cards()[0] c = note.cards()[0]
# card stats # card stats
card_stats = CardStats() card_stats = col.card_stats_data(c.id)
card_stats.ParseFromString(col.card_stats_data(c.id))
assert card_stats.note_id == note.id assert card_stats.note_id == note.id
col.reset() col.reset()
c = col.sched.getCard() c = col.sched.getCard()
col.sched.answerCard(c, 3) col.sched.answerCard(c, 3)
col.sched.answerCard(c, 2) col.sched.answerCard(c, 2)
card_stats.ParseFromString(col.card_stats_data(c.id)) card_stats = col.card_stats_data(c.id)
assert len(card_stats.revlog) == 2 assert len(card_stats.revlog) == 2

View file

@ -13,6 +13,7 @@ import time
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
from http import HTTPStatus from http import HTTPStatus
from typing import Callable
import flask import flask
import flask_cors # type: ignore import flask_cors # type: ignore
@ -21,15 +22,14 @@ from waitress.server import create_server
import aqt import aqt
from anki import hooks from anki import hooks
from anki.cards import CardId from anki._vendor import stringcase
from anki.collection import GraphPreferences, OpChanges from anki.collection import OpChanges
from anki.decks import UpdateDeckConfigs from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs
from anki.models import NotetypeNames
from anki.scheduler.v3 import NextStates from anki.scheduler.v3 import NextStates
from anki.utils import dev_mode, from_json_bytes from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog from aqt.deckoptions import DeckOptionsDialog
from aqt.operations.deck import update_deck_configs from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.qt import * from aqt.qt import *
app = flask.Flask(__name__, root_path="/fake") app = flask.Flask(__name__, root_path="/fake")
@ -375,40 +375,20 @@ def _extract_request(
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path) return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
def graph_data() -> bytes:
args = from_json_bytes(request.data)
return aqt.mw.col.graph_data(search=args["search"], days=args["days"])
def graph_preferences() -> bytes:
return aqt.mw.col.get_graph_preferences()
def set_graph_preferences() -> None:
prefs = GraphPreferences()
prefs.ParseFromString(request.data)
aqt.mw.col.set_graph_preferences(prefs)
def congrats_info() -> bytes: def congrats_info() -> bytes:
if not aqt.mw.col.sched._is_finished(): if not aqt.mw.col.sched._is_finished():
aqt.mw.taskman.run_on_main(lambda: aqt.mw.moveToState("review")) aqt.mw.taskman.run_on_main(lambda: aqt.mw.moveToState("review"))
return aqt.mw.col.congrats_info() return raw_backend_request("congrats_info")()
def i18n_resources() -> bytes: def get_deck_configs_for_update() -> bytes:
args = from_json_bytes(request.data) config_bytes = aqt.mw.col._backend.get_deck_configs_for_update_raw(request.data)
return aqt.mw.col.i18n_resources(modules=args["modules"]) configs = DeckConfigsForUpdate.FromString(config_bytes)
configs.have_addons = aqt.mw.addonManager.dirty
return configs.SerializeToString()
def deck_configs_for_update() -> bytes: def update_deck_configs() -> bytes:
args = from_json_bytes(request.data)
msg = aqt.mw.col.decks.get_deck_configs_for_update(deck_id=args["deckId"])
msg.have_addons = aqt.mw.addonManager.dirty
return msg.SerializeToString()
def update_deck_configs_request() -> bytes:
# the regular change tracking machinery expects to be started on the main # the regular change tracking machinery expects to be started on the main
# thread and uses a callback on success, so we need to run this op on # thread and uses a callback on success, so we need to run this op on
# main, and return immediately from the web request # main, and return immediately from the web request
@ -421,7 +401,7 @@ def update_deck_configs_request() -> bytes:
window.reject() window.reject()
def handle_on_main() -> None: def handle_on_main() -> None:
update_deck_configs(parent=aqt.mw, input=input).success( update_deck_configs_op(parent=aqt.mw, input=input).success(
on_success on_success
).run_in_background() ).run_in_background()
@ -444,18 +424,6 @@ def set_next_card_states() -> bytes:
return b"" return b""
def notetype_names() -> bytes:
msg = NotetypeNames(entries=aqt.mw.col.models.all_names_and_ids())
return msg.SerializeToString()
def change_notetype_info() -> bytes:
args = from_json_bytes(request.data)
return aqt.mw.col.models.change_notetype_info(
old_notetype_id=args["oldNotetypeId"], new_notetype_id=args["newNotetypeId"]
)
def change_notetype() -> bytes: def change_notetype() -> bytes:
data = request.data data = request.data
@ -468,31 +436,49 @@ def change_notetype() -> bytes:
return b"" return b""
def complete_tag() -> bytes: post_handler_list = [
return aqt.mw.col.tags.complete_tag(request.data) congrats_info,
get_deck_configs_for_update,
update_deck_configs,
next_card_states,
set_next_card_states,
change_notetype,
]
def card_stats() -> bytes: exposed_backend_list = [
args = from_json_bytes(request.data) # I18nService
return aqt.mw.col.card_stats_data(CardId(args["cardId"])) "i18n_resources",
# NotesService
"get_note",
# NotetypesService
"get_notetype_names",
"get_change_notetype_info",
# StatsService
"card_stats",
"graphs",
"get_graph_preferences",
"set_graph_preferences",
# TagsService
"complete_tag",
]
# these require a collection def raw_backend_request(endpoint: str) -> Callable[[], bytes]:
# check for key at startup
from anki._backend import RustBackend
assert hasattr(RustBackend, f"{endpoint}_raw")
return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
# all methods in here require a collection
post_handlers = { post_handlers = {
"graphData": graph_data, stringcase.camelcase(handler.__name__): handler for handler in post_handler_list
"graphPreferences": graph_preferences, } | {
"setGraphPreferences": set_graph_preferences, stringcase.camelcase(handler): raw_backend_request(handler)
"deckConfigsForUpdate": deck_configs_for_update, for handler in exposed_backend_list
"updateDeckConfigs": update_deck_configs_request,
"nextCardStates": next_card_states,
"setNextCardStates": set_next_card_states,
"changeNotetypeInfo": change_notetype_info,
"notetypeNames": notetype_names,
"changeNotetype": change_notetype,
"i18nResources": i18n_resources,
"congratsInfo": congrats_info,
"completeTag": complete_tag,
"cardStats": card_stats,
} }

View file

@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type { Stats } from "../lib/proto"; import type { Stats } from "../lib/proto";
import { getCardStats } from "./lib"; import { stats as statsService } from "../lib/proto";
import Container from "../components/Container.svelte"; import Container from "../components/Container.svelte";
import Row from "../components/Row.svelte"; import Row from "../components/Row.svelte";
import CardStats from "./CardStats.svelte"; import CardStats from "./CardStats.svelte";
@ -19,7 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function updateStats(cardId: number): Promise<void> { async function updateStats(cardId: number): Promise<void> {
const requestedCardId = cardId; const requestedCardId = cardId;
const cardStats = await getCardStats(requestedCardId); const cardStats = await statsService.cardStats({ cid: requestedCardId });
/* Skip if another update has been triggered in the meantime. */ /* Skip if another update has been triggered in the meantime. */
if (requestedCardId === cardId) { if (requestedCardId === cardId) {

View file

@ -1,11 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { Stats } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
export async function getCardStats(cardId: number): Promise<Stats.CardStatsResponse> {
return Stats.CardStatsResponse.decode(
await postRequest("/_anki/cardStats", JSON.stringify({ cardId })),
);
}

View file

@ -1,39 +1,35 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import { ChangeNotetypeState, getChangeNotetypeInfo, getNotetypeNames } from "./lib";
import { setupI18n, ModuleName } from "../lib/i18n"; import { setupI18n, ModuleName } from "../lib/i18n";
import { notetypes, empty } from "../lib/proto";
import { checkNightMode } from "../lib/nightmode"; import { checkNightMode } from "../lib/nightmode";
import { ChangeNotetypeState } from "./lib";
import ChangeNotetypePage from "./ChangeNotetypePage.svelte"; import ChangeNotetypePage from "./ChangeNotetypePage.svelte";
import "./change-notetype-base.css"; import "./change-notetype-base.css";
const notetypeNames = notetypes.getNotetypeNames(empty);
const i18n = setupI18n({
modules: [ModuleName.ACTIONS, ModuleName.CHANGE_NOTETYPE, ModuleName.KEYBOARD],
});
export async function setupChangeNotetypePage( export async function setupChangeNotetypePage(
oldNotetypeId: number, oldNotetypeId: number,
newNotetypeId: number, newNotetypeId: number,
): Promise<ChangeNotetypePage> { ): Promise<ChangeNotetypePage> {
const [info, names] = await Promise.all([ const changeNotetypeInfo = notetypes.getChangeNotetypeInfo({
getChangeNotetypeInfo(oldNotetypeId, newNotetypeId), oldNotetypeId,
getNotetypeNames(), newNotetypeId,
setupI18n({ });
modules: [ const [names, info] = await Promise.all([notetypeNames, changeNotetypeInfo, i18n]);
ModuleName.ACTIONS,
ModuleName.CHANGE_NOTETYPE,
ModuleName.KEYBOARD,
],
}),
]);
checkNightMode(); checkNightMode();
const state = new ChangeNotetypeState(names, info); const state = new ChangeNotetypeState(names, info);
return new ChangeNotetypePage({ return new ChangeNotetypePage({
target: document.body, target: document.body,
props: { state } as any, props: { state },
}); });
} }

View file

@ -2,37 +2,10 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { Notetypes } from "../lib/proto"; import { notetypes, Notetypes } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { readable, Readable } from "svelte/store"; import { readable, Readable } from "svelte/store";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
export async function getNotetypeNames(): Promise<Notetypes.NotetypeNames> {
return Notetypes.NotetypeNames.decode(
await postRequest("/_anki/notetypeNames", ""),
);
}
export async function getChangeNotetypeInfo(
oldNotetypeId: number,
newNotetypeId: number,
): Promise<Notetypes.ChangeNotetypeInfo> {
return Notetypes.ChangeNotetypeInfo.decode(
await postRequest(
"/_anki/changeNotetypeInfo",
JSON.stringify({ oldNotetypeId, newNotetypeId }),
),
);
}
export async function changeNotetype(
input: Notetypes.ChangeNotetypeRequest,
): Promise<void> {
const data: Uint8Array = Notetypes.ChangeNotetypeRequest.encode(input).finish();
await postRequest("/_anki/changeNotetype", data);
return;
}
function nullToNegativeOne(list: (number | null)[]): number[] { function nullToNegativeOne(list: (number | null)[]): number[] {
return list.map((val) => val ?? -1); return list.map((val) => val ?? -1);
} }
@ -51,11 +24,11 @@ export class ChangeNotetypeInfoWrapper {
constructor(info: Notetypes.ChangeNotetypeInfo) { constructor(info: Notetypes.ChangeNotetypeInfo) {
this.info = info; this.info = info;
const templates = info.input!.newTemplates!; const templates = info.input?.newTemplates ?? [];
if (templates.length > 0) { if (templates.length > 0) {
this.templates = negativeOneToNull(templates); this.templates = negativeOneToNull(templates);
} }
this.fields = negativeOneToNull(info.input!.newFields!); this.fields = negativeOneToNull(info.input?.newFields ?? []);
this.oldNotetypeName = info.oldNotetypeName; this.oldNotetypeName = info.oldNotetypeName;
} }
@ -141,6 +114,7 @@ export enum MapContext {
Field, Field,
Template, Template,
} }
export class ChangeNotetypeState { export class ChangeNotetypeState {
readonly info: Readable<ChangeNotetypeInfoWrapper>; readonly info: Readable<ChangeNotetypeInfoWrapper>;
readonly notetypes: Readable<NotetypeListEntry[]>; readonly notetypes: Readable<NotetypeListEntry[]>;
@ -168,10 +142,11 @@ export class ChangeNotetypeState {
async setTargetNotetypeIndex(idx: number): Promise<void> { async setTargetNotetypeIndex(idx: number): Promise<void> {
this.info_.input().newNotetypeId = this.notetypeNames.entries[idx].id!; this.info_.input().newNotetypeId = this.notetypeNames.entries[idx].id!;
this.notetypesSetter(this.buildNotetypeList()); this.notetypesSetter(this.buildNotetypeList());
const newInfo = await getChangeNotetypeInfo( const { oldNotetypeId, newNotetypeId } = this.info_.input();
this.info_.input().oldNotetypeId, const newInfo = await notetypes.getChangeNotetypeInfo({
this.info_.input().newNotetypeId, oldNotetypeId,
); newNotetypeId,
});
this.info_ = new ChangeNotetypeInfoWrapper(newInfo); this.info_ = new ChangeNotetypeInfoWrapper(newInfo);
this.info_.unusedItems(MapContext.Field); this.info_.unusedItems(MapContext.Field);
@ -197,16 +172,16 @@ export class ChangeNotetypeState {
this.infoSetter(this.info_); this.infoSetter(this.info_);
} }
dataForSaving(): Notetypes.ChangeNotetypeRequest {
return this.info_.intoInput();
}
async save(): Promise<void> { async save(): Promise<void> {
if (this.info_.unchanged()) { if (this.info_.unchanged()) {
alert("No changes to save"); alert("No changes to save");
return; return;
} }
await changeNotetype(this.dataForSaving()); await notetypes.changeNotetype(this.dataForSaving());
}
dataForSaving(): Notetypes.ChangeNotetypeRequest {
return this.info_.intoInput();
} }
private buildNotetypeList(): NotetypeListEntry[] { private buildNotetypeList(): NotetypeListEntry[] {

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getCongratsInfo } from "./lib"; import { scheduler, empty } from "../lib/proto";
import { setupI18n, ModuleName } from "../lib/i18n"; import { setupI18n, ModuleName } from "../lib/i18n";
import { checkNightMode } from "../lib/nightmode"; import { checkNightMode } from "../lib/nightmode";
@ -14,16 +14,15 @@ export async function setupCongrats(): Promise<CongratsPage> {
checkNightMode(); checkNightMode();
await i18n; await i18n;
const info = await getCongratsInfo(); const info = await scheduler.congratsInfo(empty);
const page = new CongratsPage({ const page = new CongratsPage({
target: document.body, target: document.body,
props: { info }, props: { info },
}); });
setInterval(() => { setInterval(async () => {
getCongratsInfo().then((info) => { const info = await scheduler.congratsInfo(empty);
page.$set({ info }); page.$set({ info });
});
}, 60000); }, 60000);
return page; return page;

View file

@ -1,17 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { Scheduler } from "../lib/proto"; import type { Scheduler } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { naturalUnit, unitAmount, unitName } from "../lib/time"; import { naturalUnit, unitAmount, unitName } from "../lib/time";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
export async function getCongratsInfo(): Promise<Scheduler.CongratsInfoResponse> {
return Scheduler.CongratsInfoResponse.decode(
await postRequest("/_anki/congratsInfo", ""),
);
}
export function buildNextLearnMsg(info: Scheduler.CongratsInfoResponse): string { export function buildNextLearnMsg(info: Scheduler.CongratsInfoResponse): string {
const secsUntil = info.secsUntilNextLearn; const secsUntil = info.secsUntilNextLearn;
// next learning card not due today? // next learning card not due today?

View file

@ -7,8 +7,9 @@
import "../sveltelib/export-runtime"; import "../sveltelib/export-runtime";
import { getDeckOptionsInfo, DeckOptionsState } from "./lib"; import { DeckOptionsState } from "./lib";
import { setupI18n, ModuleName } from "../lib/i18n"; import { setupI18n, ModuleName } from "../lib/i18n";
import { deckConfig } from "../lib/proto";
import { checkNightMode } from "../lib/nightmode"; import { checkNightMode } from "../lib/nightmode";
import { touchDeviceKey, modalsKey } from "../components/context-keys"; import { touchDeviceKey, modalsKey } from "../components/context-keys";
@ -24,8 +25,11 @@ const i18n = setupI18n({
], ],
}); });
export async function setupDeckOptions(deckId: number): Promise<DeckOptionsPage> { export async function setupDeckOptions(did: number): Promise<DeckOptionsPage> {
const [info] = await Promise.all([getDeckOptionsInfo(deckId), i18n]); const [info] = await Promise.all([
deckConfig.getDeckConfigsForUpdate({ did }),
i18n,
]);
checkNightMode(); checkNightMode();
@ -33,7 +37,7 @@ export async function setupDeckOptions(deckId: number): Promise<DeckOptionsPage>
context.set(modalsKey, new Map()); context.set(modalsKey, new Map());
context.set(touchDeviceKey, "ontouchstart" in document.documentElement); context.set(touchDeviceKey, "ontouchstart" in document.documentElement);
const state = new DeckOptionsState(deckId, info); const state = new DeckOptionsState(did, info);
return new DeckOptionsPage({ return new DeckOptionsPage({
target: document.body, target: document.body,
props: { state }, props: { state },

View file

@ -244,8 +244,8 @@ test("saving", () => {
expect(out.targetDeckId).toBe(123); expect(out.targetDeckId).toBe(123);
// in no-changes case, currently selected config should // in no-changes case, currently selected config should
// be returned // be returned
expect(out.configs.length).toBe(1); expect(out.configs!.length).toBe(1);
expect(out.configs[0].name).toBe("another one"); expect(out.configs![0].name).toBe("another one");
expect(out.applyToChildren).toBe(false); expect(out.applyToChildren).toBe(false);
// rename, then change current deck // rename, then change current deck
@ -255,7 +255,7 @@ test("saving", () => {
// renamed deck should be in changes, with current deck as last element // renamed deck should be in changes, with current deck as last element
out = state.dataForSaving(true); out = state.dataForSaving(true);
expect(out.configs.map((c) => c.name)).toStrictEqual(["zzz", "Default"]); expect(out.configs!.map((c) => c.name)).toStrictEqual(["zzz", "Default"]);
expect(out.applyToChildren).toBe(true); expect(out.applyToChildren).toBe(true);
// start again, adding new deck // start again, adding new deck
@ -275,7 +275,7 @@ test("saving", () => {
// only contain Default, which is the new current deck // only contain Default, which is the new current deck
out = state.dataForSaving(true); out = state.dataForSaving(true);
expect(out.removedConfigIds).toStrictEqual([1618570764780]); expect(out.removedConfigIds).toStrictEqual([1618570764780]);
expect(out.configs.map((c) => c.name)).toStrictEqual(["Default"]); expect(out.configs!.map((c) => c.name)).toStrictEqual(["Default"]);
}); });
test("aux data", () => { test("aux data", () => {
@ -302,8 +302,8 @@ test("aux data", () => {
// ensure changes serialize // ensure changes serialize
const out = state.dataForSaving(true); const out = state.dataForSaving(true);
expect(out.configs.length).toBe(2); expect(out.configs!.length).toBe(2);
const json = out.configs.map( const json = out.configs!.map(
(c) => (c) =>
JSON.parse(new TextDecoder().decode((c.config as any).other)) as Record< JSON.parse(new TextDecoder().decode((c.config as any).other)) as Record<
string, string,

View file

@ -1,29 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { DeckConfig } from "../lib/proto"; import { DeckConfig, deckConfig } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { Writable, writable, get, Readable, readable } from "svelte/store"; import { Writable, writable, get, Readable, readable } from "svelte/store";
import { isEqual, cloneDeep } from "lodash-es"; import { isEqual, cloneDeep } from "lodash-es";
import { localeCompare } from "../lib/i18n"; import { localeCompare } from "../lib/i18n";
import type { DynamicSvelteComponent } from "../sveltelib/dynamicComponent"; import type { DynamicSvelteComponent } from "../sveltelib/dynamicComponent";
export async function getDeckOptionsInfo(
deckId: number,
): Promise<DeckConfig.DeckConfigsForUpdate> {
return DeckConfig.DeckConfigsForUpdate.decode(
await postRequest("/_anki/deckConfigsForUpdate", JSON.stringify({ deckId })),
);
}
export async function saveDeckOptions(
input: DeckConfig.UpdateDeckConfigsRequest,
): Promise<void> {
const data: Uint8Array = DeckConfig.UpdateDeckConfigsRequest.encode(input).finish();
await postRequest("/_anki/updateDeckConfigs", data);
return;
}
export type DeckOptionsId = number; export type DeckOptionsId = number;
export interface ConfigWithCount { export interface ConfigWithCount {
@ -185,7 +168,9 @@ export class DeckOptionsState {
this.updateConfigList(); this.updateConfigList();
} }
dataForSaving(applyToChildren: boolean): DeckConfig.UpdateDeckConfigsRequest { dataForSaving(
applyToChildren: boolean,
): NonNullable<DeckConfig.IUpdateDeckConfigsRequest> {
const modifiedConfigsExcludingCurrent = this.configs const modifiedConfigsExcludingCurrent = this.configs
.map((c) => c.config) .map((c) => c.config)
.filter((c, idx) => { .filter((c, idx) => {
@ -199,17 +184,17 @@ export class DeckOptionsState {
// current must come last, even if unmodified // current must come last, even if unmodified
this.configs[this.selectedIdx].config, this.configs[this.selectedIdx].config,
]; ];
return DeckConfig.UpdateDeckConfigsRequest.create({ return {
targetDeckId: this.targetDeckId, targetDeckId: this.targetDeckId,
removedConfigIds: this.removedConfigs, removedConfigIds: this.removedConfigs,
configs, configs,
applyToChildren, applyToChildren,
cardStateCustomizer: get(this.cardStateCustomizer), cardStateCustomizer: get(this.cardStateCustomizer),
}); };
} }
async save(applyToChildren: boolean): Promise<void> { async save(applyToChildren: boolean): Promise<void> {
await saveDeckOptions(this.dataForSaving(applyToChildren)); await deckConfig.updateDeckConfigs(this.dataForSaving(applyToChildren));
} }
private onCurrentConfigChanged(config: ConfigInner): void { private onCurrentConfigChanged(config: ConfigInner): void {

View file

@ -19,8 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
replaceWithUnicodeSeparator, replaceWithUnicodeSeparator,
replaceWithColons, replaceWithColons,
} from "./tags"; } from "./tags";
import { Tags } from "../lib/proto"; import { tags as tagsService } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { execCommand } from "./helpers"; import { execCommand } from "./helpers";
export let size: number; export let size: number;
@ -56,14 +55,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let autocompleteDisabled: boolean = false; let autocompleteDisabled: boolean = false;
async function fetchSuggestions(input: string): Promise<string[]> { async function fetchSuggestions(input: string): Promise<string[]> {
const data = await postRequest( const { tags } = await tagsService.completeTag({ input, matchLimit: 500 });
"/_anki/completeTag", return tags;
Tags.CompleteTagRequest.encode(
Tags.CompleteTagRequest.create({ input, matchLimit: 500 }),
).finish(),
);
const response = Tags.CompleteTagResponse.decode(data);
return response.tags;
} }
const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/; const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;

View file

@ -4,49 +4,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { PreferenceRaw, PreferencePayload } from "../sveltelib/preferences";
import { Stats } from "../lib/proto"; import { Stats } from "../lib/proto";
import { postRequest } from "../lib/postrequest"; import { stats, empty } from "../lib/proto";
import type { PreferenceRaw } from "../sveltelib/preferences";
import useAsync from "../sveltelib/async"; import useAsync from "../sveltelib/async";
import useAsyncReactive from "../sveltelib/asyncReactive"; import useAsyncReactive from "../sveltelib/asyncReactive";
import { getPreferences } from "../sveltelib/preferences"; import { getPreferences } from "../sveltelib/preferences";
import { daysToRevlogRange } from "./graph-helpers"; import { daysToRevlogRange } from "./graph-helpers";
export let search: Writable<string>; export let search: Writable<string>;
export let days: Writable<number>; export let days: Writable<number>;
async function getGraphData(
search: string,
days: number,
): Promise<Stats.GraphsResponse> {
return Stats.GraphsResponse.decode(
await postRequest("/_anki/graphData", JSON.stringify({ search, days })),
);
}
async function getGraphPreferences(): Promise<Stats.GraphPreferences> {
return Stats.GraphPreferences.decode(
await postRequest("/_anki/graphPreferences", JSON.stringify({})),
);
}
async function setGraphPreferences(
prefs: PreferencePayload<Stats.GraphPreferences>,
): Promise<void> {
await postRequest(
"/_anki/setGraphPreferences",
Stats.GraphPreferences.encode(prefs).finish(),
);
}
const { const {
loading: graphLoading, loading: graphLoading,
error: graphError, error: graphError,
value: graphValue, value: graphValue,
} = useAsyncReactive(() => getGraphData($search, $days), [search, days]); } = useAsyncReactive(
() => stats.graphs({ search: $search, days: $days }),
[search, days],
);
const { const {
loading: prefsLoading, loading: prefsLoading,
@ -54,8 +30,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value: prefsValue, value: prefsValue,
} = useAsync(() => } = useAsync(() =>
getPreferences( getPreferences(
getGraphPreferences, () => stats.getGraphPreferences(empty),
setGraphPreferences, async (input: Stats.IGraphPreferences): Promise<void> => {
stats.setGraphPreferences(input);
},
Stats.GraphPreferences.toObject.bind(Stats.GraphPreferences) as ( Stats.GraphPreferences.toObject.bind(Stats.GraphPreferences) as (
preferences: Stats.GraphPreferences, preferences: Stats.GraphPreferences,
options: { defaults: boolean }, options: { defaults: boolean },

View file

@ -6,6 +6,7 @@ import { FluentBundle, FluentResource } from "@fluent/bundle";
import { firstLanguage, setBundles } from "./bundles"; import { firstLanguage, setBundles } from "./bundles";
import type { ModuleName } from "./modules"; import type { ModuleName } from "./modules";
import { i18n } from "../proto";
export function supportsVerticalText(): boolean { export function supportsVerticalText(): boolean {
const firstLang = firstLanguage(); const firstLang = firstLanguage();
@ -73,19 +74,13 @@ export function withoutUnicodeIsolation(s: string): string {
} }
export async function setupI18n(args: { modules: ModuleName[] }): Promise<void> { export async function setupI18n(args: { modules: ModuleName[] }): Promise<void> {
const resp = await fetch("/_anki/i18nResources", { const resources = await i18n.i18nResources(args);
method: "POST", const json = JSON.parse(String.fromCharCode(...resources.json));
body: JSON.stringify(args),
});
if (!resp.ok) {
throw Error(`unexpected reply: ${resp.statusText}`);
}
const json = await resp.json();
const newBundles: FluentBundle[] = []; const newBundles: FluentBundle[] = [];
for (const i in json.resources) { for (const res in json.resources) {
const text = json.resources[i]; const text = json.resources[res];
const lang = json.langs[i]; const lang = json.langs[res];
const bundle = new FluentBundle([lang, "en-US"]); const bundle = new FluentBundle([lang, "en-US"]);
const resource = new FluentResource(text); const resource = new FluentResource(text);
bundle.addResource(resource); bundle.addResource(resource);

View file

@ -1,17 +1,77 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import { anki } from "./backend_proto"; import { anki } from "./backend_proto";
import type { RPCImpl, RPCImplCallback, Message, rpc } from "protobufjs";
import Cards = anki.cards; import Cards = anki.cards;
import Collection = anki.collection;
import DeckConfig = anki.deckconfig; import DeckConfig = anki.deckconfig;
import Decks = anki.decks;
import Generic = anki.generic; import Generic = anki.generic;
import I18n = anki.i18n;
import Notes = anki.notes;
import Notetypes = anki.notetypes; import Notetypes = anki.notetypes;
import Scheduler = anki.scheduler; import Scheduler = anki.scheduler;
import Stats = anki.stats; import Stats = anki.stats;
import Tags = anki.tags; import Tags = anki.tags;
export { Stats, Cards, DeckConfig, Notetypes, Scheduler, Tags }; export { Cards, Collection, Decks, Generic, Notes };
export const empty = Generic.Empty.encode(Generic.Empty.create()).finish();
async function serviceCallback(
method: rpc.ServiceMethod<Message<any>, Message<any>>,
requestData: Uint8Array,
callback: RPCImplCallback,
): Promise<void> {
const headers = new Headers();
headers.set("Content-type", "application/octet-stream");
const methodName = method.name[0].toLowerCase() + method.name.substring(1);
const path = `/_anki/${methodName}`;
try {
const result = await fetch(path, {
method: "POST",
headers,
body: requestData,
});
const blob = await result.blob();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
callback(null, uint8Array);
} catch (error) {
console.log("error caught");
callback(error as Error, null);
}
}
export { DeckConfig };
export const deckConfig = DeckConfig.DeckConfigService.create(
serviceCallback as RPCImpl,
);
export { I18n };
export const i18n = I18n.I18nService.create(serviceCallback as RPCImpl);
export { Notetypes };
export const notetypes = Notetypes.NotetypesService.create(serviceCallback as RPCImpl);
export { Scheduler };
export const scheduler = Scheduler.SchedulerService.create(serviceCallback as RPCImpl);
export { Stats };
export const stats = Stats.StatsService.create(serviceCallback as RPCImpl);
export { Tags };
export const tags = Tags.TagsService.create(serviceCallback as RPCImpl);
export function unwrapOptionalNumber( export function unwrapOptionalNumber(
msg: Generic.IInt64 | Generic.IUInt32 | Generic.IInt32 | null | undefined, msg: Generic.IInt64 | Generic.IUInt32 | Generic.IInt32 | null | undefined,