diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto index 33041361334..0ed1a155c84 100644 --- a/proto/anki/collection.proto +++ b/proto/anki/collection.proto @@ -78,6 +78,10 @@ message OpChangesOnly { collection.OpChanges changes = 1; } +message NestedOpChanges { + OpChangesOnly changes = 1; +} + message OpChangesWithCount { OpChanges changes = 1; uint32 count = 2; diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 60360470cba..fb50f069121 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -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 diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index bedf23e5bc5..3f54e80c5fb 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -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 @@ -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 @@ -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 diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 8853558b09e..e8646d69807 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -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 @@ -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) @@ -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))) @@ -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) diff --git a/rslib/proto/typescript.rs b/rslib/proto/typescript.rs index 4e941a0cacc..3b70efdfc79 100644 --- a/rslib/proto/typescript.rs +++ b/rslib/proto/typescript.rs @@ -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"); @@ -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() } @@ -92,11 +95,21 @@ fn format_comments(comments: &Option) -> 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, + op_changes_type: OpChangesType, } impl MethodDetails { @@ -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 } } diff --git a/ts/lib/generated/post.ts b/ts/lib/generated/post.ts index 90e372520e0..c70b789e628 100644 --- a/ts/lib/generated/post.ts +++ b/ts/lib/generated/post.ts @@ -11,11 +11,12 @@ export async function postProto( input: { toBinary(): Uint8Array; getType(): { typeName: string } }, outputType: { fromBinary(arr: Uint8Array): T }, options: PostProtoOptions = {}, + opChangesType = 0, ): Promise { 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; @@ -26,11 +27,12 @@ export async function postProto( } } -async function postProtoInner(url: string, body: Uint8Array): Promise { +async function postProtoInner(url: string, body: Uint8Array, opChangesType: number): Promise { const result = await fetch(url, { method: "POST", headers: { "Content-Type": "application/binary", + "Anki-Op-Changes": opChangesType.toString(), }, body, }); diff --git a/ts/lib/tslib/operations.ts b/ts/lib/tslib/operations.ts new file mode 100644 index 00000000000..f7244e8a96a --- /dev/null +++ b/ts/lib/tslib/operations.ts @@ -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) => void; +const handlers: OperationHandler[] = []; + +export function registerOperationHandler(handler: (changes: Partial) => void): void { + handlers.push(handler); +} + +function onOperationDidExecute(changes: Partial): void { + for (const handler of handlers) { + handler(changes); + } +} + +globalThis.anki = globalThis.anki || {}; +globalThis.anki.onOperationDidExecute = onOperationDidExecute;