diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index 91cbf9861..80eaf1e5a 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -339,6 +339,17 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { ":sass" ], )?; + build_page( + "import-log", + true, + inputs![ + // + ":ts:lib", + ":ts:components", + ":ts:sveltelib", + ":sass" + ], + )?; Ok(()) } diff --git a/ftl/core/importing.ftl b/ftl/core/importing.ftl index 8fb270bf6..9284e6696 100644 --- a/ftl/core/importing.ftl +++ b/ftl/core/importing.ftl @@ -116,6 +116,44 @@ importing-cards-added = *[other] { $count } cards added. } importing-file-empty = The file you selected is empty. +importing-notes-added = + { $count -> + [one] { $count } new note imported. + *[other] { $count } new notes imported. + } +importing-notes-updated = + { $count -> + [one] { $count } note was used to update existing ones. + *[other] { $count } notes were used to update existing ones. + } +importing-existing-notes-skipped = + { $count -> + [one] { $count } note already present in your collection. + *[other] { $count } notes already present in your collection. + } +importing-conflicting-notes-skipped = + { $count -> + [one] { $count } note was not imported, because its note type has changed. + *[other] { $count } were not imported, because their note type has changed. + } +importing-import-log = Import Log +importing-no-notes-in-file = No notes found in file. +importing-notes-found-in-file2 = + { $notes -> + [one] { $notes } note + *[other] { $notes } notes + } found in file. Of those: +importing-show = Show +importing-details = Details +importing-status = Status +importing-duplicate-note-added = Duplicate note added +importing-added-new-note = New note added +importing-existing-note-skipped = Note skipped, as an up-to-date copy is already in your collection +importing-note-skipped-update-due-to-notetype = Note not updated, as notetype has been modified since you first imported the note +importing-note-updated-as-file-had-newer = Note updated, as file had newer version +importing-note-skipped-due-to-missing-notetype = Note skipped, as its notetype was missing +importing-note-skipped-due-to-missing-deck = Note skipped, as its deck was missing +importing-note-skipped-due-to-empty-first-field = Note skipped, as its first field is empty ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto index bf8b53cbb..ec97f83c2 100644 --- a/proto/anki/collection.proto +++ b/proto/anki/collection.proto @@ -72,14 +72,20 @@ message OpChanges { bool study_queues = 10; } +// Allows frontend code to extract changes from other messages like +// ImportResponse without decoding other potentially large fields. +message OpChangesOnly { + collection.OpChanges changes = 1; +} + message OpChangesWithCount { - uint32 count = 1; - OpChanges changes = 2; + OpChanges changes = 1; + uint32 count = 2; } message OpChangesWithId { - int64 id = 1; - OpChanges changes = 2; + OpChanges changes = 1; + int64 id = 2; } message UndoStatus { diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto new file mode 100644 index 000000000..7c69429ab --- /dev/null +++ b/proto/anki/frontend.proto @@ -0,0 +1,37 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +syntax = "proto3"; + +option java_multiple_files = true; + +package anki.frontend; + +import "anki/scheduler.proto"; +import "anki/generic.proto"; +import "anki/search.proto"; + +service FrontendService { + // Returns values from the reviewer + rpc GetSchedulingStatesWithContext(generic.Empty) + returns (SchedulingStatesWithContext); + // Updates reviewer state + rpc SetSchedulingStates(SetSchedulingStatesRequest) returns (generic.Empty); + + // Notify Qt layer so window modality can be updated. + rpc ImportDone(generic.Empty) returns (generic.Empty); + + rpc SearchInBrowser(search.SearchNode) returns (generic.Empty); +} + +service BackendFrontendService {} + +message SchedulingStatesWithContext { + scheduler.SchedulingStates states = 1; + scheduler.SchedulingContext context = 2; +} + +message SetSchedulingStatesRequest { + string key = 1; + scheduler.SchedulingStates states = 2; +} diff --git a/proto/anki/import_export.proto b/proto/anki/import_export.proto index bad5230e1..ba0d60a5b 100644 --- a/proto/anki/import_export.proto +++ b/proto/anki/import_export.proto @@ -64,8 +64,6 @@ message ImportResponse { repeated Note missing_deck = 7; repeated Note empty_first_field = 8; CsvMetadata.DupeResolution dupe_resolution = 9; - // Usually the sum of all queues, but may be lower if multiple duplicates - // have been updated with the same note. uint32 found_notes = 10; } collection.OpChanges changes = 1; diff --git a/proto/anki/notes.proto b/proto/anki/notes.proto index 26c36bff9..667cb815f 100644 --- a/proto/anki/notes.proto +++ b/proto/anki/notes.proto @@ -58,8 +58,8 @@ message AddNoteRequest { } message AddNoteResponse { - int64 note_id = 1; - collection.OpChanges changes = 2; + collection.OpChanges changes = 1; + int64 note_id = 2; } message UpdateNotesRequest { diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index cc6fe1fb2..10580aacd 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -38,13 +38,6 @@ service SchedulerService { rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates); - // This should be implemented by the frontend, and should return the values - // from the reviewer. The backend method will throw an error. - rpc GetSchedulingStatesWithContext(generic.Empty) - returns (SchedulingStatesWithContext); - // This should be implemented by the frontend, and should update the state - // data in the reviewer. The backend method will throw an error. - rpc SetSchedulingStates(SetSchedulingStatesRequest) returns (generic.Empty); rpc DescribeNextStates(SchedulingStates) returns (generic.StringList); rpc StateIsLeech(SchedulingState) returns (generic.Bool); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); @@ -299,11 +292,6 @@ message SchedulingContext { uint64 seed = 2; } -message SchedulingStatesWithContext { - SchedulingStates states = 1; - SchedulingContext context = 2; -} - message CustomStudyDefaultsRequest { int64 deck_id = 1; } @@ -329,8 +317,3 @@ message RepositionDefaultsResponse { bool random = 1; bool shift = 2; } - -message SetSchedulingStatesRequest { - string key = 1; - SchedulingStates states = 2; -} diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index da9bf8134..03bf99255 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -29,6 +29,7 @@ CardStats = stats_pb2.CardStatsResponse Preferences = config_pb2.Preferences UndoStatus = collection_pb2.UndoStatus OpChanges = collection_pb2.OpChanges +OpChangesOnly = collection_pb2.OpChangesOnly OpChangesWithCount = collection_pb2.OpChangesWithCount OpChangesWithId = collection_pb2.OpChangesWithId OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo diff --git a/pylib/anki/scheduler/v3.py b/pylib/anki/scheduler/v3.py index b3de4faf0..222b99b21 100644 --- a/pylib/anki/scheduler/v3.py +++ b/pylib/anki/scheduler/v3.py @@ -16,7 +16,7 @@ from __future__ import annotations from typing import Literal, Optional, Sequence -from anki import scheduler_pb2 +from anki import frontend_pb2, scheduler_pb2 from anki._legacy import deprecated from anki.cards import Card from anki.collection import OpChanges @@ -31,8 +31,8 @@ QueuedCards = scheduler_pb2.QueuedCards SchedulingState = scheduler_pb2.SchedulingState SchedulingStates = scheduler_pb2.SchedulingStates SchedulingContext = scheduler_pb2.SchedulingContext -SchedulingStatesWithContext = scheduler_pb2.SchedulingStatesWithContext -SetSchedulingStatesRequest = scheduler_pb2.SetSchedulingStatesRequest +SchedulingStatesWithContext = frontend_pb2.SchedulingStatesWithContext +SetSchedulingStatesRequest = frontend_pb2.SetSchedulingStatesRequest CardAnswer = scheduler_pb2.CardAnswer diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index ae125d651..ad2d7b7e8 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -406,6 +406,7 @@ class Browser(QMainWindow): self.form.searchEdit.lineEdit().setPlaceholderText( tr.browsing_search_bar_hint() ) + self.form.searchEdit.lineEdit().setMaxLength(2000000) self.form.searchEdit.addItems( [""] + self.mw.pm.profile.get("searchHistory", []) ) diff --git a/qt/aqt/import_export/import_csv_dialog.py b/qt/aqt/import_export/import_csv_dialog.py index 0c568b5a6..0726d1009 100644 --- a/qt/aqt/import_export/import_csv_dialog.py +++ b/qt/aqt/import_export/import_csv_dialog.py @@ -7,7 +7,6 @@ import aqt import aqt.deckconf import aqt.main import aqt.operations -from anki.collection import ImportCsvRequest from aqt.qt import * from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr from aqt.webview import AnkiWebView, AnkiWebViewKind @@ -21,11 +20,9 @@ class ImportCsvDialog(QDialog): self, mw: aqt.main.AnkiQt, path: str, - on_accepted: Callable[[ImportCsvRequest], None], ) -> None: - QDialog.__init__(self, mw) + QDialog.__init__(self, mw, Qt.WindowType.Window) self.mw = mw - self._on_accepted = on_accepted self._setup_ui(path) self.show() @@ -34,7 +31,6 @@ class ImportCsvDialog(QDialog): self.mw.garbage_collect_on_dialog_finish(self) self.setMinimumSize(400, 300) disable_help_button(self) - restoreGeom(self, self.TITLE, default_size=(800, 800)) addCloseShortcut(self) self.web = AnkiWebView(kind=AnkiWebViewKind.IMPORT_CSV) @@ -44,6 +40,7 @@ class ImportCsvDialog(QDialog): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.web) self.setLayout(layout) + restoreGeom(self, self.TITLE, default_size=(800, 800)) escaped_path = path.replace("'", r"\'") self.web.evalWithCallback( @@ -52,13 +49,9 @@ class ImportCsvDialog(QDialog): self.setWindowTitle(tr.decks_import_file()) def reject(self) -> None: + if self.mw.col and self.windowModality() == Qt.WindowModality.ApplicationModal: + self.mw.col.set_wants_abort() self.web.cleanup() self.web = None saveGeom(self, self.TITLE) QDialog.reject(self) - - def do_import(self, data: bytes) -> None: - request = ImportCsvRequest() - request.ParseFromString(data) - self._on_accepted(request) - super().reject() diff --git a/qt/aqt/import_export/import_log_dialog.py b/qt/aqt/import_export/import_log_dialog.py new file mode 100644 index 000000000..55c12df1c --- /dev/null +++ b/qt/aqt/import_export/import_log_dialog.py @@ -0,0 +1,92 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import dataclasses +import json +from dataclasses import dataclass + +import aqt +import aqt.deckconf +import aqt.main +import aqt.operations +from aqt.qt import * +from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom, tr +from aqt.webview import AnkiWebView, AnkiWebViewKind + + +@dataclass +class _CommonArgs: + type: str = dataclasses.field(init=False) + path: str + + def to_json(self) -> str: + return json.dumps(dataclasses.asdict(self)) + + +@dataclass +class ApkgArgs(_CommonArgs): + type = "apkg" + + +@dataclass +class JsonFileArgs(_CommonArgs): + type = "json_file" + + +@dataclass +class JsonStringArgs(_CommonArgs): + type = "json_string" + json: str + + +class ImportLogDialog(QDialog): + GEOMETRY_KEY = "importLog" + silentlyClose = True + + def __init__( + self, + mw: aqt.main.AnkiQt, + args: ApkgArgs | JsonFileArgs | JsonStringArgs, + ) -> None: + QDialog.__init__(self, mw, Qt.WindowType.Window) + self.mw = mw + self._setup_ui(args) + self.show() + + def _setup_ui( + self, + args: ApkgArgs | JsonFileArgs | JsonStringArgs, + ) -> None: + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumSize(400, 300) + disable_help_button(self) + addCloseShortcut(self) + + self.web = AnkiWebView(kind=AnkiWebViewKind.IMPORT_LOG) + self.web.setVisible(False) + self.web.load_ts_page("import-log") + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + self.setLayout(layout) + restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800)) + + self.web.evalWithCallback( + "anki.setupImportLogPage(%s);" % (args.to_json()), + lambda _: self.web.setFocus(), + ) + + title = tr.importing_import_log() + title += f" - {os.path.basename(args.path)}" + self.setWindowTitle(title) + + def reject(self) -> None: + if self.mw.col and self.windowModality() == Qt.WindowModality.ApplicationModal: + self.mw.col.set_wants_abort() + self.web.cleanup() + self.web = None + saveGeom(self, self.GEOMETRY_KEY) + QDialog.reject(self) diff --git a/qt/aqt/import_export/importing.py b/qt/aqt/import_export/importing.py index 7302dc267..0d228d7cb 100644 --- a/qt/aqt/import_export/importing.py +++ b/qt/aqt/import_export/importing.py @@ -5,26 +5,25 @@ from __future__ import annotations import re from abc import ABC, abstractmethod -from dataclasses import dataclass from itertools import chain -from typing import Any, Tuple, Type +from typing import Type import aqt.main -from anki.collection import ( - Collection, - DupeResolution, - ImportCsvRequest, - ImportLogWithChanges, - Progress, -) +from anki.collection import Collection, Progress from anki.errors import Interrupted from anki.foreign_data import mnemosyne from anki.lang import without_unicode_isolation from aqt.import_export.import_csv_dialog import ImportCsvDialog -from aqt.operations import CollectionOp, QueryOp +from aqt.import_export.import_log_dialog import ( + ApkgArgs, + ImportLogDialog, + JsonFileArgs, + JsonStringArgs, +) +from aqt.operations import QueryOp from aqt.progress import ProgressUpdate from aqt.qt import * -from aqt.utils import askUser, getFile, showText, showWarning, tooltip, tr +from aqt.utils import askUser, getFile, showWarning, tooltip, tr class Importer(ABC): @@ -89,12 +88,7 @@ class ApkgImporter(Importer): @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: - CollectionOp( - parent=mw, - op=lambda col: col.import_anki_package(path), - ).with_backend_progress(import_progress_update).success( - show_import_log - ).run_in_background() + ImportLogDialog(mw, ApkgArgs(path=path)) class MnemosyneImporter(Importer): @@ -105,7 +99,9 @@ class MnemosyneImporter(Importer): QueryOp( parent=mw, op=lambda col: mnemosyne.serialize(path, col.decks.current()["id"]), - success=lambda json: import_json_string(mw, json), + success=lambda json: ImportLogDialog( + mw, JsonStringArgs(path=path, json=json) + ), ).with_progress().run_in_background() @@ -114,15 +110,7 @@ class CsvImporter(Importer): @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: - def on_accepted(request: ImportCsvRequest) -> None: - CollectionOp( - parent=mw, - op=lambda col: col.import_csv(request), - ).with_backend_progress(import_progress_update).success( - show_import_log - ).run_in_background() - - ImportCsvDialog(mw, path, on_accepted) + ImportCsvDialog(mw, path) class JsonImporter(Importer): @@ -130,12 +118,7 @@ class JsonImporter(Importer): @staticmethod def do_import(mw: aqt.main.AnkiQt, path: str) -> None: - CollectionOp( - parent=mw, - op=lambda col: col.import_json_file(path), - ).with_backend_progress(import_progress_update).success( - show_import_log - ).run_in_background() + ImportLogDialog(mw, JsonFileArgs(path=path)) IMPORTERS: list[Type[Importer]] = [ @@ -222,101 +205,9 @@ def import_collection_package_op( ) -def import_json_string(mw: aqt.main.AnkiQt, json: str) -> None: - CollectionOp( - parent=mw, op=lambda col: col.import_json_string(json) - ).with_backend_progress(import_progress_update).success( - show_import_log - ).run_in_background() - - -def show_import_log(log_with_changes: ImportLogWithChanges) -> None: - showText(stringify_log(log_with_changes.log), plain_text_edit=True) - - -def stringify_log(log: ImportLogWithChanges.Log) -> str: - queues = log_queues(log) - return "\n".join( - chain( - (tr.importing_notes_found_in_file(val=log.found_notes),), - ( - queue.summary_template(val=len(queue.notes)) - for queue in queues - if queue.notes - ), - ("",), - *( - [ - f"[{queue.action_string}] {', '.join(note.fields)}" - for note in queue.notes - ] - for queue in queues - ), - ) - ) - - def import_progress_update(progress: Progress, update: ProgressUpdate) -> None: if not progress.HasField("importing"): return update.label = progress.importing if update.user_wants_abort: update.abort = True - - -@dataclass -class LogQueue: - notes: Any - # Callable[[Union[str, int, float]], str] (if mypy understood kwargs) - summary_template: Any - action_string: str - - -def first_field_queue(log: ImportLogWithChanges.Log) -> LogQueue: - if log.dupe_resolution == DupeResolution.DUPLICATE: - summary_template = tr.importing_added_duplicate_with_first_field - action_string = tr.adding_added() - elif log.dupe_resolution == DupeResolution.PRESERVE: - summary_template = tr.importing_first_field_matched - action_string = tr.importing_skipped() - else: - summary_template = tr.importing_first_field_matched - action_string = tr.importing_updated() - return LogQueue(log.first_field_match, summary_template, action_string) - - -def log_queues(log: ImportLogWithChanges.Log) -> Tuple[LogQueue, ...]: - return ( - LogQueue( - log.conflicting, - tr.importing_notes_skipped_update_due_to_notetype, - tr.importing_skipped(), - ), - LogQueue( - log.updated, - tr.importing_notes_updated_as_file_had_newer, - tr.importing_updated(), - ), - LogQueue(log.new, tr.importing_notes_added_from_file, tr.adding_added()), - LogQueue( - log.duplicate, - tr.importing_notes_skipped_as_theyre_already_in, - tr.importing_identical(), - ), - first_field_queue(log), - LogQueue( - log.missing_notetype, - lambda val: f"Notes skipped, as their notetype was missing: {val}", - tr.importing_skipped(), - ), - LogQueue( - log.missing_deck, - lambda val: f"Notes skipped, as their deck was missing: {val}", - tr.importing_skipped(), - ), - LogQueue( - log.empty_first_field, - tr.importing_empty_first_field, - tr.importing_skipped(), - ), - ) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 43395a97f..c207773da 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -25,13 +25,13 @@ import aqt import aqt.main import aqt.operations from anki import hooks -from anki.collection import OpChanges +from anki.collection import OpChanges, OpChangesOnly, SearchNode from anki.decks import UpdateDeckConfigs from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog -from aqt.import_export.import_csv_dialog import ImportCsvDialog +from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.qt import * from aqt.utils import aqt_data_path @@ -421,6 +421,65 @@ def set_scheduling_states() -> bytes: return b"" +def import_done() -> bytes: + def update_window_modality() -> None: + if window := aqt.mw.app.activeWindow(): + from aqt.import_export.import_csv_dialog import ImportCsvDialog + from aqt.import_export.import_log_dialog import ImportLogDialog + + if isinstance(window, ImportCsvDialog) or isinstance( + window, ImportLogDialog + ): + window.hide() + window.setWindowModality(Qt.WindowModality.NonModal) + window.show() + + aqt.mw.taskman.run_on_main(update_window_modality) + return b"" + + +def import_request(endpoint: str) -> bytes: + output = raw_backend_request(endpoint)() + response = OpChangesOnly() + response.ParseFromString(output) + + def handle_on_main() -> None: + window = aqt.mw.app.activeWindow() + on_op_finished(aqt.mw, response, window) + + aqt.mw.taskman.run_on_main(handle_on_main) + + return output + + +def import_csv() -> bytes: + return import_request("import_csv") + + +def import_anki_package() -> bytes: + return import_request("import_anki_package") + + +def import_json_file() -> bytes: + return import_request("import_json_file") + + +def import_json_string() -> bytes: + return import_request("import_json_string") + + +def search_in_browser() -> bytes: + node = SearchNode() + node.ParseFromString(request.data) + + def handle_on_main() -> None: + aqt.dialogs.open("Browser", aqt.mw, search=(node,)) + + aqt.mw.taskman.run_on_main(handle_on_main) + + return b"" + + def change_notetype() -> bytes: data = request.data @@ -433,18 +492,6 @@ def change_notetype() -> bytes: return b"" -def import_csv() -> bytes: - data = request.data - - def handle_on_main() -> None: - window = aqt.mw.app.activeWindow() - if isinstance(window, ImportCsvDialog): - window.do_import(data) - - aqt.mw.taskman.run_on_main(handle_on_main) - return b"" - - post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -452,11 +499,18 @@ post_handler_list = [ get_scheduling_states_with_context, set_scheduling_states, change_notetype, + import_done, import_csv, + import_anki_package, + import_json_file, + import_json_string, + search_in_browser, ] exposed_backend_list = [ + # CollectionService + "latest_progress", # DeckService "get_deck_names", # I18nService diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py index 761c6e7ab..1256425ad 100644 --- a/qt/aqt/operations/__init__.py +++ b/qt/aqt/operations/__init__.py @@ -14,6 +14,7 @@ from anki.collection import ( ImportLogWithChanges, OpChanges, OpChangesAfterUndo, + OpChangesOnly, OpChangesWithCount, OpChangesWithId, Progress, @@ -34,6 +35,7 @@ ResultWithChanges = TypeVar( "ResultWithChanges", bound=Union[ OpChanges, + OpChangesOnly, OpChangesWithCount, OpChangesWithId, OpChangesAfterUndo, @@ -124,7 +126,7 @@ class CollectionOp(Generic[ResultWithChanges]): if self._success: self._success(result) finally: - self._finish_op(mw, result, initiator) + on_op_finished(mw, result, initiator) self._run(mw, wrapped_op, wrapped_done) @@ -141,32 +143,23 @@ class CollectionOp(Generic[ResultWithChanges]): else: mw.taskman.with_progress(op, on_done, parent=self._parent) - def _finish_op( - self, mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None - ) -> None: - mw.update_undo_actions() - mw.autosave() - self._fire_change_hooks_after_op_performed(result, initiator) - def _fire_change_hooks_after_op_performed( - self, - result: ResultWithChanges, - handler: object | None, - ) -> None: - from aqt import mw +def on_op_finished( + mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None +) -> None: + mw.update_undo_actions() + mw.autosave() - assert mw + if isinstance(result, OpChanges): + changes = result + else: + changes = result.changes # type: ignore[union-attr] - if isinstance(result, OpChanges): - changes = result - else: - changes = result.changes # type: ignore[union-attr] - - # fire new hook - aqt.gui_hooks.operation_did_execute(changes, handler) - # fire legacy hook so old code notices changes - if mw.col.op_made_changes(changes): - aqt.gui_hooks.state_did_reset() + # fire new hook + aqt.gui_hooks.operation_did_execute(changes, initiator) + # fire legacy hook so old code notices changes + if mw.col.op_made_changes(changes): + aqt.gui_hooks.state_did_reset() T = TypeVar("T") diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 203cc3d2b..19d834a77 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -248,6 +248,7 @@ class AnkiWebViewKind(Enum): EMPTY_CARDS = "empty cards" FIND_DUPLICATES = "find duplicates" FIELDS = "fields" + IMPORT_LOG = "import log" class AnkiWebView(QWebEngineView): diff --git a/rslib/proto_gen/src/lib.rs b/rslib/proto_gen/src/lib.rs index 6b424bcac..8e4656632 100644 --- a/rslib/proto_gen/src/lib.rs +++ b/rslib/proto_gen/src/lib.rs @@ -53,7 +53,8 @@ pub fn get_services(pool: &DescriptorPool) -> (Vec, Vec Result<()> { @@ -20,6 +21,14 @@ pub fn write_rust_interface(pool: &DescriptorPool) -> Result<()> { buf.push_str("use crate::error::Result; use prost::Message;"); let (col_services, backend_services) = get_services(pool); + let col_services = col_services + .into_iter() + .filter(|s| s.name != "FrontendService") + .collect_vec(); + let backend_services = backend_services + .into_iter() + .filter(|s| s.name != "BackendFrontendService") + .collect_vec(); render_collection_services(&col_services, &mut buf)?; render_backend_services(&backend_services, &mut buf)?; diff --git a/rslib/src/import_export/text/import.rs b/rslib/src/import_export/text/import.rs index b9ac7fe7f..30d38fff0 100644 --- a/rslib/src/import_export/text/import.rs +++ b/rslib/src/import_export/text/import.rs @@ -379,9 +379,10 @@ impl<'a> Context<'a> { } fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> { + let mut update_result = DuplicateUpdateResult::None; for dupe in ctx.dupes { if dupe.note.notetype_id != ctx.notetype.id { - log.conflicting.push(dupe.note.into_log_note()); + update_result.update(DuplicateUpdateResult::Conflicting(dupe)); continue; } @@ -393,20 +394,16 @@ impl<'a> Context<'a> { ctx.global_tags.iter().chain(ctx.updated_tags.iter()), ); - if !dupe.identical { + if dupe.identical { + update_result.update(DuplicateUpdateResult::Identical(dupe)); + } else { self.prepare_note(&mut note, &ctx.notetype)?; self.col.update_note_undoable(¬e, &dupe.note)?; + update_result.update(DuplicateUpdateResult::Update(dupe)); } self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?; - - if dupe.identical { - log.duplicate.push(dupe.note.into_log_note()); - } else if dupe.first_field_match { - log.first_field_match.push(note.into_log_note()); - } else { - log.updated.push(note.into_log_note()); - } } + update_result.log(log); Ok(()) } @@ -441,6 +438,46 @@ impl<'a> Context<'a> { } } +/// Helper enum to decide which result to log if multiple duplicates were found +/// for a single incoming note. +enum DuplicateUpdateResult { + None, + Conflicting(Duplicate), + Identical(Duplicate), + Update(Duplicate), +} + +impl DuplicateUpdateResult { + fn priority(&self) -> u8 { + match self { + DuplicateUpdateResult::None => 0, + DuplicateUpdateResult::Conflicting(_) => 1, + DuplicateUpdateResult::Identical(_) => 2, + DuplicateUpdateResult::Update(_) => 3, + } + } + + fn update(&mut self, new: Self) { + if self.priority() < new.priority() { + *self = new; + } + } + + fn log(self, log: &mut NoteLog) { + match self { + DuplicateUpdateResult::None => (), + DuplicateUpdateResult::Conflicting(dupe) => { + log.conflicting.push(dupe.note.into_log_note()) + } + DuplicateUpdateResult::Identical(dupe) => log.duplicate.push(dupe.note.into_log_note()), + DuplicateUpdateResult::Update(dupe) if dupe.first_field_match => { + log.first_field_match.push(dupe.note.into_log_note()) + } + DuplicateUpdateResult::Update(dupe) => log.updated.push(dupe.note.into_log_note()), + } + } +} + impl NoteContext<'_> { fn is_dupe(&self) -> bool { !self.dupes.is_empty() diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index e55c3201c..9e862c34d 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -6,8 +6,6 @@ mod states; use anki_proto::generic; use anki_proto::scheduler; -use anki_proto::scheduler::SchedulingStatesWithContext; -use anki_proto::scheduler::SetSchedulingStatesRequest; use crate::prelude::*; use crate::scheduler::new::NewCardDueOrder; @@ -239,12 +237,4 @@ impl crate::services::SchedulerService for Collection { ) -> Result { self.custom_study_defaults(input.deck_id.into()) } - - fn get_scheduling_states_with_context(&mut self) -> Result { - invalid_input!("the frontend should implement this") - } - - fn set_scheduling_states(&mut self, _input: SetSchedulingStatesRequest) -> Result<()> { - invalid_input!("the frontend should implement this") - } } diff --git a/ts/components/BackendProgressIndicator.svelte b/ts/components/BackendProgressIndicator.svelte new file mode 100644 index 000000000..30fe87961 --- /dev/null +++ b/ts/components/BackendProgressIndicator.svelte @@ -0,0 +1,93 @@ + + + + +{#if !result} +
+
+
+
+
+
+
+
{label}
+
+{/if} + + diff --git a/ts/components/VirtualTable.svelte b/ts/components/VirtualTable.svelte new file mode 100644 index 000000000..70d7bce45 --- /dev/null +++ b/ts/components/VirtualTable.svelte @@ -0,0 +1,92 @@ + + + +
(scrollTop = container.scrollTop)} +> +
+ + + {#each slice as index (index)} + + {/each} +
+
+
+ + diff --git a/ts/components/WithTooltip.svelte b/ts/components/WithTooltip.svelte index 6ad68a673..12e1cb47b 100644 --- a/ts/components/WithTooltip.svelte +++ b/ts/components/WithTooltip.svelte @@ -25,8 +25,11 @@ export let showDelay = 0; export let hideDelay = 0; + let tooltipElement: HTMLElement; + let tooltipObject: Tooltip; function createTooltip(element: HTMLElement): void { + tooltipElement = element; element.title = tooltip; tooltipObject = new Tooltip(element, { placement, @@ -37,7 +40,12 @@ }); } - onDestroy(() => tooltipObject?.dispose()); + onDestroy(() => { + tooltipElement?.addEventListener("hidden.bs.tooltip", () => { + tooltipObject?.dispose(); + }); + tooltipObject?.hide(); + }); diff --git a/ts/import-csv/ImportCsvPage.svelte b/ts/import-csv/ImportCsvPage.svelte index bb506dd94..172566a86 100644 --- a/ts/import-csv/ImportCsvPage.svelte +++ b/ts/import-csv/ImportCsvPage.svelte @@ -10,15 +10,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html CsvMetadata_DupeResolution, CsvMetadata_MappedNotetype, CsvMetadata_MatchScope, + ImportResponse, } from "@tslib/anki/import_export_pb"; import type { NotetypeNameId } from "@tslib/anki/notetypes_pb"; - import { getCsvMetadata, importCsv } from "@tslib/backend"; + import { getCsvMetadata, importCsv, importDone } from "@tslib/backend"; import * as tr from "@tslib/ftl"; + import BackendProgressIndicator from "../components/BackendProgressIndicator.svelte"; import Col from "../components/Col.svelte"; import Container from "../components/Container.svelte"; import Row from "../components/Row.svelte"; import Spacer from "../components/Spacer.svelte"; + import ImportLogPage from "../import-log/ImportLogPage.svelte"; import DeckDupeCheckSwitch from "./DeckDupeCheckSwitch.svelte"; import DeckSelector from "./DeckSelector.svelte"; import DelimiterSelector from "./DelimiterSelector.svelte"; @@ -59,8 +62,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let deckId: bigint | null; export let deckColumn: number | null; + let importResponse: ImportResponse | undefined = undefined; let lastNotetypeId = globalNotetype?.id; let lastDelimeter = delimiter; + let importing = false; $: columnOptions = getColumnOptions( columnLabels, @@ -94,8 +99,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html }); } - async function onImport(): Promise { - await importCsv({ + async function onImport(): Promise { + const result = await importCsv({ path, metadata: { dupeResolution, @@ -114,51 +119,69 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html preview: [], }, }); + await importDone({}); + importing = false; + return result; } - +
+ {#if importing} + + {:else if importResponse} + + {:else} + (importing = true)} /> - - - - -
- - - - - - - - -
- - {#if globalNotetype} - - {/if} - {#if deckId} - - {/if} - - - - - - - -
- - - - - - + + + + +
+ + + + + + + + +
+ + {#if globalNotetype} + + {/if} + {#if deckId} + + {/if} + + + + + + + +
+ + + + + + + {/if} +
diff --git a/ts/import-log/DetailsTable.svelte b/ts/import-log/DetailsTable.svelte new file mode 100644 index 000000000..d90e0349d --- /dev/null +++ b/ts/import-log/DetailsTable.svelte @@ -0,0 +1,94 @@ + + + +
+ {tr.importingDetails()} + + + # + {tr.importingStatus()} + {tr.editingFields()} + + + + + {index + 1} + + {rows[index].summary.action} + + + {rows[index].note.fields.join(",")} + + + { + showInBrowser([rows[index].note]); + }} + > + {@html magnifyIcon} + + + + + +
+ + diff --git a/ts/import-log/ImportLogPage.svelte b/ts/import-log/ImportLogPage.svelte new file mode 100644 index 000000000..b648a70a6 --- /dev/null +++ b/ts/import-log/ImportLogPage.svelte @@ -0,0 +1,81 @@ + + + + + + {#if result} +

+ {tr.importingNotesFoundInFile2({ + notes: foundNotes, + })} +

+
    + {#each summaries as summary} + + {/each} +
+ {#if closeButton} + + {/if} + + {/if} +
+ + diff --git a/ts/import-log/QueueSummary.svelte b/ts/import-log/QueueSummary.svelte new file mode 100644 index 000000000..e390f7e87 --- /dev/null +++ b/ts/import-log/QueueSummary.svelte @@ -0,0 +1,38 @@ + + + +{#if notes.length} +
  • + + {@html summary.icon} + + {summary.summaryTemplate({ count: notes.length })} + {#if summary.canBrowse} + + {/if} +
  • +{/if} + + diff --git a/ts/import-log/TableCell.svelte b/ts/import-log/TableCell.svelte new file mode 100644 index 000000000..b78dd15e8 --- /dev/null +++ b/ts/import-log/TableCell.svelte @@ -0,0 +1,21 @@ + + + + + + diff --git a/ts/import-log/TableCellWithTooltip.svelte b/ts/import-log/TableCellWithTooltip.svelte new file mode 100644 index 000000000..5b7ba9acc --- /dev/null +++ b/ts/import-log/TableCellWithTooltip.svelte @@ -0,0 +1,22 @@ + + + + + createTooltip(event.detail.element)} + > + + + diff --git a/ts/import-log/icons.ts b/ts/import-log/icons.ts new file mode 100644 index 000000000..02bef772a --- /dev/null +++ b/ts/import-log/icons.ts @@ -0,0 +1,10 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// + +export { default as checkCircle } from "@mdi/svg/svg/check-circle.svg"; +export { default as closeBox } from "@mdi/svg/svg/close-box.svg"; +export { default as magnifyIcon } from "@mdi/svg/svg/magnify.svg"; +export { default as newBox } from "@mdi/svg/svg/new-box.svg"; +export { default as updateIcon } from "@mdi/svg/svg/update.svg"; diff --git a/ts/import-log/import-log-base.scss b/ts/import-log/import-log-base.scss new file mode 100644 index 000000000..b4d5d174e --- /dev/null +++ b/ts/import-log/import-log-base.scss @@ -0,0 +1,18 @@ +@use "sass/bootstrap-dark"; + +@import "sass/base"; + +@import "sass/bootstrap-tooltip"; +@import "bootstrap/scss/buttons"; + +.night-mode { + @include bootstrap-dark.night-mode; +} + +body { + padding: 0 1em 1em 1em; +} + +html { + height: initial; +} diff --git a/ts/import-log/index.ts b/ts/import-log/index.ts new file mode 100644 index 000000000..59d35e6de --- /dev/null +++ b/ts/import-log/index.ts @@ -0,0 +1,40 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import "./import-log-base.scss"; + +import { ModuleName, setupI18n } from "@tslib/i18n"; +import { checkNightMode } from "@tslib/nightmode"; + +import ImportLogPage from "./ImportLogPage.svelte"; +import type { LogParams } from "./types"; + +const i18n = setupI18n({ + modules: [ + ModuleName.IMPORTING, + ModuleName.ADDING, + ModuleName.EDITING, + ModuleName.ACTIONS, + ModuleName.KEYBOARD, + ], +}); + +export async function setupImportLogPage( + params: LogParams, +): Promise { + await i18n; + + checkNightMode(); + + return new ImportLogPage({ + target: document.body, + props: { + params, + }, + }); +} + +if (window.location.hash.startsWith("#test-")) { + const path = window.location.hash.replace("#test-", ""); + setupImportLogPage({ type: "apkg", path }); +} diff --git a/ts/import-log/lib.ts b/ts/import-log/lib.ts new file mode 100644 index 000000000..5640098de --- /dev/null +++ b/ts/import-log/lib.ts @@ -0,0 +1,129 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { ImportResponse_Log, ImportResponse_Note } from "@tslib/anki/import_export_pb"; +import { CsvMetadata_DupeResolution } from "@tslib/anki/import_export_pb"; +import { searchInBrowser } from "@tslib/backend"; +import * as tr from "@tslib/ftl"; + +import { checkCircle, closeBox, newBox, updateIcon } from "./icons"; +import type { LogQueue, NoteRow, SummarizedLogQueues } from "./types"; + +function getFirstFieldQueue(log: ImportResponse_Log): { + action: string; + queue: LogQueue; +} { + let reason: string; + let action: string; + if (log.dupeResolution === CsvMetadata_DupeResolution.DUPLICATE) { + reason = tr.importingDuplicateNoteAdded(); + action = tr.addingAdded(); + } else if (log.dupeResolution === CsvMetadata_DupeResolution.PRESERVE) { + reason = tr.importingExistingNoteSkipped(); + action = tr.importingSkipped(); + } else { + reason = tr.importingNoteUpdatedAsFileHadNewer(); + action = tr.importingUpdated(); + } + const queue: LogQueue = { + reason, + notes: log.firstFieldMatch, + }; + return { action, queue }; +} + +export function getSummaries(log: ImportResponse_Log): SummarizedLogQueues[] { + const summarizedQueues = [ + { + queues: [ + { + notes: log.new, + reason: tr.importingAddedNewNote(), + }, + ], + action: tr.addingAdded(), + summaryTemplate: tr.importingNotesAdded, + canBrowse: true, + icon: newBox, + }, + { + queues: [ + { + notes: log.duplicate, + reason: tr.importingExistingNoteSkipped(), + }, + ], + action: tr.importingSkipped(), + summaryTemplate: tr.importingExistingNotesSkipped, + canBrowse: true, + icon: checkCircle, + }, + { + queues: [ + { + notes: log.updated, + reason: tr.importingNoteUpdatedAsFileHadNewer(), + }, + ], + action: tr.importingUpdated(), + summaryTemplate: tr.importingNotesUpdated, + canBrowse: true, + icon: updateIcon, + }, + { + queues: [ + { + notes: log.conflicting, + reason: tr.importingNoteSkippedUpdateDueToNotetype(), + }, + { + notes: log.missingNotetype, + reason: tr.importingNoteSkippedDueToMissingNotetype(), + }, + { + notes: log.missingDeck, + reason: tr.importingNoteSkippedDueToMissingDeck(), + }, + { + notes: log.emptyFirstField, + reason: tr.importingNoteSkippedDueToEmptyFirstField(), + }, + ], + action: tr.importingSkipped(), + summaryTemplate: tr.importingConflictingNotesSkipped, + canBrowse: false, + icon: closeBox, + }, + ]; + const firstFieldQueue = getFirstFieldQueue(log); + for (const summary of summarizedQueues) { + if (summary.action === firstFieldQueue.action) { + summary.queues.push(firstFieldQueue.queue); + break; + } + } + return summarizedQueues; +} + +export function getRows(summaries: SummarizedLogQueues[]): NoteRow[] { + const rows: NoteRow[] = []; + for (const summary of summaries) { + for (const queue of summary.queues) { + if (queue.notes) { + for (const note of queue.notes) { + rows.push({ summary, queue, note }); + } + } + } + } + return rows; +} + +export function showInBrowser(notes: ImportResponse_Note[]): void { + searchInBrowser({ + filter: { + case: "nids", + value: { ids: notes.map((note) => note.id!.nid) }, + }, + }); +} diff --git a/ts/import-log/tsconfig.json b/ts/import-log/tsconfig.json new file mode 100644 index 000000000..61d5e4756 --- /dev/null +++ b/ts/import-log/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "*" + ], + "references": [ + { + "path": "../lib" + }, + { + "path": "../components" + } + ], + "compilerOptions": { + "types": [ + "jest" + ] + } +} diff --git a/ts/import-log/types.ts b/ts/import-log/types.ts new file mode 100644 index 000000000..2e74d9b85 --- /dev/null +++ b/ts/import-log/types.ts @@ -0,0 +1,36 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { ImportResponse_Note } from "@tslib/anki/import_export_pb"; + +export type LogQueue = { + notes: ImportResponse_Note[]; + reason: string; +}; + +export type SummarizedLogQueues = { + queues: LogQueue[]; + action: string; + summaryTemplate: (args: { count: number }) => string; + canBrowse: boolean; + icon: unknown; +}; + +export type NoteRow = { + summary: SummarizedLogQueues; + queue: LogQueue; + note: ImportResponse_Note; +}; + +type PathParams = { + type?: "apkg" | "json_file"; + path: string; +}; + +type JsonParams = { + type?: "json_string"; + path: string; + json: string; +}; + +export type LogParams = PathParams | JsonParams; diff --git a/ts/lib/progress.ts b/ts/lib/progress.ts new file mode 100644 index 000000000..544b4a766 --- /dev/null +++ b/ts/lib/progress.ts @@ -0,0 +1,18 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { Progress } from "@tslib/anki/collection_pb"; +import { latestProgress } from "@tslib/backend"; + +export async function runWithBackendProgress( + callback: () => Promise, + onUpdate: (progress: Progress) => void, +): Promise { + const intervalId = setInterval(async () => { + const progress = await latestProgress({}); + onUpdate(progress); + }, 100); + const result = await callback(); + clearInterval(intervalId); + return result; +} diff --git a/ts/reviewer/answering.ts b/ts/reviewer/answering.ts index eed855d89..e3ed5eb30 100644 --- a/ts/reviewer/answering.ts +++ b/ts/reviewer/answering.ts @@ -2,7 +2,8 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { JsonValue } from "@bufbuild/protobuf"; -import type { SchedulingContext, SchedulingStatesWithContext } from "@tslib/anki/scheduler_pb"; +import type { SchedulingStatesWithContext } from "@tslib/anki/frontend_pb"; +import type { SchedulingContext } from "@tslib/anki/scheduler_pb"; import { SchedulingStates } from "@tslib/anki/scheduler_pb"; import { getSchedulingStatesWithContext, setSchedulingStates } from "@tslib/backend"; diff --git a/ts/reviewer/lib.test.ts b/ts/reviewer/lib.test.ts index 8624af5d6..78eabcff7 100644 --- a/ts/reviewer/lib.test.ts +++ b/ts/reviewer/lib.test.ts @@ -1,7 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import { SchedulingContext, SchedulingStates, SchedulingStatesWithContext } from "@tslib/anki/scheduler_pb"; +import { SchedulingStatesWithContext } from "@tslib/anki/frontend_pb"; +import { SchedulingContext, SchedulingStates } from "@tslib/anki/scheduler_pb"; import { applyStateTransform } from "./answering";