mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
b9da61f993
commit
98715e593a
41 changed files with 1203 additions and 271 deletions
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
37
proto/anki/frontend.proto
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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", [])
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
92
qt/aqt/import_export/import_log_dialog.py
Normal file
92
qt/aqt/import_export/import_log_dialog.py
Normal 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)
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -248,6 +248,7 @@ class AnkiWebViewKind(Enum):
|
|||
EMPTY_CARDS = "empty cards"
|
||||
FIND_DUPLICATES = "find duplicates"
|
||||
FIELDS = "fields"
|
||||
IMPORT_LOG = "import log"
|
||||
|
||||
|
||||
class AnkiWebView(QWebEngineView):
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
93
ts/components/BackendProgressIndicator.svelte
Normal file
93
ts/components/BackendProgressIndicator.svelte
Normal 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>
|
92
ts/components/VirtualTable.svelte
Normal file
92
ts/components/VirtualTable.svelte
Normal 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>
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ const i18n = setupI18n({
|
|||
ModuleName.KEYBOARD,
|
||||
ModuleName.NOTETYPES,
|
||||
ModuleName.STUDYING,
|
||||
ModuleName.ADDING,
|
||||
],
|
||||
});
|
||||
|
||||
|
|
53
ts/import-log/CloseButton.svelte
Normal file
53
ts/import-log/CloseButton.svelte
Normal 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>
|
94
ts/import-log/DetailsTable.svelte
Normal file
94
ts/import-log/DetailsTable.svelte
Normal 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>
|
81
ts/import-log/ImportLogPage.svelte
Normal file
81
ts/import-log/ImportLogPage.svelte
Normal 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>
|
38
ts/import-log/QueueSummary.svelte
Normal file
38
ts/import-log/QueueSummary.svelte
Normal 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>
|
21
ts/import-log/TableCell.svelte
Normal file
21
ts/import-log/TableCell.svelte
Normal 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>
|
22
ts/import-log/TableCellWithTooltip.svelte
Normal file
22
ts/import-log/TableCellWithTooltip.svelte
Normal 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
10
ts/import-log/icons.ts
Normal 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";
|
18
ts/import-log/import-log-base.scss
Normal file
18
ts/import-log/import-log-base.scss
Normal 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
40
ts/import-log/index.ts
Normal 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
129
ts/import-log/lib.ts
Normal 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) },
|
||||
},
|
||||
});
|
||||
}
|
19
ts/import-log/tsconfig.json
Normal file
19
ts/import-log/tsconfig.json
Normal 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
36
ts/import-log/types.ts
Normal 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
18
ts/lib/progress.ts
Normal 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;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
Loading…
Reference in a new issue