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(
self, module_index: int, message_index: int, **kwargs: str | int | float
) -> str:
return self.translate_string(
translate_string_in(
module_index=module_index, message_index=message_index, **kwargs
)
args = {
k: i18n_pb2.TranslateArgValue(str=v)
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(
self,
seconds: Any,
@ -123,31 +133,17 @@ class RustBackend(RustBackendGenerated):
)
return self.format_timespan(seconds=seconds, context=context)
def _run_command(self, service: int, method: int, input: Any) -> bytes:
input_bytes = input.SerializeToString()
def _run_command(self, service: int, method: int, input: bytes) -> bytes:
try:
return self._backend.command(service, method, input_bytes)
return self._backend.command(service, method, input)
except Exception as error:
err_bytes = bytes(error.args[0])
error_bytes = bytes(error.args[0])
err = backend_pb2.BackendError()
err.ParseFromString(err_bytes)
err.ParseFromString(error_bytes)
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):
def __init__(self, backend: ref[RustBackend] | None):
self.backend = backend

View file

@ -51,24 +51,7 @@ LABEL_OPTIONAL = 1
LABEL_REQUIRED = 2
LABEL_REPEATED = 3
# messages we don't want to unroll in codegen
SKIP_UNROLL_INPUT = {
"TranslateString",
"SetPreferences",
"UpdateDeckConfigs",
"AnswerCard",
"ChangeNotetype",
"CompleteTag",
}
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
SKIP_DECODE = {
"Graphs",
"GetGraphPreferences",
"GetChangeNotetypeInfo",
"CompleteTag",
"CardStats",
}
RAW_ONLY = {"TranslateString"}
def python_type(field):
@ -116,62 +99,59 @@ def fix_snakecase(name):
return name
def get_input_args(msg):
fields = sorted(msg.fields, key=lambda x: x.number)
def get_input_args(input_type):
fields = sorted(input_type.fields, key=lambda x: x.number)
self_star = ["self"]
if len(fields) >= 2:
self_star.append("*")
return ", ".join(self_star + [f"{f.name}: {python_type(f)}" for f in fields])
def get_input_assign(msg):
fields = sorted(msg.fields, key=lambda x: x.number)
def get_input_assign(input_type):
fields = sorted(input_type.fields, key=lambda x: x.number)
return ", ".join(f"{f.name}={f.name}" for f in fields)
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))
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 (
len(method.output_type.fields) == 1
and method.output_type.fields[0].type != TYPE_ENUM
and method.name not in SKIP_UNROLL_OUTPUT
):
# unwrap single return arg
f = method.output_type.fields[0]
single_field = f".{f.name}"
return_type = python_type(f)
single_attribute = f".{f.name}"
else:
single_field = ""
return_type = fullname(method.output_type.full_name)
if method.name in SKIP_DECODE:
return_type = "bytes"
single_attribute = ""
buf = f"""\
def {name}({input_args}) -> {return_type}:
{input_assign_outer}"""
def {name}_raw(self, message: bytes) -> bytes:
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)}()
output.ParseFromString(self._run_command({service_idx}, {method_idx}, input))
return output{single_field}
if not method.name in RAW_ONLY:
buf += f"""\
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

View file

@ -804,25 +804,12 @@ class Collection(DeprecatedNamesMixin):
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)
def studied_today(self) -> str:
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
##########################################################################

View file

@ -285,7 +285,8 @@ class DeckManager(DeprecatedNamesMixin):
return self.col._backend.get_deck_configs_for_update(deck_id)
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]:
"A list of all deck config."

View file

@ -370,7 +370,7 @@ and notes.mid = ? and cards.ord = ?""",
def change_notetype_info(
self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId
) -> bytes:
) -> ChangeNotetypeInfo:
return self.col._backend.get_change_notetype_info(
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
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
@ -414,15 +415,13 @@ and notes.mid = ? and cards.ord = ?""",
template_map = self._convert_legacy_map(cmap, len(newModel["tmpls"]))
self.col._backend.change_notetype(
ChangeNotetypeRequest(
note_ids=nids,
new_fields=field_map,
new_templates=template_map,
old_notetype_name=notetype["name"],
old_notetype_id=notetype["id"],
new_notetype_id=newModel["id"],
current_schema=self.col.db.scalar("select scm from col"),
)
note_ids=nids,
new_fields=field_map,
new_templates=template_map,
old_notetype_name=notetype["name"],
old_notetype_id=notetype["id"],
new_notetype_id=newModel["id"],
current_schema=self.col.db.scalar("select scm from col"),
)
def _convert_legacy_map(

View file

@ -86,7 +86,8 @@ class Scheduler(SchedulerBaseWithLegacy):
def answer_card(self, input: CardAnswer) -> OpChanges:
"Update card to provided state, and remove it from queue."
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:
"True if new state marks the card as a leech."

View file

@ -26,6 +26,7 @@ from anki.utils import ids2str
# public exports
TagTreeNode = tags_pb2.TagTreeNode
CompleteTagRequest = tags_pb2.CompleteTagRequest
MARKED_TAG = "marked"
@ -68,11 +69,6 @@ class TagManager(DeprecatedNamesMixin):
"Set browser expansion state for tag, registering the tag if missing."
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
#############################################################

View file

@ -15,14 +15,13 @@ def test_stats():
col.addNote(note)
c = note.cards()[0]
# card stats
card_stats = CardStats()
card_stats.ParseFromString(col.card_stats_data(c.id))
card_stats = col.card_stats_data(c.id)
assert card_stats.note_id == note.id
col.reset()
c = col.sched.getCard()
col.sched.answerCard(c, 3)
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

View file

@ -13,6 +13,7 @@ import time
import traceback
from dataclasses import dataclass
from http import HTTPStatus
from typing import Callable
import flask
import flask_cors # type: ignore
@ -21,15 +22,14 @@ from waitress.server import create_server
import aqt
from anki import hooks
from anki.cards import CardId
from anki.collection import GraphPreferences, OpChanges
from anki.decks import UpdateDeckConfigs
from anki.models import NotetypeNames
from anki._vendor import stringcase
from anki.collection import OpChanges
from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs
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.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 *
app = flask.Flask(__name__, root_path="/fake")
@ -375,40 +375,20 @@ def _extract_request(
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:
if not aqt.mw.col.sched._is_finished():
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:
args = from_json_bytes(request.data)
return aqt.mw.col.i18n_resources(modules=args["modules"])
def get_deck_configs_for_update() -> bytes:
config_bytes = aqt.mw.col._backend.get_deck_configs_for_update_raw(request.data)
configs = DeckConfigsForUpdate.FromString(config_bytes)
configs.have_addons = aqt.mw.addonManager.dirty
return configs.SerializeToString()
def deck_configs_for_update() -> 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:
def update_deck_configs() -> bytes:
# 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
# main, and return immediately from the web request
@ -421,7 +401,7 @@ def update_deck_configs_request() -> bytes:
window.reject()
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
).run_in_background()
@ -444,18 +424,6 @@ def set_next_card_states() -> bytes:
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:
data = request.data
@ -468,31 +436,49 @@ def change_notetype() -> bytes:
return b""
def complete_tag() -> bytes:
return aqt.mw.col.tags.complete_tag(request.data)
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
update_deck_configs,
next_card_states,
set_next_card_states,
change_notetype,
]
def card_stats() -> bytes:
args = from_json_bytes(request.data)
return aqt.mw.col.card_stats_data(CardId(args["cardId"]))
exposed_backend_list = [
# I18nService
"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 = {
"graphData": graph_data,
"graphPreferences": graph_preferences,
"setGraphPreferences": set_graph_preferences,
"deckConfigsForUpdate": deck_configs_for_update,
"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,
stringcase.camelcase(handler.__name__): handler for handler in post_handler_list
} | {
stringcase.camelcase(handler): raw_backend_request(handler)
for handler in exposed_backend_list
}

View file

@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { Stats } from "../lib/proto";
import { getCardStats } from "./lib";
import { stats as statsService } from "../lib/proto";
import Container from "../components/Container.svelte";
import Row from "../components/Row.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> {
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. */
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
// 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 { notetypes, empty } from "../lib/proto";
import { checkNightMode } from "../lib/nightmode";
import { ChangeNotetypeState } from "./lib";
import ChangeNotetypePage from "./ChangeNotetypePage.svelte";
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(
oldNotetypeId: number,
newNotetypeId: number,
): Promise<ChangeNotetypePage> {
const [info, names] = await Promise.all([
getChangeNotetypeInfo(oldNotetypeId, newNotetypeId),
getNotetypeNames(),
setupI18n({
modules: [
ModuleName.ACTIONS,
ModuleName.CHANGE_NOTETYPE,
ModuleName.KEYBOARD,
],
}),
]);
const changeNotetypeInfo = notetypes.getChangeNotetypeInfo({
oldNotetypeId,
newNotetypeId,
});
const [names, info] = await Promise.all([notetypeNames, changeNotetypeInfo, i18n]);
checkNightMode();
const state = new ChangeNotetypeState(names, info);
return new ChangeNotetypePage({
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
import * as tr from "../lib/ftl";
import { Notetypes } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { notetypes, Notetypes } from "../lib/proto";
import { readable, Readable } from "svelte/store";
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[] {
return list.map((val) => val ?? -1);
}
@ -51,11 +24,11 @@ export class ChangeNotetypeInfoWrapper {
constructor(info: Notetypes.ChangeNotetypeInfo) {
this.info = info;
const templates = info.input!.newTemplates!;
const templates = info.input?.newTemplates ?? [];
if (templates.length > 0) {
this.templates = negativeOneToNull(templates);
}
this.fields = negativeOneToNull(info.input!.newFields!);
this.fields = negativeOneToNull(info.input?.newFields ?? []);
this.oldNotetypeName = info.oldNotetypeName;
}
@ -141,6 +114,7 @@ export enum MapContext {
Field,
Template,
}
export class ChangeNotetypeState {
readonly info: Readable<ChangeNotetypeInfoWrapper>;
readonly notetypes: Readable<NotetypeListEntry[]>;
@ -168,10 +142,11 @@ export class ChangeNotetypeState {
async setTargetNotetypeIndex(idx: number): Promise<void> {
this.info_.input().newNotetypeId = this.notetypeNames.entries[idx].id!;
this.notetypesSetter(this.buildNotetypeList());
const newInfo = await getChangeNotetypeInfo(
this.info_.input().oldNotetypeId,
this.info_.input().newNotetypeId,
);
const { oldNotetypeId, newNotetypeId } = this.info_.input();
const newInfo = await notetypes.getChangeNotetypeInfo({
oldNotetypeId,
newNotetypeId,
});
this.info_ = new ChangeNotetypeInfoWrapper(newInfo);
this.info_.unusedItems(MapContext.Field);
@ -197,16 +172,16 @@ export class ChangeNotetypeState {
this.infoSetter(this.info_);
}
dataForSaving(): Notetypes.ChangeNotetypeRequest {
return this.info_.intoInput();
}
async save(): Promise<void> {
if (this.info_.unchanged()) {
alert("No changes to save");
return;
}
await changeNotetype(this.dataForSaving());
}
dataForSaving(): Notetypes.ChangeNotetypeRequest {
return this.info_.intoInput();
await notetypes.changeNotetype(this.dataForSaving());
}
private buildNotetypeList(): NotetypeListEntry[] {

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 { checkNightMode } from "../lib/nightmode";
@ -14,16 +14,15 @@ export async function setupCongrats(): Promise<CongratsPage> {
checkNightMode();
await i18n;
const info = await getCongratsInfo();
const info = await scheduler.congratsInfo(empty);
const page = new CongratsPage({
target: document.body,
props: { info },
});
setInterval(() => {
getCongratsInfo().then((info) => {
page.$set({ info });
});
setInterval(async () => {
const info = await scheduler.congratsInfo(empty);
page.$set({ info });
}, 60000);
return page;

View file

@ -1,17 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { Scheduler } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import type { Scheduler } from "../lib/proto";
import { naturalUnit, unitAmount, unitName } from "../lib/time";
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 {
const secsUntil = info.secsUntilNextLearn;
// next learning card not due today?

View file

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

View file

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

View file

@ -1,29 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { DeckConfig } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { DeckConfig, deckConfig } from "../lib/proto";
import { Writable, writable, get, Readable, readable } from "svelte/store";
import { isEqual, cloneDeep } from "lodash-es";
import { localeCompare } from "../lib/i18n";
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 interface ConfigWithCount {
@ -185,7 +168,9 @@ export class DeckOptionsState {
this.updateConfigList();
}
dataForSaving(applyToChildren: boolean): DeckConfig.UpdateDeckConfigsRequest {
dataForSaving(
applyToChildren: boolean,
): NonNullable<DeckConfig.IUpdateDeckConfigsRequest> {
const modifiedConfigsExcludingCurrent = this.configs
.map((c) => c.config)
.filter((c, idx) => {
@ -199,17 +184,17 @@ export class DeckOptionsState {
// current must come last, even if unmodified
this.configs[this.selectedIdx].config,
];
return DeckConfig.UpdateDeckConfigsRequest.create({
return {
targetDeckId: this.targetDeckId,
removedConfigIds: this.removedConfigs,
configs,
applyToChildren,
cardStateCustomizer: get(this.cardStateCustomizer),
});
};
}
async save(applyToChildren: boolean): Promise<void> {
await saveDeckOptions(this.dataForSaving(applyToChildren));
await deckConfig.updateDeckConfigs(this.dataForSaving(applyToChildren));
}
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,
replaceWithColons,
} from "./tags";
import { Tags } from "../lib/proto";
import { postRequest } from "../lib/postrequest";
import { tags as tagsService } from "../lib/proto";
import { execCommand } from "./helpers";
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;
async function fetchSuggestions(input: string): Promise<string[]> {
const data = await postRequest(
"/_anki/completeTag",
Tags.CompleteTagRequest.encode(
Tags.CompleteTagRequest.create({ input, matchLimit: 500 }),
).finish(),
);
const response = Tags.CompleteTagResponse.decode(data);
return response.tags;
const { tags } = await tagsService.completeTag({ input, matchLimit: 500 });
return tags;
}
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">
import type { Writable } from "svelte/store";
import type { PreferenceRaw, PreferencePayload } from "../sveltelib/preferences";
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 useAsyncReactive from "../sveltelib/asyncReactive";
import { getPreferences } from "../sveltelib/preferences";
import { daysToRevlogRange } from "./graph-helpers";
export let search: Writable<string>;
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 {
loading: graphLoading,
error: graphError,
value: graphValue,
} = useAsyncReactive(() => getGraphData($search, $days), [search, days]);
} = useAsyncReactive(
() => stats.graphs({ search: $search, days: $days }),
[search, days],
);
const {
loading: prefsLoading,
@ -54,8 +30,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value: prefsValue,
} = useAsync(() =>
getPreferences(
getGraphPreferences,
setGraphPreferences,
() => stats.getGraphPreferences(empty),
async (input: Stats.IGraphPreferences): Promise<void> => {
stats.setGraphPreferences(input);
},
Stats.GraphPreferences.toObject.bind(Stats.GraphPreferences) as (
preferences: Stats.GraphPreferences,
options: { defaults: boolean },

View file

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

View file

@ -1,17 +1,77 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 type { RPCImpl, RPCImplCallback, Message, rpc } from "protobufjs";
import Cards = anki.cards;
import Collection = anki.collection;
import DeckConfig = anki.deckconfig;
import Decks = anki.decks;
import Generic = anki.generic;
import I18n = anki.i18n;
import Notes = anki.notes;
import Notetypes = anki.notetypes;
import Scheduler = anki.scheduler;
import Stats = anki.stats;
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(
msg: Generic.IInt64 | Generic.IUInt32 | Generic.IInt32 | null | undefined,