Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions proto/anki/collection.proto
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ message OpChangesOnly {
collection.OpChanges changes = 1;
}

message NestedOpChanges {
OpChangesOnly changes = 1;
}

message OpChangesWithCount {
OpChanges changes = 1;
uint32 count = 2;
Expand Down
1 change: 1 addition & 0 deletions pylib/anki/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
UndoStatus = collection_pb2.UndoStatus
OpChanges = collection_pb2.OpChanges
OpChangesOnly = collection_pb2.OpChangesOnly
NestedOpChanges = collection_pb2.NestedOpChanges
OpChangesWithCount = collection_pb2.OpChangesWithCount
OpChangesWithId = collection_pb2.OpChangesWithId
OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo
Expand Down
32 changes: 30 additions & 2 deletions qt/aqt/mediasrv.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from dataclasses import dataclass
from errno import EPROTOTYPE
from http import HTTPStatus
from typing import Any

import flask
import flask_cors
Expand All @@ -28,7 +29,13 @@
import aqt.main
import aqt.operations
from anki import hooks
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.collection import (
NestedOpChanges,
OpChanges,
OpChangesOnly,
Progress,
SearchNode,
)
from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.utils import dev_mode
Expand Down Expand Up @@ -707,7 +714,28 @@ def raw_backend_request(endpoint: str) -> Callable[[], bytes]:

assert hasattr(RustBackend, f"{endpoint}_raw")

return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
def wrapped() -> bytes:
output = getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
if op_changes_type := int(request.headers.get("Anki-Op-Changes", "0")):
op_message_types = (OpChanges, OpChangesOnly, NestedOpChanges)
try:
response = op_message_types[op_changes_type - 1]()
response.ParseFromString(output)
changes: Any = response
for _ in range(op_changes_type - 1):
changes = changes.changes
except IndexError:
raise ValueError(f"unhandled op changes level: {op_changes_type}")

def handle_on_main() -> None:
handler = aqt.mw.app.activeWindow()
on_op_finished(aqt.mw, changes, handler)

aqt.mw.taskman.run_on_main(handle_on_main)

return output

return wrapped


# all methods in here require a collection
Expand Down
17 changes: 16 additions & 1 deletion qt/aqt/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
from enum import Enum
from typing import TYPE_CHECKING, Any, Type, cast

from google.protobuf.json_format import MessageToDict
from typing_extensions import TypedDict, Unpack

import anki
import anki.lang
from anki._legacy import deprecated
from anki.lang import is_rtl
from anki.utils import hmr_mode, is_lin, is_mac, is_win
from anki.utils import hmr_mode, is_lin, is_mac, is_win, to_json_bytes
from aqt import colors, gui_hooks
from aqt.operations import OpChanges
from aqt.qt import *
from aqt.qt import sip
from aqt.theme import theme_manager
Expand Down Expand Up @@ -382,6 +384,7 @@ def __init__(
self._filterSet = False
gui_hooks.theme_did_change.append(self.on_theme_did_change)
gui_hooks.body_classes_need_update.append(self.on_body_classes_need_update)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)

qconnect(self.loadFinished, self._on_load_finished)

Expand Down Expand Up @@ -911,6 +914,7 @@ def cleanup(self) -> None:

gui_hooks.theme_did_change.remove(self.on_theme_did_change)
gui_hooks.body_classes_need_update.remove(self.on_body_classes_need_update)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
# defer page cleanup so that in-flight requests have a chance to complete first
# https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363
mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self)))
Expand Down Expand Up @@ -952,6 +956,17 @@ def on_body_classes_need_update(self) -> None:
f"""document.body.classList.toggle("reduce-motion", {json.dumps(mw.pm.reduce_motion())}); """
)

def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
if handler is self.parentWidget():
return

changes_json = to_json_bytes(MessageToDict(changes)).decode()
self.eval(
f"if(globalThis.anki && globalThis.anki.onOperationDidExecute) globalThis.anki.onOperationDidExecute({changes_json})"
)

