Improve presentation of importing results (#2568)

* Implement import log screen in Svelte

* Show filename in import log screen title

* Remove unused NoteRow property

* Show number of imported notes

* Use a single nid expression

* Use 'count' as variable name for consistency

* Import from @tslib/backend instead

* Fix summary_template typing

* Fix clippy warning

* Apply suggestions from code review

* Fix imports

* Contents -> Fields

* Increase max length of browser search bar

https://github.com/ankitects/anki/pull/2568/files#r1255227035

* Fix race condition in Bootstrap tooltip destruction

https://github.com/twbs/bootstrap/issues/37474

* summary_template -> summaryTemplate

* Make show link a button

* Run import ops on Svelte side

* Fix geometry not being restored in CSV Import page

* Make VirtualTable fill available height

* Keep CSV dialog modal

* Reword importing-existing-notes-skipped

* Avoid mentioning matching based on first field

* Change tick and cross icons

* List skipped notes last

* Pure CSS spinner

* Move set_wants_abort() call to relevant dialogs

* Show number of imported cards

* Remove bold from first sentence and indent summaries

* Update UI after import operations

* Add close button to import log page

Also make virtual table react to resize event.

* Fix typing

* Make CSV dialog non-modal again

Otherwise user can't interact with browser window.

* Update window modality after import

* Commit DB and update undo actions after import op

* Split frontend proto into separate file, so backend can ignore it

Currently the automatically-generated frontend RPC methods get placed in
'backend.js' with all the backend methods; we could optionally split them
into a separate 'frontend.js' file in the future.

* Migrate import_done from a bridgecmd to a HTTP request

* Update plural form of importing-notes-added

* Move import response handling to mediasrv

* Move task callback to script section

* Avoid unnecessary :global()

* .log cannot be missing if result exists

* Move import log search handling to mediasrv

* Type common params of ImportLogDialog

* Use else if

* Remove console.log()

* Add way to test apkg imports in new log screen

* Remove unused import

* Get actual card count for CSV imports

* Use import type

* Fix typing error

* Ignore import log when checking for changes in Python layer

* Apply suggestions from code review

* Remove imported card count for now

* Avoid non-null assertion in assignment

* Change showInBrowser to take an array of notes

* Use dataclasses for import log args

* Simplify ResultWithChanges in TS

* Only abort import when window is modal

* Fix ResultWithChanges typing

* Fix Rust warnings

* Only log one duplicate per incoming note

* Update wording about note updates

* Remove caveat about found_notes

* Reduce font size

* Remove redundant map

* Give credit to loading.io

* Remove unused line

---------

Co-authored-by: RumovZ <gp5glkw78@relay.firefox.com>
This commit is contained in:
Abdo 2023-08-02 13:29:44 +03:00 committed by GitHub
parent b9da61f993
commit 98715e593a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1203 additions and 271 deletions

View file

@ -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(())
}

View file

@ -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.

View file

@ -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 {

37
proto/anki/frontend.proto Normal file
View file

@ -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;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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", [])
)

View file

@ -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()

View file

@ -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)

View file

@ -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(),
),
)

View file

@ -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

View file

@ -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")

View file

@ -248,6 +248,7 @@ class AnkiWebViewKind(Enum):
EMPTY_CARDS = "empty cards"
FIND_DUPLICATES = "find duplicates"
FIELDS = "fields"
IMPORT_LOG = "import log"
class AnkiWebView(QWebEngineView):

View file

@ -53,7 +53,8 @@ pub fn get_services(pool: &DescriptorPool) -> (Vec<CollectionService>, Vec<Backe
Either::Left(CollectionService::from_proto(service))
}
});
assert!(col_services.len() == backend_services.len());
// frontend.proto is only in col_services
assert_eq!(col_services.len(), backend_services.len());
// copy collection methods into backend services if they don't have one with
// a matching name
for service in &mut backend_services {

View file

@ -13,6 +13,7 @@ use anki_proto_gen::Method;
use anyhow::Context;
use anyhow::Result;
use inflections::Inflect;
use itertools::Itertools;
use prost_reflect::DescriptorPool;
pub fn write_rust_interface(pool: &DescriptorPool) -> 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)?;

View file

@ -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(&note, &dupe.note)?;
update_result.update(DuplicateUpdateResult::Update(dupe));
}
self.add_cards(&mut cards, &note, 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()

View file

@ -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<scheduler::CustomStudyDefaultsResponse> {
self.custom_study_defaults(input.deck_id.into())
}
fn get_scheduling_states_with_context(&mut self) -> Result<SchedulingStatesWithContext> {
invalid_input!("the frontend should implement this")
}
fn set_scheduling_states(&mut self, _input: SetSchedulingStatesRequest) -> Result<()> {
invalid_input!("the frontend should implement this")
}
}