@deprecated(info="use theme_manager.qcolor() instead")
def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:
return theme_manager.qcolor(colors.CANVAS)
Expand Down
46 changes: 45 additions & 1 deletion rslib/proto/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use anki_proto_gen::Method;
use anyhow::Result;
use inflections::Inflect;
use itertools::Itertools;
use prost_reflect::MessageDescriptor;

pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {
let root = Path::new("../../out/ts/lib/generated");
Expand Down Expand Up @@ -73,14 +74,16 @@ fn write_ts_method(
input_type,
output_type,
comments,
op_changes_type,
}: &MethodDetails,
out: &mut String,
) {
let op_changes_type = *op_changes_type as u8;
let comments = format_comments(comments);
writeln!(
out,
r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{
return await postProto("{method_name}", new {input_type}(input), {output_type}, options);
return await postProto("{method_name}", new {input_type}(input), {output_type}, options, {op_changes_type});
}}"#
).unwrap()
}
Expand All @@ -92,11 +95,21 @@ fn format_comments(comments: &Option<String>) -> String {
.unwrap_or_default()
}

#[derive(Clone, Copy)]
#[repr(u8)]
enum OpChangesType {
None = 0,
OpChanges = 1,
OpChangesOnly = 2,
NestedOpChanges = 3,
}

struct MethodDetails {
method_name: String,
input_type: String,
output_type: String,
comments: Option<String>,
op_changes_type: OpChangesType,
}

impl MethodDetails {
Expand All @@ -105,12 +118,43 @@ impl MethodDetails {
let input_type = full_name_to_imported_reference(method.proto.input().full_name());
let output_type = full_name_to_imported_reference(method.proto.output().full_name());
let comments = method.comments.clone();
let op_changes_type =
get_op_changes_type(&method.proto.output(), &method.proto.output(), 1);
Self {
method_name: name,
input_type,
output_type,
comments,
op_changes_type,
}
}
}

fn get_op_changes_type(
root_message: &MessageDescriptor,
message: &MessageDescriptor,
level: u8,
) -> OpChangesType {
if message.full_name() == "anki.collection.OpChanges" {
match level {
0 => OpChangesType::None,
1 => OpChangesType::OpChanges,
2 => OpChangesType::OpChangesOnly,
3 => OpChangesType::NestedOpChanges,
_ => panic!(
"unhandled op changes level for message {}: {}",
root_message.full_name(),
level
),
}
} else if let Some(field) = message.get_field(1) {
if let Some(field_message) = field.kind().as_message() {
get_op_changes_type(root_message, field_message, level + 1)
} else {
OpChangesType::None
}
} else {
OpChangesType::None
}
}

Expand Down
6 changes: 4 additions & 2 deletions ts/lib/generated/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ export async function postProto<T>(
input: { toBinary(): Uint8Array; getType(): { typeName: string } },
outputType: { fromBinary(arr: Uint8Array): T },
options: PostProtoOptions = {},
opChangesType = 0,
): Promise<T> {
try {
const inputBytes = input.toBinary();
const path = `/_anki/${method}`;
const outputBytes = await postProtoInner(path, inputBytes);
const outputBytes = await postProtoInner(path, inputBytes, opChangesType);
return outputType.fromBinary(outputBytes);
} catch (err) {
const { alertOnError = true } = options;
Expand All @@ -26,11 +27,12 @@ export async function postProto<T>(
}
}

async function postProtoInner(url: string, body: Uint8Array): Promise<Uint8Array> {
async function postProtoInner(url: string, body: Uint8Array, opChangesType: number): Promise<Uint8Array> {
const result = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/binary",
"Anki-Op-Changes": opChangesType.toString(),
},
body,
});
Expand Down
20 changes: 20 additions & 0 deletions ts/lib/tslib/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

import type { OpChanges } from "@generated/anki/collection_pb";

type OperationHandler = (changes: Partial<OpChanges>) => void;
const handlers: OperationHandler[] = [];

export function registerOperationHandler(handler: (changes: Partial<OpChanges>) => void): void {
handlers.push(handler);
}

function onOperationDidExecute(changes: Partial<OpChanges>): void {
for (const handler of handlers) {
handler(changes);
}
}

globalThis.anki = globalThis.anki || {};
globalThis.anki.onOperationDidExecute = onOperationDidExecute;