View file

@ -0,0 +1,93 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { OpChanges, Progress } from "@tslib/anki/collection_pb";
import { runWithBackendProgress } from "@tslib/progress";
import { pageTheme } from "../sveltelib/theme";
type ResultWithChanges = OpChanges | { changes?: OpChanges };
export let task: () => Promise<ResultWithChanges | undefined>;
export let result: ResultWithChanges | undefined = undefined;
let label: string = "";
function onUpdate(progress: Progress) {
if (progress.value.value && label !== progress.value.value) {
label = progress.value.value.toString();
}
}
$: (async () => {
if (!result) {
result = await runWithBackendProgress(task, onUpdate);
}
})();
</script>
<!-- spinner taken from https://loading.io/css/; CC0 -->
{#if !result}
<div class="progress">
<div class="spinner" class:nightMode={$pageTheme.isDark}>
<div />
<div />
<div />
<div />
</div>
<div id="label">{label}</div>
</div>
{/if}
<style lang="scss">
.progress {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.spinner {
display: block;
position: relative;
width: 80px;
height: 80px;
margin: 0 auto;
div {
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid #000;
border-radius: 50%;
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #000 transparent transparent transparent;
}
&.nightMode div {
border-top-color: #fff;
}
div:nth-child(1) {
animation-delay: -0.45s;
}
div:nth-child(2) {
animation-delay: -0.3s;
}
div:nth-child(3) {
animation-delay: -0.15s;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#label {
text-align: center;
}
</style>

View file

@ -0,0 +1,92 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
let className: string = "";
export { className as class };
export let itemsCount: number = 0;
export let itemHeight: number;
export let bottomOffset: number = 0;
let container: HTMLElement;
let scrollTop: number = 0;
$: containerHeight = container
? Math.floor(
(document.documentElement.clientHeight -
container.offsetTop -
bottomOffset) /
itemHeight,
) * itemHeight
: 0;
$: innerHeight = Math.max(containerHeight, itemsCount * itemHeight);
$: sliceLength = Math.ceil(containerHeight / itemHeight);
$: startIndex = Math.floor(scrollTop / itemHeight);
$: endIndex = Math.min(startIndex + sliceLength, itemsCount);
$: slice = new Array(endIndex - startIndex).fill(0).map((_, i) => startIndex + i);
window.addEventListener("resize", () => {
containerHeight = containerHeight;
});
</script>
<div
class="outer"
style="--container-height: {containerHeight + 1}px"
bind:this={container}
on:scroll={() => (scrollTop = container.scrollTop)}
>
<div class="inner" style="height: {innerHeight}px;">
<table
class="table {className}"
tabindex="-1"
style="margin-top: {scrollTop}px"
>
<slot name="headers" />
{#each slice as index (index)}
<slot name="row" {index} />
{/each}
</table>
</div>
</div>
<style lang="scss">
.outer {
width: 100%;
overflow: auto;
height: var(--container-height);
margin: 0 auto;
}
.inner {
position: relative;
overflow-y: visible;
width: 100%;
}
.table {
// Prevent infinite scrolling
position: absolute;
border-collapse: collapse;
white-space: nowrap;
:global(th),
:global(td) {
text-overflow: ellipsis;
overflow: hidden;
border: 1px solid var(--border-subtle);
padding: 0.25rem 0.5rem;
max-width: 15em;
}
:global(th) {
background: var(--border);
text-align: center;
}
}
</style>

View file

@ -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();
});
</script>
<slot {createTooltip} {tooltipObject} />

View file

@ -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<void> {
await importCsv({
async function onImport(): Promise<ImportResponse> {
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;
}
</script>
<StickyHeader {path} {onImport} />
<div class="outer">
{#if importing}
<BackendProgressIndicator task={onImport} bind:result={importResponse} />
{:else if importResponse}
<ImportLogPage response={importResponse} params={{ path }} />
{:else}
<StickyHeader {path} onImport={() => (importing = true)} />
<Container class="csv-page">
<Row --cols={2}>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFile()} />
<Spacer --height="1.5rem" />
<DelimiterSelector bind:delimiter disabled={forceDelimiter} />
<HtmlSwitch bind:isHtml disabled={forceIsHtml} />
<Preview {columnOptions} {preview} />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingImportOptions()} />
<Spacer --height="1.5rem" />
{#if globalNotetype}
<NotetypeSelector
{notetypeNameIds}
bind:notetypeId={globalNotetype.id}
/>
{/if}
{#if deckId}
<DeckSelector {deckNameIds} bind:deckId />
{/if}
<DupeResolutionSelector bind:dupeResolution />
<DeckDupeCheckSwitch bind:matchScope />
<Tags bind:globalTags bind:updatedTags />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFieldMapping()} />
<Spacer --height="1.5rem" />
<FieldMapper {columnOptions} bind:globalNotetype bind:tagsColumn />
</Container>
</Col>
</Row>
</Container>
<Container class="csv-page">
<Row --cols={2}>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFile()} />
<Spacer --height="1.5rem" />
<DelimiterSelector bind:delimiter disabled={forceDelimiter} />
<HtmlSwitch bind:isHtml disabled={forceIsHtml} />
<Preview {columnOptions} {preview} />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingImportOptions()} />
<Spacer --height="1.5rem" />
{#if globalNotetype}
<NotetypeSelector
{notetypeNameIds}
bind:notetypeId={globalNotetype.id}
/>
{/if}
{#if deckId}
<DeckSelector {deckNameIds} bind:deckId />
{/if}
<DupeResolutionSelector bind:dupeResolution />
<DeckDupeCheckSwitch bind:matchScope />
<Tags bind:globalTags bind:updatedTags />
</Container>
</Col>
<Col --col-size={1} breakpoint="md">
<Container>
<Header heading={tr.importingFieldMapping()} />
<Spacer --height="1.5rem" />
<FieldMapper
{columnOptions}
bind:globalNotetype
bind:tagsColumn
/>
</Container>
</Col>
</Row>
</Container>
{/if}
</div>
<style lang="scss">
.outer {
margin: 0 auto;
}
:global(.csv-page) {
--gutter-inline: 0.25rem;

View file

@ -8,15 +8,13 @@
@import "bootstrap/scss/close";
@import "bootstrap/scss/grid";
@import "sass/bootstrap-forms";
@import "sass/bootstrap-tooltip";
.night-mode {
@include bootstrap-dark.night-mode;
}
body {
min-height: 100vh;
width: min(100vw, 70em);
margin: 0 auto;
padding: 0 1em 1em 1em;
}

View file

@ -20,6 +20,7 @@ const i18n = setupI18n({
ModuleName.KEYBOARD,
ModuleName.NOTETYPES,
ModuleName.STUDYING,
ModuleName.ADDING,
],
});

View file

@ -0,0 +1,53 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { bridgeCommand } from "@tslib/bridgecommand";
import * as tr from "@tslib/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import LabelButton from "../components/LabelButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
export let container: HTMLElement;
const keyCombination = "Control+Enter";
function onClose() {
bridgeCommand("close");
}
</script>
<div
class="fixed-header d-flex flex-row-reverse justify-content-between"
bind:this={container}
>
<LabelButton
primary
tooltip={getPlatformString(keyCombination)}
on:click={onClose}
--border-left-radius="5px"
--border-right-radius="5px"
>
<div class="close">{tr.actionsClose()}</div>
</LabelButton>
<Shortcut {keyCombination} on:action={onClose} />
</div>
<style lang="scss">
.fixed-header {
position: fixed;
right: 0;
bottom: 0;
z-index: 10;
margin: 0;
padding: 0.5rem;
background: var(--canvas);
.close {
margin-inline: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,94 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import IconButton from "../components/IconButton.svelte";
import VirtualTable from "../components/VirtualTable.svelte";
import { magnifyIcon } from "./icons";
import { getRows, showInBrowser } from "./lib";
import TableCellWithTooltip from "./TableCellWithTooltip.svelte";
import type { SummarizedLogQueues } from "./types";
export let summaries: SummarizedLogQueues[];
export let bottomOffset: number = 0;
$: rows = getRows(summaries);
</script>
<details>
<summary>{tr.importingDetails()}</summary>
<VirtualTable
class="details-table"
itemHeight={50}
itemsCount={rows.length}
bind:bottomOffset
>
<tr slot="headers">
<th>#</th>
<th>{tr.importingStatus()}</th>
<th>{tr.editingFields()}</th>
<th />
</tr>
<svelte:fragment slot="row" let:index>
<tr>
<td class="index-cell">{index + 1}</td>
<TableCellWithTooltip
class="status-cell"
tooltip={rows[index].queue.reason}
>
{rows[index].summary.action}
</TableCellWithTooltip>
<TableCellWithTooltip
class="contents-cell"
tooltip={rows[index].note.fields.join(",")}
>
{rows[index].note.fields.join(",")}
</TableCellWithTooltip>
<td class="search-cell">
<IconButton
class="search-icon"
iconSize={100}
active={false}
disabled={!rows[index].summary.canBrowse}
on:click={() => {
showInBrowser([rows[index].note]);
}}
>
{@html magnifyIcon}
</IconButton>
</td>
</tr>
</svelte:fragment>
</VirtualTable>
</details>
<style lang="scss">
:global(.details-table) {
margin: 0 auto;
width: 100%;
:global(.search-icon) {
border: none !important;
background: transparent !important;
}
tr {
height: 50px;
text-align: center;
}
.index-cell {
width: 6em;
}
:global(.status-cell) {
width: 5em;
}
:global(.contents-cell) {
text-align: left;
}
:global(.search-cell) {
width: 4em;
}
}
</style>

View file

@ -0,0 +1,81 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { ImportResponse } from "@tslib/anki/import_export_pb";
import {
importAnkiPackage,
importDone,
importJsonFile,
importJsonString,
} from "@tslib/backend";
import * as tr from "@tslib/ftl";
import BackendProgressIndicator from "../components/BackendProgressIndicator.svelte";
import Container from "../components/Container.svelte";
import CloseButton from "./CloseButton.svelte";
import DetailsTable from "./DetailsTable.svelte";
import { getSummaries } from "./lib";
import QueueSummary from "./QueueSummary.svelte";
import type { LogParams } from "./types";
export let params: LogParams;
export let response: ImportResponse | undefined = undefined;
let result = response;
$: summaries = result ? getSummaries(result.log!) : [];
$: foundNotes = result?.log?.foundNotes ?? 0;
let closeButton: HTMLElement;
async function onImport(): Promise<ImportResponse | undefined> {
let result: ImportResponse | undefined;
switch (params.type) {
case "apkg":
result = await importAnkiPackage({
packagePath: params.path,
});
break;
case "json_file":
result = await importJsonFile({ val: params.path });
break;
case "json_string":
result = await importJsonString({ val: params.json });
break;
}
await importDone({});
return result;
}
</script>
<Container class="import-log-page">
<BackendProgressIndicator task={onImport} bind:result />
{#if result}
<p class="note-count">
{tr.importingNotesFoundInFile2({
notes: foundNotes,
})}
</p>
<ul class="summary-list">
{#each summaries as summary}
<QueueSummary {summary} />
{/each}
</ul>
{#if closeButton}
<DetailsTable {summaries} bind:bottomOffset={closeButton.clientHeight} />
{/if}
<CloseButton bind:container={closeButton} />
{/if}
</Container>
<style lang="scss">
:global(.import-log-page) {
font-size: 16px;
margin: 8px auto;
}
.note-count {
margin-bottom: 4px;
}
.summary-list {
padding-inline-start: 8px;
}
</style>

View file

@ -0,0 +1,38 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@tslib/ftl";
import IconConstrain from "../components/IconConstrain.svelte";
import { showInBrowser } from "./lib";
import type { SummarizedLogQueues } from "./types";
export let summary: SummarizedLogQueues;
$: notes = summary.queues.map((queue) => queue.notes).flat();
function onShow(event: MouseEvent) {
showInBrowser(notes);
event.preventDefault();
}
</script>
{#if notes.length}
<li>
<IconConstrain>
{@html summary.icon}
</IconConstrain>
{summary.summaryTemplate({ count: notes.length })}
{#if summary.canBrowse}
<button on:click={onShow}>{tr.importingShow()}</button>
{/if}
</li>
{/if}
<style lang="scss">
li {
list-style-type: none;
}
</style>

View file

@ -0,0 +1,21 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
let className = "";
export { className as class };
const dispatch = createEventDispatcher();
let element: HTMLElement;
onMount(async () => {
dispatch("mount", { element });
});
</script>
<td bind:this={element} class={className}>
<slot />
</td>

View file

@ -0,0 +1,22 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithTooltip from "components/WithTooltip.svelte";
import TableCell from "./TableCell.svelte";
let className = "";
export { className as class };
export let tooltip: string;
</script>
<WithTooltip {tooltip} let:createTooltip>
<TableCell
class={className}
on:mount={(event) => createTooltip(event.detail.element)}
>
<slot />
</TableCell>
</WithTooltip>

10
ts/import-log/icons.ts Normal file
View file

@ -0,0 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// <reference types="../lib/image-import" />
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";

View file

@ -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;
}

40
ts/import-log/index.ts Normal file
View file

@ -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<ImportLogPage> {
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 });
}

129
ts/import-log/lib.ts Normal file
View file

@ -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) },
},
});
}

View file

@ -0,0 +1,19 @@
{
"extends": "../tsconfig.json",
"include": [
"*"
],
"references": [
{
"path": "../lib"
},
{
"path": "../components"
}
],
"compilerOptions": {
"types": [
"jest"
]
}
}

36
ts/import-log/types.ts Normal file
View file

@ -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;

18
ts/lib/progress.ts Normal file
View file

@ -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<T>(
callback: () => Promise<T>,
onUpdate: (progress: Progress) => void,
): Promise<T> {
const intervalId = setInterval(async () => {
const progress = await latestProgress({});
onUpdate(progress);
}, 100);
const result = await callback();
clearInterval(intervalId);
return result;
}

View file

@ -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";

View file

@ -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";