Merging Notetypes on Import (#2612)

* Remember original id when importing notetype

* Reuse notetypes with matching original id

* Add field and template ids

* Enable merging imported notetypes

* Fix test

Note should be updated if the incoming note's notetype is
remapped to the existing note's notetype.
On the other hand, it should be skipped if its notetype id is mapped
to some new notetype.

* Change field and template ids to i32

* Add merge notetypes flag to proto message

* Add dialog for apkg import

* Move HelpModal into components

* Generalize import dialog

* Move SettingTitle into components

* Add help modal to ImportAnkiPackagePage

* Move SwitchRow into components

* Fix backend method import

* Make testable in browser

* Fix broken modal

* Wrap in container and fix margins

* Update commented Anki version of new proto fields

* Check ids when comparing notetype schemas

* Add tooltip for merging notetypes.

* Allow updating notes regardless of mtime

* Gitignore yarn-error.log

* Allow updating notetypes regardless of mtime

* Fix apkg help carousel

* Use i64s for template and field ids

* Add option to omit importing scheduling info

* Restore last settings in apkg import dialog

* Display error when getting metadata in webview

* Update manual links for apkg importing

* Apply suggestions from code review

Co-authored-by: Damien Elmes <dae@users.noreply.github.com>

* Omit schduling -> Import all cards as new cards

* Tweak importing-update-notes-help

* UpdateCondition → ImportAnkiPackageUpdateCondition

* Load keyboard.ftl

* Skip updating dupes in 'update alwyas' case

* Explain more when merging notetypes is required

* "omit scheduling" → "with scheduling"

* Skip updating notetype dupes if 'update always'

* Merge duplicated notetypes from previous imports

* Fix rebase aftermath

* Fix panic when merging

* Clarify 'update notetypes' help

* Mention 'merge notetypes' in the log

* Add a test which covers the previously panicking path

* Use nested ftl messages to ensure consistency

* Make order of merged fields deterministic

* Rewrite test to trigger panic

* Update version comment on new fields
This commit is contained in:
RumovZ 2023-09-09 01:00:55 +02:00 committed by GitHub
parent ba7140ddec
commit 14de8451dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 1409 additions and 275 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ node_modules
.ninja_log
.ninja_deps
/extra
yarn-error.log

View File

@ -314,6 +314,17 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
":sass"
],
)?;
build_page(
"import-anki-package",
true,
inputs![
//
":ts:lib",
":ts:components",
":ts:sveltelib",
":sass"
],
)?;
// we use the generated .css file separately
build_page(
"editable",

View File

@ -30,6 +30,18 @@ importing-map-to = Map to { $val }
importing-map-to-tags = Map to Tags
importing-mapped-to = mapped to <b>{ $val }</b>
importing-mapped-to-tags = mapped to <b>Tags</b>
# the action of combining two existing notetypes to create a new one
importing-merge-notetypes = Merge notetypes
importing-merge-notetypes-help =
If checked, and you or the deck author altered the schema of a notetype, Anki will
merge the two versions instead of keeping both.
Altering a notetype's schema means adding, removing, or reordering fields or templates,
or changing the sort field.
As a counterexample, changing the front side of an existing template does *not* constitute
a schema change.
Warning: This will require a one-way sync, and may mark existing notes as modified.
importing-mnemosyne-20-deck-db = Mnemosyne 2.0 Deck (*.db)
importing-multicharacter-separators-are-not-supported-please = Multi-character separators are not supported. Please enter one character only.
importing-notes-added-from-file = Notes added from file: { $val }
@ -37,6 +49,10 @@ importing-notes-found-in-file = Notes found in file: { $val }
importing-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copies are already in your collection: { $val }
importing-notes-skipped-update-due-to-notetype = Notes not updated, as notetype has been modified since you first imported the notes: { $val }
importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val }
importing-include-reviews = Include reviews
importing-include-reviews-help =
If enabled, any previous reviews that the deck sharer included will also be imported.
Otherwise, all cards will be imported as new cards.
importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip)
importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
# the '|' character
@ -57,6 +73,19 @@ importing-unable-to-import-from-a-readonly = Unable to import from a read-only f
importing-unknown-file-format = Unknown file format.
importing-update-existing-notes-when-first-field = Update existing notes when first field matches
importing-updated = Updated
importing-update-if-newer = If newer
importing-update-always = Always
importing-update-never = Never
importing-update-notes = Update notes
importing-update-notes-help =
When to update an existing note in your collection. By default, this is only done
if the matching imported note was more recently modified.
importing-update-notetypes = Update notetypes
importing-update-notetypes-help =
When to update an existing notetype in your collection. By default, this is only done
if the matching imported notetype was more recently modified. Changes to template text
and styling can always be imported, but for schema changes (e.g. the number or order of
fields has changed), the '{ importing-merge-notetypes }' option will also need to be enabled.
importing-note-added =
{ $count ->
[one] { $count } note added
@ -136,6 +165,11 @@ importing-conflicting-notes-skipped =
[one] { $count } note was not imported, because its note type has changed.
*[other] { $count } were not imported, because their note type has changed.
}
importing-conflicting-notes-skipped2 =
{ $count ->
[one] { $count } note was not imported, because its notetype has changed, and '{ importing-merge-notetypes }' was not enabled.
*[other] { $count } were not imported, because their notetype has changed, and '{ importing-merge-notetypes }' was not enabled.
}
importing-import-log = Import Log
importing-no-notes-in-file = No notes found in file.
importing-notes-found-in-file2 =

View File

@ -14,6 +14,8 @@ import "anki/generic.proto";
service ImportExportService {
rpc ImportAnkiPackage(ImportAnkiPackageRequest) returns (ImportResponse);
rpc GetImportAnkiPackagePresets(generic.Empty)
returns (ImportAnkiPackageOptions);
rpc ExportAnkiPackage(ExportAnkiPackageRequest) returns (generic.UInt32);
rpc GetCsvMetadata(CsvMetadataRequest) returns (CsvMetadata);
rpc ImportCsv(ImportCsvRequest) returns (ImportResponse);
@ -45,8 +47,22 @@ message ExportCollectionPackageRequest {
bool legacy = 3;
}
enum ImportAnkiPackageUpdateCondition {
IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER = 0;
IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_ALWAYS = 1;
IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_NEVER = 2;
}
message ImportAnkiPackageOptions {
bool merge_notetypes = 1;
ImportAnkiPackageUpdateCondition update_notes = 2;
ImportAnkiPackageUpdateCondition update_notetypes = 3;
bool with_scheduling = 4;
}
message ImportAnkiPackageRequest {
string package_path = 1;
ImportAnkiPackageOptions options = 2;
}
message ImportResponse {

View File

@ -70,6 +70,8 @@ message Notetype {
repeated CardRequirement reqs = 8;
// Only set on notetypes created with Anki 2.1.62+.
StockNotetype.OriginalStockKind original_stock_kind = 9;
// the id in the source collection for imported notetypes (Anki 23.09)
optional int64 original_id = 10;
bytes other = 255;
}
@ -83,6 +85,8 @@ message Notetype {
bool plain_text = 6;
bool collapsed = 7;
bool exclude_from_search = 8;
// used for merging notetypes on import (Anki 23.09)
optional int64 id = 9;
bytes other = 255;
}
@ -99,6 +103,8 @@ message Notetype {
int64 target_deck_id = 5;
string browser_font_name = 6;
uint32 browser_font_size = 7;
// used for merging notetypes on import (Anki 23.09)
optional int64 id = 8;
bytes other = 255;
}

View File

@ -38,6 +38,7 @@ BrowserRow = search_pb2.BrowserRow
BrowserColumns = search_pb2.BrowserColumns
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
ImportLogWithChanges = import_export_pb2.ImportResponse
ImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest
ImportCsvRequest = import_export_pb2.ImportCsvRequest
CsvMetadata = import_export_pb2.CsvMetadata
DupeResolution = CsvMetadata.DupeResolution
@ -395,8 +396,11 @@ class Collection(DeprecatedNamesMixin):
out_path=out_path, include_media=include_media, legacy=legacy
)
def import_anki_package(self, path: str) -> ImportLogWithChanges:
return self._backend.import_anki_package(package_path=path)
def import_anki_package(
self, request: ImportAnkiPackageRequest
) -> ImportLogWithChanges:
log = self._backend.import_anki_package_raw(request.SerializeToString())
return ImportLogWithChanges.FromString(log)
def export_anki_package(
self,

View File

@ -12,8 +12,13 @@ from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGe
from aqt.webview import AnkiWebView, AnkiWebViewKind
class ImportCsvDialog(QDialog):
TITLE = "csv import"
class ImportDialog(QDialog):
TITLE: str
KIND: AnkiWebViewKind
TS_PAGE: str
SETUP_FUNCTION_NAME: str
DEFAULT_SIZE = (800, 800)
MIN_SIZE = (400, 300)
silentlyClose = True
def __init__(
@ -29,13 +34,14 @@ class ImportCsvDialog(QDialog):
def _setup_ui(self, path: str) -> None:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.mw.garbage_collect_on_dialog_finish(self)
self.setMinimumSize(400, 300)
self.setMinimumSize(*self.MIN_SIZE)
disable_help_button(self)
restoreGeom(self, self.TITLE, default_size=self.DEFAULT_SIZE)
addCloseShortcut(self)
self.web = AnkiWebView(kind=AnkiWebViewKind.IMPORT_CSV)
self.web = AnkiWebView(kind=self.KIND)
self.web.setVisible(False)
self.web.load_ts_page("import-csv")
self.web.load_ts_page(self.TS_PAGE)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.web)
@ -44,7 +50,8 @@ class ImportCsvDialog(QDialog):
escaped_path = path.replace("'", r"\'")
self.web.evalWithCallback(
f"anki.setupImportCsvPage('{escaped_path}');", lambda _: self.web.setFocus()
f"anki.{self.SETUP_FUNCTION_NAME}('{escaped_path}');",
lambda _: self.web.setFocus(),
)
self.setWindowTitle(tr.decks_import_file())
@ -55,3 +62,17 @@ class ImportCsvDialog(QDialog):
self.web = None
saveGeom(self, self.TITLE)
QDialog.reject(self)
class ImportCsvDialog(ImportDialog):
TITLE = "csv import"
KIND = AnkiWebViewKind.IMPORT_CSV
TS_PAGE = "import-csv"
SETUP_FUNCTION_NAME = "setupImportCsvPage"
class ImportAnkiPackageDialog(ImportDialog):
TITLE = "anki package import"
KIND = AnkiWebViewKind.IMPORT_ANKI_PACKAGE
TS_PAGE = "import-anki-package"
SETUP_FUNCTION_NAME = "setupImportAnkiPackagePage"

View File

@ -25,11 +25,6 @@ class _CommonArgs:
return json.dumps(dataclasses.asdict(self))
@dataclass
class ApkgArgs(_CommonArgs):
type = "apkg"
@dataclass
class JsonFileArgs(_CommonArgs):
type = "json_file"
@ -48,7 +43,7 @@ class ImportLogDialog(QDialog):
def __init__(
self,
mw: aqt.main.AnkiQt,
args: ApkgArgs | JsonFileArgs | JsonStringArgs,
args: JsonFileArgs | JsonStringArgs,
) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
@ -57,7 +52,7 @@ class ImportLogDialog(QDialog):
def _setup_ui(
self,
args: ApkgArgs | JsonFileArgs | JsonStringArgs,
args: JsonFileArgs | JsonStringArgs,
) -> None:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.mw.garbage_collect_on_dialog_finish(self)

View File

@ -13,9 +13,8 @@ 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.import_export.import_dialog import ImportAnkiPackageDialog, ImportCsvDialog
from aqt.import_export.import_log_dialog import (
ApkgArgs,
ImportLogDialog,
JsonFileArgs,
JsonStringArgs,
@ -88,7 +87,7 @@ class ApkgImporter(Importer):
@staticmethod
def do_import(mw: aqt.main.AnkiQt, path: str) -> None:
ImportLogDialog(mw, ApkgArgs(path=path))
ImportAnkiPackageDialog(mw, path)
class MnemosyneImporter(Importer):

View File

@ -424,12 +424,10 @@ def set_scheduling_states() -> bytes:
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_dialog import ImportDialog
from aqt.import_export.import_log_dialog import ImportLogDialog
if isinstance(window, ImportCsvDialog) or isinstance(
window, ImportLogDialog
):
if isinstance(window, (ImportDialog, ImportLogDialog)):
window.hide()
window.setWindowModality(Qt.WindowModality.NonModal)
window.show()
@ -517,6 +515,7 @@ exposed_backend_list = [
"i18n_resources",
# ImportExportService
"get_csv_metadata",
"get_import_anki_package_presets",
# NotesService
"get_field_names",
"get_note",

View File

@ -249,6 +249,7 @@ class AnkiWebViewKind(Enum):
FIND_DUPLICATES = "find duplicates"
FIELDS = "fields"
IMPORT_LOG = "import log"
IMPORT_ANKI_PACKAGE = "anki package import"
class AnkiWebView(QWebEngineView):

View File

@ -45,6 +45,10 @@ pub fn write_rust_protos(descriptors_path: PathBuf) -> Result<DescriptorPool> {
"CsvMetadata.MatchScope",
"#[derive(serde::Deserialize, serde::Serialize)]",
)
.type_attribute(
"ImportAnkiPackageUpdateCondition",
"#[derive(serde::Deserialize, serde::Serialize)]",
)
.compile_protos(paths.as_slice(), &[proto_dir])
.context("prost build")?;

View File

@ -34,6 +34,8 @@ pub enum BoolKey {
RandomOrderReposition,
Sched2021,
ShiftPositionOfExistingCards,
MergeNotetypes,
WithScheduling,
#[strum(to_string = "normalize_note_text")]
NormalizeNoteText,

View File

@ -21,6 +21,7 @@ pub use self::deck::DeckConfigKey;
pub use self::notetype::get_aux_notetype_config_key;
pub use self::number::I32ConfigKey;
pub use self::string::StringKey;
use crate::import_export::package::UpdateCondition;
use crate::prelude::*;
/// Only used when updating/undoing.
@ -51,6 +52,8 @@ pub(crate) enum ConfigKey {
LocalOffset,
Rollover,
Backups,
UpdateNotes,
UpdateNotetypes,
#[strum(to_string = "timeLim")]
AnswerTimeLimitSecs,
@ -286,6 +289,16 @@ impl Collection {
pub(crate) fn set_backup_limits(&mut self, limits: BackupLimits) -> Result<()> {
self.set_config(ConfigKey::Backups, &limits).map(|_| ())
}
pub(crate) fn get_update_notes(&self) -> UpdateCondition {
self.get_config_optional(ConfigKey::UpdateNotes)
.unwrap_or_default()
}
pub(crate) fn get_update_notetypes(&self) -> UpdateCondition {
self.get_config_optional(ConfigKey::UpdateNotetypes)
.unwrap_or_default()
}
}
// 2021 scheduler moves this into deck config

View File

@ -43,7 +43,7 @@ impl Collection {
}
pub(crate) fn get_first_io_notetype(&mut self) -> Result<Option<Arc<Notetype>>> {
for (_, nt) in self.get_all_notetypes()? {
for nt in self.get_all_notetypes()? {
if nt.config.original_stock_kind() == OriginalStockKind::ImageOcclusion {
return Some(io_notetype_if_valid(nt)).transpose();
}

View File

@ -6,6 +6,7 @@ use std::collections::HashSet;
use std::mem;
use super::Context;
use super::TemplateMap;
use crate::card::CardQueue;
use crate::card::CardType;
use crate::config::SchedulerVersion;
@ -19,6 +20,8 @@ struct CardContext<'a> {
usn: Usn,
imported_notes: &'a HashMap<NoteId, NoteId>,
notetype_map: &'a HashMap<NoteId, NotetypeId>,
remapped_templates: &'a HashMap<NotetypeId, TemplateMap>,
remapped_decks: &'a HashMap<DeckId, DeckId>,
/// The number of days the source collection is ahead of the target
@ -37,6 +40,8 @@ impl<'c> CardContext<'c> {
days_elapsed: u32,
target_col: &'a mut Collection,
imported_notes: &'a HashMap<NoteId, NoteId>,
notetype_map: &'a HashMap<NoteId, NotetypeId>,
remapped_templates: &'a HashMap<NotetypeId, TemplateMap>,
imported_decks: &'a HashMap<DeckId, DeckId>,
) -> Result<Self> {
let existing_cards = target_col.storage.all_cards_as_nid_and_ord()?;
@ -47,6 +52,8 @@ impl<'c> CardContext<'c> {
target_col,
usn,
imported_notes,
notetype_map,
remapped_templates,
remapped_decks: imported_decks,
existing_cards,
collection_delta,
@ -68,6 +75,8 @@ impl Context<'_> {
pub(super) fn import_cards_and_revlog(
&mut self,
imported_notes: &HashMap<NoteId, NoteId>,
notetype_map: &HashMap<NoteId, NotetypeId>,
remapped_templates: &HashMap<NotetypeId, TemplateMap>,
imported_decks: &HashMap<DeckId, DeckId>,
keep_filtered: bool,
) -> Result<()> {
@ -76,6 +85,8 @@ impl Context<'_> {
self.data.days_elapsed,
self.target_col,
imported_notes,
notetype_map,
remapped_templates,
imported_decks,
)?;
ctx.import_cards(mem::take(&mut self.data.cards), keep_filtered)?;
@ -122,6 +133,7 @@ impl CardContext<'_> {
fn add_card(&mut self, card: &mut Card, keep_filtered: bool) -> Result<()> {
card.usn = self.usn;
self.remap_deck_ids(card);
self.remap_template_index(card);
card.shift_collection_relative_dates(self.collection_delta);
if !keep_filtered {
card.maybe_remove_from_filtered_deck(self.scheduler_version);
@ -151,6 +163,16 @@ impl CardContext<'_> {
card.original_deck_id = *did;
}
}
fn remap_template_index(&self, card: &mut Card) {
card.template_idx = self
.notetype_map
.get(&card.note_id)
.and_then(|ntid| self.remapped_templates.get(ntid))
.and_then(|map| map.get(&card.template_idx))
.copied()
.unwrap_or(card.template_idx);
}
}
impl Card {

View File

@ -21,8 +21,11 @@ use zip::ZipArchive;
use super::super::meta::MetaExt;
use crate::collection::CollectionBuilder;
use crate::config::ConfigKey;
use crate::import_export::gather::ExchangeData;
use crate::import_export::package::ImportAnkiPackageOptions;
use crate::import_export::package::Meta;
use crate::import_export::package::UpdateCondition;
use crate::import_export::ImportProgress;
use crate::import_export::NoteLog;
use crate::media::MediaManager;
@ -30,8 +33,14 @@ use crate::prelude::*;
use crate::progress::ThrottlingProgressHandler;
use crate::search::SearchNode;
/// A map of old to new template indices for a given notetype.
type TemplateMap = std::collections::HashMap<u16, u16>;
struct Context<'a> {
target_col: &'a mut Collection,
merge_notetypes: bool,
update_notes: UpdateCondition,
update_notetypes: UpdateCondition,
media_manager: MediaManager,
archive: ZipArchive<File>,
meta: Meta,
@ -41,13 +50,21 @@ struct Context<'a> {
}
impl Collection {
pub fn import_apkg(&mut self, path: impl AsRef<Path>) -> Result<OpOutput<NoteLog>> {
pub fn import_apkg(
&mut self,
path: impl AsRef<Path>,
options: ImportAnkiPackageOptions,
) -> Result<OpOutput<NoteLog>> {
let file = open_file(path)?;
let archive = ZipArchive::new(file)?;
let progress = self.new_progress_handler();
self.transact(Op::Import, |col| {
let mut ctx = Context::new(archive, col, progress)?;
col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?;
col.set_config(BoolKey::WithScheduling, &options.with_scheduling)?;
col.set_config(ConfigKey::UpdateNotes, &options.update_notes())?;
col.set_config(ConfigKey::UpdateNotetypes, &options.update_notetypes())?;
let mut ctx = Context::new(archive, col, options, progress)?;
ctx.import()
})
}
@ -57,6 +74,7 @@ impl<'a> Context<'a> {
fn new(
mut archive: ZipArchive<File>,
target_col: &'a mut Collection,
options: ImportAnkiPackageOptions,
mut progress: ThrottlingProgressHandler<ImportProgress>,
) -> Result<Self> {
let media_manager = target_col.media()?;
@ -66,11 +84,14 @@ impl<'a> Context<'a> {
&meta,
SearchNode::WholeCollection,
&mut progress,
true,
options.with_scheduling,
)?;
let usn = target_col.usn()?;
Ok(Self {
target_col,
merge_notetypes: options.merge_notetypes,
update_notes: options.update_notes(),
update_notetypes: options.update_notetypes(),
media_manager,
archive,
meta,
@ -81,12 +102,24 @@ impl<'a> Context<'a> {
}
fn import(&mut self) -> Result<NoteLog> {
let notetypes = self
.data
.notes
.iter()
.map(|n| (n.id, n.notetype_id))
.collect();
let mut media_map = self.prepare_media()?;
let note_imports = self.import_notes_and_notetypes(&mut media_map)?;
let keep_filtered = self.data.enables_filtered_decks();
let contains_scheduling = self.data.contains_scheduling();
let imported_decks = self.import_decks_and_configs(keep_filtered, contains_scheduling)?;
self.import_cards_and_revlog(&note_imports.id_map, &imported_decks, keep_filtered)?;
self.import_cards_and_revlog(
&note_imports.id_map,
&notetypes,
&note_imports.remapped_templates,
&imported_decks,
keep_filtered,
)?;
self.copy_media(&mut media_map)?;
Ok(note_imports.log)
}

View File

@ -7,14 +7,14 @@ use std::collections::HashSet;
use std::mem;
use std::sync::Arc;
use sha1::Digest;
use sha1::Sha1;
use super::media::MediaUseMap;
use super::Context;
use super::TemplateMap;
use crate::import_export::package::media::safe_normalized_file_name;
use crate::import_export::package::UpdateCondition;
use crate::import_export::ImportProgress;
use crate::import_export::NoteLog;
use crate::notetype::ChangeNotetypeInput;
use crate::prelude::*;
use crate::progress::ThrottlingProgressHandler;
use crate::text::replace_media_refs;
@ -24,15 +24,21 @@ struct NoteContext<'a> {
usn: Usn,
normalize_notes: bool,
remapped_notetypes: HashMap<NotetypeId, NotetypeId>,
remapped_fields: HashMap<NotetypeId, Vec<Option<u32>>>,
target_guids: HashMap<String, NoteMeta>,
target_ids: HashSet<NoteId>,
target_notetypes: Vec<Arc<Notetype>>,
media_map: &'a mut MediaUseMap,
merge_notetypes: bool,
update_notes: UpdateCondition,
update_notetypes: UpdateCondition,
imports: NoteImports,
}
#[derive(Debug, Default)]
pub(super) struct NoteImports {
pub(super) id_map: HashMap<NoteId, NoteId>,
pub(super) remapped_templates: HashMap<NotetypeId, TemplateMap>,
/// All notes from the source collection as [Vec]s of their fields, and
/// grouped by import result kind.
pub(super) log: NoteLog,
@ -83,7 +89,14 @@ impl Context<'_> {
&mut self,
media_map: &mut MediaUseMap,
) -> Result<NoteImports> {
let mut ctx = NoteContext::new(self.usn, self.target_col, media_map)?;
let mut ctx = NoteContext::new(
self.usn,
self.target_col,
media_map,
self.merge_notetypes,
self.update_notes,
self.update_notetypes,
)?;
ctx.import_notetypes(mem::take(&mut self.data.notetypes))?;
ctx.import_notes(mem::take(&mut self.data.notes), &mut self.progress)?;
Ok(ctx.imports)
@ -95,26 +108,41 @@ impl<'n> NoteContext<'n> {
usn: Usn,
target_col: &'a mut Collection,
media_map: &'a mut MediaUseMap,
merge_notetypes: bool,
update_notes: UpdateCondition,
update_notetypes: UpdateCondition,
) -> Result<Self> {
let target_guids = target_col.storage.note_guid_map()?;
let normalize_notes = target_col.get_config_bool(BoolKey::NormalizeNoteText);
let target_ids = target_col.storage.get_all_note_ids()?;
let target_notetypes = target_col.get_all_notetypes()?;
Ok(Self {
target_col,
usn,
normalize_notes,
remapped_notetypes: HashMap::new(),
remapped_fields: HashMap::new(),
target_guids,
target_ids,
target_notetypes,
imports: NoteImports::default(),
merge_notetypes,
update_notes,
update_notetypes,
media_map,
})
}
fn import_notetypes(&mut self, mut notetypes: Vec<Notetype>) -> Result<()> {
for notetype in &mut notetypes {
if let Some(existing) = self.target_col.storage.get_notetype(notetype.id)? {
self.merge_or_remap_notetype(notetype, existing)?;
notetype.config.original_id.replace(notetype.id.0);
if let Some(nt) = self.get_target_notetype(notetype.id) {
let existing = nt.as_ref().clone();
if self.merge_notetypes {
self.update_or_merge_notetype(notetype, existing)?;
} else {
self.update_or_duplicate_notetype(notetype, existing)?;
}
} else {
self.add_notetype(notetype)?;
}
@ -122,21 +150,54 @@ impl<'n> NoteContext<'n> {
Ok(())
}
fn merge_or_remap_notetype(
fn get_target_notetype(&self, ntid: NotetypeId) -> Option<&Arc<Notetype>> {
self.target_notetypes.iter().find(|nt| nt.id == ntid)
}
fn update_or_duplicate_notetype(
&mut self,
incoming: &mut Notetype,
existing: Notetype,
mut existing: Notetype,
) -> Result<()> {
if incoming.schema_hash() == existing.schema_hash() {
if incoming.mtime_secs > existing.mtime_secs {
self.update_notetype(incoming, existing)?;
if !existing.equal_schema(incoming) {
if let Some(nt) = self.get_previously_duplicated_notetype(incoming) {
existing = nt;
self.remapped_notetypes.insert(incoming.id, existing.id);
incoming.id = existing.id;
} else {
return self.add_notetype_with_remapped_id(incoming);
}
} else {
self.add_notetype_with_remapped_id(incoming)?;
}
if should_update(
self.update_notetypes,
existing.mtime_secs,
incoming.mtime_secs,
) {
self.update_notetype(incoming, existing, false)?;
}
Ok(())
}
/// Try to find a notetype with matching original id and schema.
fn get_previously_duplicated_notetype(&self, original: &Notetype) -> Option<Notetype> {
self.target_notetypes
.iter()
.find(|nt| {
nt.id != original.id
&& nt.config.original_id == Some(original.id.0)
&& nt.equal_schema(original)
})
.map(|nt| nt.as_ref().clone())
}
fn should_update_notetype(&self, existing: &Notetype, incoming: &Notetype) -> bool {
match self.update_notetypes {
UpdateCondition::IfNewer => existing.mtime_secs < incoming.mtime_secs,
UpdateCondition::Always => true,
UpdateCondition::Never => false,
}
}
fn add_notetype(&mut self, notetype: &mut Notetype) -> Result<()> {
notetype.prepare_for_update(None, true)?;
self.target_col
@ -146,12 +207,101 @@ impl<'n> NoteContext<'n> {
.add_notetype_with_unique_id_undoable(notetype)
}
fn update_notetype(&mut self, notetype: &mut Notetype, original: Notetype) -> Result<()> {
notetype.usn = self.usn;
fn update_notetype(
&mut self,
notetype: &mut Notetype,
original: Notetype,
modified: bool,
) -> Result<()> {
if modified {
notetype.set_modified(self.usn);
notetype.prepare_for_update(Some(&original), true)?;
} else {
notetype.usn = self.usn;
}
self.target_col
.add_or_update_notetype_with_existing_id_inner(notetype, Some(original), self.usn, true)
}
fn update_or_merge_notetype(
&mut self,
incoming: &mut Notetype,
mut existing: Notetype,
) -> Result<()> {
let original_existing = existing.clone();
// get and merge duplicated notetypes from previous no-merge imports
let mut siblings = self.get_sibling_notetypes(existing.id);
existing.merge_all(&siblings);
incoming.merge(&existing);
existing.merge(incoming);
self.record_remapped_ords(incoming);
let new_incoming = if self.should_update_notetype(&existing, incoming) {
// ords must be existing's as they are used to remap note fields and card
// template indices
incoming.copy_ords(&existing);
incoming
} else {
&mut existing
};
self.update_notetype(new_incoming, original_existing, true)?;
self.drop_sibling_notetypes(new_incoming, &mut siblings)
}
/// Get notetypes with different id, but matching original id.
fn get_sibling_notetypes(&mut self, original_id: NotetypeId) -> Vec<Notetype> {
self.target_notetypes
.iter()
.filter(|nt| nt.id != original_id && nt.config.original_id == Some(original_id.0))
.map(|nt| nt.as_ref().clone())
.collect()
}
/// Removes the sibling notetypes, changing their notes' notetype to
/// `original`. This assumes `siblings` have already been merged into
/// `original`.
fn drop_sibling_notetypes(
&mut self,
original: &Notetype,
siblings: &mut [Notetype],
) -> Result<()> {
for nt in siblings {
nt.merge(original);
let note_ids = self.target_col.search_notes_unordered(nt.id)?;
self.target_col
.change_notetype_of_notes_inner(ChangeNotetypeInput {
current_schema: self.target_col_schema_change()?,
note_ids,
old_notetype_name: nt.name.clone(),
old_notetype_id: nt.id,
new_notetype_id: original.id,
new_fields: nt.field_ords_vec(),
new_templates: Some(nt.template_ords_vec()),
})?;
self.target_col.remove_notetype_inner(nt.id)?;
}
Ok(())
}
fn target_col_schema_change(&self) -> Result<TimestampMillis> {
self.target_col
.storage
.get_collection_timestamps()
.map(|ts| ts.schema_change)
}
fn record_remapped_ords(&mut self, incoming: &Notetype) {
self.remapped_fields
.insert(incoming.id, incoming.field_ords().collect());
self.imports.remapped_templates.insert(
incoming.id,
incoming
.template_ords()
.enumerate()
.filter_map(|(new, old)| old.map(|ord| (ord as u16, new as u16)))
.collect(),
);
}
fn add_notetype_with_remapped_id(&mut self, notetype: &mut Notetype) -> Result<()> {
let old_id = mem::take(&mut notetype.id);
notetype.usn = self.usn;
@ -170,26 +320,10 @@ impl<'n> NoteContext<'n> {
self.imports.log.found_notes = notes.len() as u32;
for mut note in notes {
incrementor.increment()?;
let remapped_notetype_id = self.remapped_notetypes.get(&note.notetype_id);
self.remap_notetype_and_fields(&mut note);
if let Some(existing_note) = self.target_guids.get(&note.guid) {
if existing_note.mtime < note.mtime {
if existing_note.notetype_id != note.notetype_id
|| remapped_notetype_id.is_some()
{
// Existing GUID with different notetype id, or changed notetype schema
self.imports.log_conflicting(note);
} else {
self.update_note(note, existing_note.id)?;
}
} else {
self.imports.log_duplicate(note, existing_note.id);
}
self.maybe_update_existing_note(*existing_note, note)?;
} else {
if let Some(remapped_ntid) = remapped_notetype_id {
// Notetypes have diverged, but this is a new note, so we can import
// with a new notetype id.
note.notetype_id = *remapped_ntid;
}
self.add_note(note)?;
}
}
@ -197,6 +331,29 @@ impl<'n> NoteContext<'n> {
Ok(())
}
fn remap_notetype_and_fields(&mut self, note: &mut Note) {
if let Some(new_ords) = self.remapped_fields.get(&note.notetype_id) {
note.reorder_fields(new_ords);
}
if let Some(remapped_ntid) = self.remapped_notetypes.get(&note.notetype_id) {
note.notetype_id = *remapped_ntid;
}
}
fn maybe_update_existing_note(&mut self, existing: NoteMeta, incoming: Note) -> Result<()> {
if incoming.notetype_id != existing.notetype_id {
// notetype of existing note has changed, or notetype of incoming note has been
// remapped due to a schema conflict
self.imports.log_conflicting(incoming);
} else if should_update(self.update_notes, existing.mtime, incoming.mtime) {
self.update_note(incoming, existing.id)?;
} else {
// TODO: might still want to update merged in fields
self.imports.log_duplicate(incoming, existing.id);
}
Ok(())
}
fn add_note(&mut self, mut note: Note) -> Result<()> {
self.munge_media(&mut note)?;
self.target_col.canonify_note_tags(&mut note, self.usn)?;
@ -274,23 +431,74 @@ impl<'n> NoteContext<'n> {
}
}
fn should_update(
cond: UpdateCondition,
existing_mtime: TimestampSecs,
incoming_mtime: TimestampSecs,
) -> bool {
match cond {
UpdateCondition::IfNewer => existing_mtime < incoming_mtime,
UpdateCondition::Always => existing_mtime != incoming_mtime,
UpdateCondition::Never => false,
}
}
impl Notetype {
fn schema_hash(&self) -> Sha1Hash {
let mut hasher = Sha1::new();
for field in &self.fields {
hasher.update(field.name.as_bytes());
pub(crate) fn field_ords(&self) -> impl Iterator<Item = Option<u32>> + '_ {
self.fields.iter().map(|f| f.ord)
}
pub(crate) fn template_ords(&self) -> impl Iterator<Item = Option<u32>> + '_ {
self.templates.iter().map(|t| t.ord)
}
fn field_ords_vec(&self) -> Vec<Option<usize>> {
self.field_ords()
.map(|opt| opt.map(|u| u as usize))
.collect()
}
fn template_ords_vec(&self) -> Vec<Option<usize>> {
self.template_ords()
.map(|opt| opt.map(|u| u as usize))
.collect()
}
fn equal_schema(&self, other: &Self) -> bool {
self.fields.len() == other.fields.len()
&& self.templates.len() == other.templates.len()
&& self
.fields
.iter()
.zip(other.fields.iter())
.all(|(f1, f2)| f1.is_match(f2))
&& self
.templates
.iter()
.zip(other.templates.iter())
.all(|(t1, t2)| t1.is_match(t2))
}
fn copy_ords(&mut self, other: &Self) {
for (field, other_ord) in self.fields.iter_mut().zip(other.field_ords()) {
field.ord = other_ord;
}
for template in &self.templates {
hasher.update(template.name.as_bytes());
for (template, other_ord) in self.templates.iter_mut().zip(other.template_ords()) {
template.ord = other_ord;
}
hasher.finalize().into()
}
}
#[cfg(test)]
mod test {
use anki_proto::import_export::ImportAnkiPackageOptions;
use tempfile::TempDir;
use super::*;
use crate::collection::CollectionBuilder;
use crate::import_export::package::media::SafeMediaEntry;
use crate::notetype::CardTemplate;
use crate::notetype::NoteField;
/// Import [Note] into [Collection], optionally taking a [MediaUseMap],
/// or a [Notetype] remapping.
@ -298,14 +506,30 @@ mod test {
($col:expr, $note:expr, $old_notetype:expr => $new_notetype:expr) => {{
let mut media_map = MediaUseMap::default();
let mut progress = $col.new_progress_handler();
let mut ctx = NoteContext::new(Usn(1), &mut $col, &mut media_map).unwrap();
let mut ctx = NoteContext::new(
Usn(1),
&mut $col,
&mut media_map,
false,
UpdateCondition::IfNewer,
UpdateCondition::IfNewer,
)
.unwrap();
ctx.remapped_notetypes.insert($old_notetype, $new_notetype);
ctx.import_notes(vec![$note], &mut progress).unwrap();
ctx.imports.log
}};
($col:expr, $note:expr, $media_map:expr) => {{
let mut progress = $col.new_progress_handler();
let mut ctx = NoteContext::new(Usn(1), &mut $col, &mut $media_map).unwrap();
let mut ctx = NoteContext::new(
Usn(1),
&mut $col,
&mut $media_map,
false,
UpdateCondition::IfNewer,
UpdateCondition::IfNewer,
)
.unwrap();
ctx.import_notes(vec![$note], &mut progress).unwrap();
ctx.imports.log
}};
@ -327,6 +551,38 @@ mod test {
};
}
struct Remappings {
remapped_notetypes: HashMap<NotetypeId, NotetypeId>,
remapped_fields: HashMap<NotetypeId, Vec<Option<u32>>>,
remapped_templates: HashMap<NotetypeId, TemplateMap>,
}
/// Imports the notetype into the collection, and returns its remapped id if
/// any.
macro_rules! import_notetype {
($col:expr, $notetype:expr) => {{
import_notetype!($col, $notetype, merge = false)
}};
($col:expr, $notetype:expr, merge = $merge:expr) => {{
let mut media_map = MediaUseMap::default();
let mut ctx = NoteContext::new(
Usn(1),
$col,
&mut media_map,
$merge,
UpdateCondition::IfNewer,
UpdateCondition::IfNewer,
)
.unwrap();
ctx.import_notetypes(vec![$notetype]).unwrap();
Remappings {
remapped_notetypes: ctx.remapped_notetypes,
remapped_fields: ctx.remapped_fields,
remapped_templates: ctx.imports.remapped_templates,
}
}};
}
impl Collection {
fn note_id_for_guid(&self, guid: &str) -> NoteId {
self.storage
@ -336,6 +592,16 @@ mod test {
}
}
impl Notetype {
pub(crate) fn field_names(&self) -> impl Iterator<Item = &String> {
self.fields.iter().map(|f| &f.name)
}
pub(crate) fn template_names(&self) -> impl Iterator<Item = &String> {
self.templates.iter().map(|t| &t.name)
}
}
#[test]
fn should_add_note_with_new_id_if_guid_is_unique_and_id_is_not() {
let mut col = Collection::new();
@ -403,11 +669,10 @@ mod test {
let mut col = Collection::new();
let basic_ntid = col.get_notetype_by_name("basic").unwrap().unwrap().id;
let mut note = NoteAdder::basic(&mut col).add(&mut col);
note.notetype_id.0 = 123;
note.mtime.0 += 1;
note.fields_mut()[0] = "updated".to_string();
let mut log = import_note!(col, note, NotetypeId(123) => basic_ntid);
let mut log = import_note!(col, note, basic_ntid => NotetypeId(123));
assert_eq!(col.get_all_notes()[0].fields()[0], "");
assert_note_logged!(log, conflicting, &["updated", ""]);
}
@ -426,4 +691,205 @@ mod test {
assert_eq!(col.get_all_notes()[0].fields()[0], "<img src='bar.jpg'>");
assert_note_logged!(log, new, &[" bar.jpg ", ""]);
}
#[test]
fn should_import_new_notetype() {
let mut col = Collection::new();
let mut new_basic = crate::notetype::stock::basic(&col.tr);
new_basic.id.0 = 123;
import_notetype!(&mut col, new_basic);
assert!(col.storage.get_notetype(NotetypeId(123)).unwrap().is_some());
}
#[test]
fn should_update_existing_notetype_with_older_mtime_and_matching_schema() {
let mut col = Collection::new();
let mut basic = col.basic_notetype();
basic.mtime_secs.0 += 1;
basic.name = String::from("new");
import_notetype!(&mut col, basic);
assert!(col.get_notetype_by_name("new").unwrap().is_some());
}
#[test]
fn should_not_update_existing_notetype_with_newer_mtime_and_matching_schema() {
let mut col = Collection::new();
let mut basic = col.basic_notetype();
basic.mtime_secs.0 -= 1;
basic.name = String::from("new");
import_notetype!(&mut col, basic);
assert!(col.get_notetype_by_name("new").unwrap().is_none());
}
#[test]
fn should_rename_field_with_matching_id_without_schema_change() {
let mut col = Collection::new();
let mut to_import = col.basic_notetype();
to_import.fields[0].name = String::from("renamed");
to_import.mtime_secs.0 += 1;
import_notetype!(&mut col, to_import);
assert_eq!(col.basic_notetype().fields[0].name, "renamed");
}
#[test]
fn should_add_remapped_notetype_if_schema_has_changed_and_reuse_it_subsequently() {
let mut col = Collection::new();
let mut to_import = col.basic_notetype();
to_import.fields[0].name = String::from("new field");
// clear id or schemas would still match
to_import.fields[0].config.id.take();
// schema mismatch => notetype should be imported with new id
let out = import_notetype!(&mut col, to_import.clone());
let remapped_id = *out.remapped_notetypes.values().next().unwrap();
assert_eq!(col.basic_notetype().fields[0].name, "Front");
let remapped = col.storage.get_notetype(remapped_id).unwrap().unwrap();
assert_eq!(remapped.fields[0].name, "new field");
// notetype with matching schema and original id exists => should be reused
to_import.name = String::from("new name");
to_import.mtime_secs.0 = remapped.mtime_secs.0 + 1;
let out_2 = import_notetype!(&mut col, to_import);
let remapped_id_2 = *out_2.remapped_notetypes.values().next().unwrap();
assert_eq!(remapped_id, remapped_id_2);
let updated = col.storage.get_notetype(remapped_id).unwrap().unwrap();
assert_eq!(updated.name, "new name");
}
#[test]
fn should_merge_notetype_fields() {
let mut col = Collection::new();
let mut to_import = col.basic_notetype();
to_import.mtime_secs.0 += 1;
to_import.fields.remove(0);
to_import.fields[0].name = String::from("renamed");
to_import.fields[0].ord.replace(0);
to_import.fields.push(NoteField::new("new"));
to_import.fields[1].ord.replace(1);
let fields = import_notetype!(&mut col, to_import.clone(), merge = true).remapped_fields;
// Front field is preserved and new field added
assert!(col
.basic_notetype()
.field_names()
.eq(["Front", "renamed", "new"]));
// extra field must be inserted into incoming notes
assert_eq!(
fields.get(&to_import.id).unwrap(),
&[None, Some(0), Some(1)]
);
}
#[test]
fn should_merge_notetype_templates() {
let mut col = Collection::new();
let mut to_import = col.basic_rev_notetype();
to_import.mtime_secs.0 += 1;
to_import.templates.remove(0);
to_import.templates[0].name = String::from("renamed");
to_import.templates[0].ord.replace(0);
to_import.templates.push(CardTemplate::new("new", "", ""));
to_import.templates[1].ord.replace(1);
let templates =
import_notetype!(&mut col, to_import.clone(), merge = true).remapped_templates;
// Card 1 is preserved and new template added
assert!(col
.basic_rev_notetype()
.template_names()
.eq(["Card 1", "renamed", "new"]));
// templates must be shifted accordingly
let map = templates.get(&to_import.id).unwrap();
assert_eq!(map.get(&0), Some(&1));
assert_eq!(map.get(&1), Some(&2));
}
#[test]
fn should_merge_notetype_duplicates_from_previous_imports() {
let mut col = Collection::new();
let mut incoming = col.basic_notetype();
incoming.fields.push(NoteField::new("new incoming"));
// simulate a notetype duplicated during previous import
let mut remapped = col.basic_notetype();
remapped.config.original_id.replace(incoming.id.0);
// ... which was modified and has notes
remapped.fields.push(NoteField::new("new remapped"));
remapped.id.0 = 0;
col.add_notetype_inner(&mut remapped, Usn(0), true).unwrap();
let mut note = Note::new(&remapped);
*note.fields_mut() = vec![
String::from("front"),
String::from("back"),
String::from("new"),
];
col.add_note(&mut note, DeckId(1)).unwrap();
let ntid = incoming.id;
import_notetype!(&mut col, incoming, merge = true);
// both notetypes should have been merged into it
assert!(col.get_notetype(ntid).unwrap().unwrap().field_names().eq([
"Front",
"Back",
"new remapped",
"new incoming",
]));
assert!(col.get_all_notes()[0]
.fields()
.iter()
.eq(["front", "back", "new", ""]))
}
#[test]
fn reimport_with_merge_enabled_should_handle_duplicates() -> Result<()> {
// import from src to dst
let mut src = Collection::new();
NoteAdder::basic(&mut src)
.fields(&["foo", "bar"])
.add(&mut src);
let temp_dir = TempDir::new()?;
let path = temp_dir.path().join("foo.apkg");
src.export_apkg(&path, "", false, false, false, None)?;
let mut dst = CollectionBuilder::new(temp_dir.path().join("dst.anki2"))
.with_desktop_media_paths()
.build()?;
dst.import_apkg(&path, ImportAnkiPackageOptions::default())?;
// add a field to src
let mut nt = src.basic_notetype();
nt.fields.push(NoteField::new("new incoming"));
src.update_notetype(&mut nt, false)?;
// importing again with merge enabled will fail, and add an empty notetype
assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 6);
src.export_apkg(&path, "", false, false, false, None)?;
assert_eq!(
dst.import_apkg(&path, ImportAnkiPackageOptions::default())?
.output
.conflicting
.len(),
1
);
assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7);
// if enabling merge, it should succeed and remove the empty notetype
src.export_apkg(&path, "", false, false, false, None)?;
assert_eq!(
dst.import_apkg(
&path,
ImportAnkiPackageOptions {
merge_notetypes: true,
..Default::default()
}
)?
.output
.conflicting
.len(),
0
);
assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 6);
Ok(())
}
}

View File

@ -8,6 +8,7 @@ use std::fs::File;
use std::io::Write;
use anki_io::read_file;
use anki_proto::import_export::ImportAnkiPackageOptions;
use crate::media::files::sha1_of_data;
use crate::media::MediaManager;
@ -50,7 +51,9 @@ fn roundtrip_inner(legacy: bool) {
None,
)
.unwrap();
target_col.import_apkg(&apkg_path).unwrap();
target_col
.import_apkg(&apkg_path, ImportAnkiPackageOptions::default())
.unwrap();
target_col.assert_decks();
target_col.assert_notetype(&notetype);

View File

@ -7,6 +7,8 @@ mod media;
mod meta;
use anki_proto::import_export::media_entries::MediaEntry;
pub use anki_proto::import_export::ImportAnkiPackageOptions;
pub use anki_proto::import_export::ImportAnkiPackageUpdateCondition as UpdateCondition;
use anki_proto::import_export::MediaEntries;
pub(crate) use apkg::NoteMeta;
pub(crate) use colpkg::export::export_colpkg_from_data;

View File

@ -4,23 +4,33 @@ use anki_proto::generic;
use anki_proto::import_export::import_response::Log as NoteLog;
use anki_proto::import_export::ExportLimit;
use crate::collection::Collection;
use crate::error;
use crate::ops::OpOutput;
use crate::prelude::*;
use crate::search::SearchNode;
impl crate::services::ImportExportService for Collection {
fn import_anki_package(
&mut self,
input: anki_proto::import_export::ImportAnkiPackageRequest,
) -> error::Result<anki_proto::import_export::ImportResponse> {
self.import_apkg(&input.package_path).map(Into::into)
) -> Result<anki_proto::import_export::ImportResponse> {
self.import_apkg(&input.package_path, input.options.unwrap_or_default())
.map(Into::into)
}
fn get_import_anki_package_presets(
&mut self,
) -> Result<anki_proto::import_export::ImportAnkiPackageOptions> {
Ok(anki_proto::import_export::ImportAnkiPackageOptions {
merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes),
with_scheduling: self.get_config_bool(BoolKey::WithScheduling),
update_notes: self.get_update_notes() as i32,
update_notetypes: self.get_update_notetypes() as i32,
})
}
fn export_anki_package(
&mut self,
input: anki_proto::import_export::ExportAnkiPackageRequest,
) -> error::Result<generic::UInt32> {
) -> Result<generic::UInt32> {
self.export_apkg(
&input.out_path,
SearchNode::from(input.limit.unwrap_or_default()),
@ -35,7 +45,7 @@ impl crate::services::ImportExportService for Collection {
fn get_csv_metadata(
&mut self,
input: anki_proto::import_export::CsvMetadataRequest,
) -> error::Result<anki_proto::import_export::CsvMetadata> {
) -> Result<anki_proto::import_export::CsvMetadata> {
let delimiter = input.delimiter.is_some().then(|| input.delimiter());
self.get_csv_metadata(
@ -50,7 +60,7 @@ impl crate::services::ImportExportService for Collection {
fn import_csv(
&mut self,
input: anki_proto::import_export::ImportCsvRequest,
) -> error::Result<anki_proto::import_export::ImportResponse> {
) -> Result<anki_proto::import_export::ImportResponse> {
self.import_csv(&input.path, input.metadata.unwrap_or_default())
.map(Into::into)
}
@ -58,14 +68,14 @@ impl crate::services::ImportExportService for Collection {
fn export_note_csv(
&mut self,
input: anki_proto::import_export::ExportNoteCsvRequest,
) -> error::Result<generic::UInt32> {
) -> Result<generic::UInt32> {
self.export_note_csv(input).map(Into::into)
}
fn export_card_csv(
&mut self,
input: anki_proto::import_export::ExportCardCsvRequest,
) -> error::Result<generic::UInt32> {
) -> Result<generic::UInt32> {
self.export_card_csv(
&input.out_path,
SearchNode::from(input.limit.unwrap_or_default()),
@ -77,14 +87,14 @@ impl crate::services::ImportExportService for Collection {
fn import_json_file(
&mut self,
input: generic::String,
) -> error::Result<anki_proto::import_export::ImportResponse> {
) -> Result<anki_proto::import_export::ImportResponse> {
self.import_json_file(&input.val).map(Into::into)
}
fn import_json_string(
&mut self,
input: generic::String,
) -> error::Result<anki_proto::import_export::ImportResponse> {
) -> Result<anki_proto::import_export::ImportResponse> {
self.import_json_string(&input.val).map(Into::into)
}
}

View File

@ -350,9 +350,12 @@ impl MediaChecker<'_> {
for nid in nids {
self.increment_progress()?;
let mut note = self.col.storage.get_note(nid)?.unwrap();
let nt = notetypes.get(&note.notetype_id).ok_or_else(|| {
AnkiError::db_error("missing note type", DbErrorKind::MissingEntity)
})?;
let nt = notetypes
.iter()
.find(|nt| nt.id == note.notetype_id)
.ok_or_else(|| {
AnkiError::db_error("missing note type", DbErrorKind::MissingEntity)
})?;
let mut tracker = |fname| {
referenced_files
.entry(fname)

View File

@ -31,7 +31,7 @@ lazy_static! {
impl Collection {
pub fn report_media_field_referencing_templates(&mut self, buf: &mut String) -> Result<()> {
let notetypes = self.get_all_notetypes()?;
let templates = media_field_referencing_templates(notetypes.values().map(Deref::deref));
let templates = media_field_referencing_templates(notetypes.iter().map(Deref::deref));
write_template_report(buf, &templates, &self.tr);
Ok(())
}

View File

@ -77,7 +77,7 @@ impl Collection {
let mut buf = String::new();
for (ntid, notes) in empty {
if !notes.is_empty() {
let nt = nts.get(ntid).unwrap();
let nt = nts.iter().find(|nt| nt.id == *ntid).unwrap();
write!(
buf,
"<div><b>{}</b></div><ol>",

View File

@ -38,6 +38,7 @@ impl NoteField {
ord: None,
name: name.into(),
config: NoteFieldConfig {
id: Some(rand::random()),
sticky: false,
rtl: false,
plain_text: false,

170
rslib/src/notetype/merge.rs Normal file
View File

@ -0,0 +1,170 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::CardTemplate;
use crate::notetype::NoteField;
use crate::prelude::*;
impl Notetype {
/// Inserts not yet existing fields ands templates from `other`.
pub(crate) fn merge(&mut self, other: &Self) {
self.merge_fields(other);
self.merge_templates(other);
}
pub(crate) fn merge_all<'a>(&mut self, others: impl IntoIterator<Item = &'a Self>) {
for other in others {
self.merge(other);
}
}
/// Inserts not yet existing fields from `other`.
fn merge_fields(&mut self, other: &Self) {
for (index, field) in other.fields.iter().enumerate() {
match self.find_field(field) {
Some(i) if i == index => (),
Some(i) => self.fields.swap(i, index),
None => {
let mut missing = field.clone();
missing.ord.take();
self.fields.insert(index, missing);
}
}
}
}
fn find_field(&self, like: &NoteField) -> Option<usize> {
self.fields
.iter()
.enumerate()
.find_map(|(i, f)| f.is_match(like).then_some(i))
}
/// Inserts not yet existing templates from `other`.
fn merge_templates(&mut self, other: &Self) {
for (index, template) in other.templates.iter().enumerate() {
match self.find_template(template) {
Some(i) if i == index => (),
Some(i) => self.templates.swap(i, index),
None => {
let mut missing = template.clone();
missing.ord.take();
self.templates.insert(index, missing);
}
}
}
}
fn find_template(&self, like: &CardTemplate) -> Option<usize> {
self.templates
.iter()
.enumerate()
.find_map(|(i, t)| t.is_match(like).then_some(i))
}
}
impl NoteField {
/// True if both ids are identical, but not [None], or at least one id is
/// [None] and the names are identical.
pub(crate) fn is_match(&self, other: &Self) -> bool {
if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) {
id == other_id
} else {
self.name == other.name
}
}
}
impl CardTemplate {
/// True if both ids are identical, but not [None], or at least one id is
/// [None] and the names are identical.
pub(crate) fn is_match(&self, other: &Self) -> bool {
if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) {
id == other_id
} else {
self.name == other.name
}
}
}
#[cfg(test)]
mod test {
use itertools::assert_equal;
use super::*;
use crate::notetype::stock;
impl Notetype {
fn field_ids(&self) -> impl Iterator<Item = Option<i64>> + '_ {
self.fields.iter().map(|field| field.config.id)
}
fn template_ids(&self) -> impl Iterator<Item = Option<i64>> + '_ {
self.templates.iter().map(|template| template.config.id)
}
}
#[test]
fn merge_new_fields() {
let mut basic = stock::basic(&I18n::template_only());
let mut other = basic.clone();
other.add_field("with id");
other.add_field("without id");
other.fields[3].config.id.take();
basic.merge(&other);
assert_equal(basic.field_ids(), other.field_ids());
assert_equal(basic.field_names(), other.field_names());
}
#[test]
fn skip_merging_field_with_existing_id() {
let mut basic = stock::basic(&I18n::template_only());
let mut other = basic.clone();
other.fields[1].name = String::from("renamed");
basic.merge(&other);
assert_equal(basic.field_ids(), other.field_ids());
assert_equal(basic.field_names(), ["Front", "Back"].iter());
}
#[test]
fn align_field_order() {
let mut basic = stock::basic(&I18n::template_only());
let mut other = basic.clone();
other.fields.swap(0, 1);
basic.merge(&other);
assert_equal(basic.field_ids(), other.field_ids());
assert_equal(basic.field_names(), other.field_names());
}
#[test]
fn merge_new_templates() {
let mut basic = stock::basic(&I18n::template_only());
let mut other = basic.clone();
other.add_template("with id", "", "");
other.add_template("without id", "", "");
other.templates[2].config.id.take();
basic.merge(&other);
assert_equal(basic.template_ids(), other.template_ids());
assert_equal(basic.template_names(), other.template_names());
}
#[test]
fn skip_merging_template_with_existing_id() {
let mut basic = stock::basic(&I18n::template_only());
let mut other = basic.clone();
other.templates[0].name = String::from("renamed");
basic.merge(&other);
assert_equal(basic.template_ids(), other.template_ids());
assert_equal(basic.template_names(), std::iter::once("Card 1"));
}
#[test]
fn align_template_order() {
let mut basic_rev = stock::basic_forward_reverse(&I18n::template_only());
let mut other = basic_rev.clone();
other.templates.swap(0, 1);
basic_rev.merge(&other);
assert_equal(basic_rev.template_ids(), other.template_ids());
assert_equal(basic_rev.template_names(), other.template_names());
}
}

View File

@ -5,6 +5,7 @@ mod cardgen;
mod checks;
mod emptycards;
mod fields;
mod merge;
mod notetypechange;
mod render;
mod restore;
@ -213,16 +214,11 @@ impl Collection {
}
}
pub fn get_all_notetypes(&mut self) -> Result<HashMap<NotetypeId, Arc<Notetype>>> {
pub fn get_all_notetypes(&mut self) -> Result<Vec<Arc<Notetype>>> {
self.storage
.get_all_notetype_names()?
.get_all_notetype_ids()?
.into_iter()
.map(|(ntid, _)| {
self.get_notetype(ntid)
.transpose()
.unwrap()
.map(|nt| (ntid, nt))
})
.filter_map(|ntid| self.get_notetype(ntid).transpose())
.collect()
}
@ -718,7 +714,7 @@ impl Collection {
Ok(())
}
fn remove_notetype_inner(&mut self, ntid: NotetypeId) -> Result<()> {
pub(crate) fn remove_notetype_inner(&mut self, ntid: NotetypeId) -> Result<()> {
let notetype = if let Some(notetype) = self.storage.get_notetype(ntid)? {
notetype
} else {

View File

@ -209,7 +209,10 @@ fn default_field_map(current_notetype: &Notetype, new_notetype: &Notetype) -> Ve
}
impl Collection {
fn change_notetype_of_notes_inner(&mut self, input: ChangeNotetypeInput) -> Result<()> {
pub(crate) fn change_notetype_of_notes_inner(
&mut self,
input: ChangeNotetypeInput,
) -> Result<()> {
require!(
input.current_schema == self.storage.get_collection_timestamps()?.schema_change,
"schema changed"

View File

@ -64,6 +64,8 @@ pub struct NotetypeSchema11 {
pub(crate) req: CardRequirementsSchema11,
#[serde(default, skip_serializing_if = "is_default")]
pub(crate) original_stock_kind: i32,
#[serde(default, skip_serializing_if = "is_default")]
pub(crate) original_id: Option<i64>,
#[serde(flatten)]
pub(crate) other: HashMap<String, Value>,
}
@ -109,6 +111,7 @@ impl From<NotetypeSchema11> for Notetype {
latex_svg: nt.latexsvg,
reqs: nt.req.0.into_iter().map(Into::into).collect(),
original_stock_kind: nt.original_stock_kind,
original_id: nt.original_id,
other: other_to_bytes(&nt.other),
},
fields: nt.flds.into_iter().map(Into::into).collect(),
@ -172,6 +175,7 @@ impl From<Notetype> for NotetypeSchema11 {
latexsvg: c.latex_svg,
req: CardRequirementsSchema11(c.reqs.into_iter().map(Into::into).collect()),
original_stock_kind: c.original_stock_kind,
original_id: c.original_id,
other: parse_other_fields(&c.other, &RESERVED_NOTETYPE_KEYS),
}
}
@ -249,6 +253,9 @@ pub struct NoteFieldSchema11 {
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) exclude_from_search: bool,
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) id: Option<i64>,
#[serde(flatten)]
pub(crate) other: HashMap<String, Value>,
}
@ -266,6 +273,7 @@ impl Default for NoteFieldSchema11 {
description: String::new(),
collapsed: false,
exclude_from_search: false,
id: None,
other: Default::default(),
}
}
@ -285,6 +293,7 @@ impl From<NoteFieldSchema11> for NoteField {
description: f.description,
collapsed: f.collapsed,
exclude_from_search: f.exclude_from_search,
id: f.id,
other: other_to_bytes(&f.other),
},
}
@ -305,6 +314,7 @@ impl From<NoteField> for NoteFieldSchema11 {
description: conf.description,
collapsed: conf.collapsed,
exclude_from_search: conf.exclude_from_search,
id: conf.id,
other: parse_other_fields(&conf.other, &RESERVED_FIELD_KEYS),
}
}
@ -321,6 +331,7 @@ static RESERVED_FIELD_KEYS: Set<&'static str> = phf_set! {
"collapsed",
"description",
"excludeFromSearch",
"id",
};
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
@ -340,6 +351,8 @@ pub struct CardTemplateSchema11 {
pub(crate) bfont: String,
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) bsize: u8,
#[serde(default, deserialize_with = "default_on_invalid")]
pub(crate) id: Option<i64>,
#[serde(flatten)]
pub(crate) other: HashMap<String, Value>,
}
@ -359,6 +372,7 @@ impl From<CardTemplateSchema11> for CardTemplate {
target_deck_id: t.did.unwrap_or(DeckId(0)).0,
browser_font_name: t.bfont,
browser_font_size: t.bsize as u32,
id: t.id,
other: other_to_bytes(&t.other),
},
}
@ -382,6 +396,7 @@ impl From<CardTemplate> for CardTemplateSchema11 {
},
bfont: conf.browser_font_name,
bsize: conf.browser_font_size as u8,
id: conf.id,
other: parse_other_fields(&conf.other, &RESERVED_TEMPLATE_KEYS),
}
}
@ -397,6 +412,7 @@ static RESERVED_TEMPLATE_KEYS: Set<&'static str> = phf_set! {
"bqfmt",
"bfont",
"bsize",
"id",
};
#[cfg(test)]

View File

@ -4,6 +4,7 @@
//! Updates to notes/cards when the structure of a notetype is changed.
use std::collections::HashMap;
use std::mem;
use super::CardGenContext;
use super::CardTemplate;
@ -102,20 +103,7 @@ impl Collection {
for nid in nids {
let mut note = self.storage.get_note(nid)?.unwrap();
let original = note.clone();
*note.fields_mut() = ords
.iter()
.map(|f| {
if let Some(idx) = f {
note.fields()
.get(*idx as usize)
.map(AsRef::as_ref)
.unwrap_or("")
} else {
""
}
})
.map(Into::into)
.collect();
note.reorder_fields(&ords);
self.update_note_inner_without_cards(
&mut note,
&original,
@ -192,6 +180,19 @@ impl Notetype {
}
}
impl Note {
pub(crate) fn reorder_fields(&mut self, new_ords: &[Option<u32>]) {
*self.fields_mut() = new_ords
.iter()
.map(|ord| {
ord.and_then(|idx| self.fields_mut().get_mut(idx as usize))
.map(mem::take)
.unwrap_or_default()
})
.collect();
}
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -86,6 +86,7 @@ impl CardTemplate {
mtime_secs: TimestampSecs(0),
usn: Usn(0),
config: CardTemplateConfig {
id: Some(rand::random()),
q_format: qfmt.into(),
a_format: afmt.into(),
q_format_browser: "".into(),

View File

@ -608,11 +608,10 @@ impl SqlWriter<'_> {
&mut self,
field_name: &str,
) -> Result<Vec<FieldQualifiedSearchContext>> {
let notetypes = self.col.get_all_notetypes()?;
let matches_glob = glob_matcher(field_name);
let mut field_map = vec![];
for nt in notetypes.values() {
for nt in self.col.get_all_notetypes()? {
let matched_fields = nt
.fields
.iter()
@ -639,11 +638,10 @@ impl SqlWriter<'_> {
&mut self,
field_name: &str,
) -> Result<Vec<(NotetypeId, Vec<u32>)>> {
let notetypes = self.col.get_all_notetypes()?;
let matches_glob = glob_matcher(field_name);
let mut field_map = vec![];
for nt in notetypes.values() {
for nt in self.col.get_all_notetypes()? {
let matched_fields: Vec<u32> = nt
.fields
.iter()
@ -663,10 +661,9 @@ impl SqlWriter<'_> {
}
fn included_fields_by_notetype(&mut self) -> Result<Option<Vec<UnqualifiedSearchContext>>> {
let notetypes = self.col.get_all_notetypes()?;
let mut any_excluded = false;
let mut field_map = vec![];
for nt in notetypes.values() {
for nt in self.col.get_all_notetypes()? {
let mut sortf_excluded = false;
let matched_fields = nt
.fields
@ -699,10 +696,9 @@ impl SqlWriter<'_> {
fn included_fields_for_unqualified_regex(
&mut self,
) -> Result<Option<Vec<UnqualifiedRegexSearchContext>>> {
let notetypes = self.col.get_all_notetypes()?;
let mut any_excluded = false;
let mut field_map = vec![];
for nt in notetypes.values() {
for nt in self.col.get_all_notetypes()? {
let matched_fields: Vec<u32> = nt
.fields
.iter()

View File

@ -136,6 +136,13 @@ impl SqliteStorage {
.collect()
}
pub fn get_all_notetype_ids(&self) -> Result<Vec<NotetypeId>> {
self.db
.prepare_cached("SELECT id FROM notetypes")?
.query_and_then([], |row| row.get(0).map_err(Into::into))?
.collect()
}
/// Returns list of (id, name, use_count)
pub fn get_notetype_use_counts(&self) -> Result<Vec<(NotetypeId, String, u32)>> {
self.db

View File

@ -99,6 +99,20 @@ impl Collection {
self.adjust_remaining_steps_in_deck(DeckId(1), Some(&config), Some(&new_config), Usn(0))
.unwrap();
}
pub(crate) fn basic_notetype(&self) -> Notetype {
let ntid = self.storage.get_notetype_id("Basic").unwrap().unwrap();
self.storage.get_notetype(ntid).unwrap().unwrap()
}
pub(crate) fn basic_rev_notetype(&self) -> Notetype {
let ntid = self
.storage
.get_notetype_id("Basic (and reversed card)")
.unwrap()
.unwrap();
self.storage.get_notetype(ntid).unwrap().unwrap()
}
}
#[derive(Debug, Default, Clone)]

View File

@ -3,8 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Select from "../components/Select.svelte";
import SelectOption from "../components/SelectOption.svelte";
import Select from "./Select.svelte";
import SelectOption from "./SelectOption.svelte";
export let options: string[] = [];
export let disabled: number[] = [];

View File

@ -3,12 +3,12 @@
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import type { Breakpoint } from "../components/types";
import Col from "./Col.svelte";
import ConfigInput from "./ConfigInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
import RevertButton from "./RevertButton.svelte";
import Row from "./Row.svelte";
import type { Breakpoint } from "./types";
export let value: number;
export let defaultValue: number;

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
-->
<script lang="ts">
export let error: Error;
</script>
<div class="message">
{error.message}
</div>
<style lang="scss">
.message {
text-align: center;
margin: 50px 0 0;
}
</style>

View File

@ -8,19 +8,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Modal from "bootstrap/js/dist/modal";
import { createEventDispatcher, getContext, onMount } from "svelte";
import Badge from "../components/Badge.svelte";
import Col from "../components/Col.svelte";
import { modalsKey } from "../components/context-keys";
import Row from "../components/Row.svelte";
import { pageTheme } from "../sveltelib/theme";
import Badge from "./Badge.svelte";
import Col from "./Col.svelte";
import { modalsKey } from "./context-keys";
import HelpSection from "./HelpSection.svelte";
import { infoCircle, manualIcon } from "./icons";
import type { DeckOption } from "./types";
import Row from "./Row.svelte";
import type { HelpItem } from "./types";
export let title: string;
export let url: string;
export let startIndex = 0;
export let helpSections: DeckOption[];
export let helpSections: HelpItem[];
export const modalKey: string = Math.random().toString(36).substring(2);
@ -117,12 +117,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:this={carouselRef}
>
<div class="carousel-inner">
{#each helpSections as section, i}
{#each helpSections as item, i}
<div
class="carousel-item"
class:active={i == startIndex}
>
<HelpSection {section} />
<HelpSection {item} />
</div>
{/each}
</div>

View File

@ -6,22 +6,22 @@
import * as tr from "@tslib/ftl";
import { renderMarkdown } from "@tslib/helpers";
import Row from "../components/Row.svelte";
import type { DeckOption } from "./types";
import Row from "./Row.svelte";
import type { HelpItem } from "./types";
export let section: DeckOption;
export let item: HelpItem;
</script>
<Row>
<h2>
{#if section.url}
{@html section.title}
{#if item.url}
{@html item.title}
{:else}
{@html section.title}
{@html item.title}
{/if}
</h2>
{#if section.help}
{@html renderMarkdown(section.help)}
{#if item.help}
{@html renderMarkdown(item.help)}
{:else}
{@html renderMarkdown(
tr.helpNoExplanation({
@ -30,14 +30,14 @@
)}
{/if}
</Row>
{#if section.url}
{#if item.url}
<hr />
<div class="chapter-redirect">
{@html renderMarkdown(
tr.helpForMoreInfo({
link: `<a href="${section.url}" title="${tr.helpOpenManualChapter({
name: section.title,
})}">${section.title}</a>`,
link: `<a href="${item.url}" title="${tr.helpOpenManualChapter({
name: item.title,
})}">${item.title}</a>`,
}),
)}
</div>

View File

@ -7,12 +7,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { cloneDeep, isEqual as isEqualLodash } from "lodash-es";
import { getContext } from "svelte";
import Badge from "../components/Badge.svelte";
import { touchDeviceKey } from "../components/context-keys";
import DropdownItem from "../components/DropdownItem.svelte";
import Popover from "../components/Popover.svelte";
import WithFloating from "../components/WithFloating.svelte";
import Badge from "./Badge.svelte";
import { touchDeviceKey } from "./context-keys";
import DropdownItem from "./DropdownItem.svelte";
import { revertIcon } from "./icons";
import Popover from "./Popover.svelte";
import WithFloating from "./WithFloating.svelte";
type T = unknown;

View File

@ -6,8 +6,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "@tslib/ftl";
import { getPlatformString } from "@tslib/shortcuts";
import LabelButton from "../components/LabelButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
import LabelButton from "./LabelButton.svelte";
import Shortcut from "./Shortcut.svelte";
export let path: string;
export let onImport: () => void;

View File

@ -3,12 +3,12 @@
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import Row from "../components/Row.svelte";
import Switch from "../components/Switch.svelte";
import Col from "./Col.svelte";
import ConfigInput from "./ConfigInput.svelte";
import Label from "./Label.svelte";
import RevertButton from "./RevertButton.svelte";
import Row from "./Row.svelte";
import Switch from "./Switch.svelte";
export let value: boolean;
export let defaultValue: boolean;

View File

@ -5,9 +5,12 @@
export { default as hsplitIcon } from "@mdi/svg/svg/arrow-split-horizontal.svg";
export { default as vsplitIcon } from "@mdi/svg/svg/arrow-split-vertical.svg";
export { default as manualIcon } from "@mdi/svg/svg/book-open-variant.svg";
export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg";
export { default as chevronLeft } from "@mdi/svg/svg/chevron-left.svg";
export { default as chevronRight } from "@mdi/svg/svg/chevron-right.svg";
export { default as chevronUp } from "@mdi/svg/svg/chevron-up.svg";
export { default as horizontalHandle } from "@mdi/svg/svg/drag-horizontal.svg";
export { default as verticalHandle } from "@mdi/svg/svg/drag-vertical.svg";
export { default as revertIcon } from "bootstrap-icons/icons/arrow-counterclockwise.svg";
export { default as infoCircle } from "bootstrap-icons/icons/info-circle.svg";

View File

@ -3,3 +3,9 @@
export type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
export type HelpItem = {
title: string;
help?: string;
url?: string;
};

View File

@ -9,16 +9,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import CardStateCustomizer from "./CardStateCustomizer.svelte";
import HelpModal from "./HelpModal.svelte";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import SwitchRow from "./SwitchRow.svelte";
import type { DeckOption } from "./types";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -64,7 +64,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
url: "https://faqs.ankiweb.net/the-2021-scheduler.html#add-ons-and-custom-scheduling",
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -9,13 +9,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SwitchRow from "./SwitchRow.svelte";
import type { DeckOption } from "./types";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -33,7 +33,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
help: tr.deckConfigAlwaysIncludeQuestionAudioTooltip(),
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -9,13 +9,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SwitchRow from "./SwitchRow.svelte";
import type { DeckOption } from "./types";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -41,7 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
help: tr.deckConfigBuryInterdayLearningTooltip() + priorityTooltip,
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -4,10 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import Row from "../components/Row.svelte";
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
import SettingTitle from "./SettingTitle.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
export let value: string;
export let title: string;

View File

@ -9,16 +9,16 @@
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import { ValueTab } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import SwitchRow from "./SwitchRow.svelte";
import TabbedValue from "./TabbedValue.svelte";
import type { DeckOption } from "./types";
import Warning from "./Warning.svelte";
export let state: DeckOptionsState;
@ -149,7 +149,7 @@
url: HelpPage.DeckOptions.newCardsday,
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -13,14 +13,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import { reviewMixChoices } from "./strings";
import type { DeckOption } from "./types";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -116,7 +116,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
help: tr.deckConfigReviewSortOrderTooltip() + currentDeck,
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -17,10 +17,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { runWithBackendProgress } from "@tslib/progress";
import TitledContainer from "components/TitledContainer.svelte";
import ConfigInput from "./ConfigInput.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import type { DeckOptionsState } from "./lib";
import RevertButton from "./RevertButton.svelte";
import SettingTitle from "./SettingTitle.svelte";
import WeightsInputRow from "./WeightsInputRow.svelte";
export let state: DeckOptionsState;

View File

@ -9,15 +9,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import StepsInputRow from "./StepsInputRow.svelte";
import type { DeckOption } from "./types";
import Warning from "./Warning.svelte";
export let state: DeckOptionsState;
@ -61,7 +61,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
url: HelpPage.Leeches.waiting,
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -10,15 +10,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import StepsInputRow from "./StepsInputRow.svelte";
import type { DeckOption } from "./types";
import Warning from "./Warning.svelte";
export let state: DeckOptionsState;
@ -76,7 +76,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
url: HelpPage.DeckOptions.insertionOrder,
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -4,10 +4,10 @@
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import Row from "../components/Row.svelte";
import SpinBox from "../components/SpinBox.svelte";
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
export let value: number;
export let defaultValue: number;

View File

@ -4,10 +4,10 @@
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import Row from "../components/Row.svelte";
import SpinBox from "../components/SpinBox.svelte";
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
export let value: number;
export let defaultValue: number;

View File

@ -4,9 +4,9 @@
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import Row from "../components/Row.svelte";
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
import StepsInput from "./StepsInput.svelte";
export let value: any;

View File

@ -9,14 +9,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import HelpModal from "./HelpModal.svelte";
import type { HelpItem } from "../components/types";
import type { DeckOptionsState } from "./lib";
import SettingTitle from "./SettingTitle.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import SwitchRow from "./SwitchRow.svelte";
import type { DeckOption } from "./types";
import Warning from "./Warning.svelte";
export let state: DeckOptionsState;
@ -40,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
help: tr.deckConfigShowAnswerTimerTooltip(),
},
};
const helpSections = Object.values(settings) as DeckOption[];
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;

View File

@ -3,8 +3,8 @@
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ConfigInput from "./ConfigInput.svelte";
import RevertButton from "./RevertButton.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import WeightsInput from "./WeightsInput.svelte";
export let value: any;

View File

@ -3,8 +3,5 @@
/// <reference types="../lib/image-import" />
export { default as manualIcon } from "@mdi/svg/svg/book-open-variant.svg";
export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg";
export { default as revertIcon } from "bootstrap-icons/icons/arrow-counterclockwise.svg";
export { default as gearIcon } from "bootstrap-icons/icons/gear.svg";
export { default as infoCircle } from "bootstrap-icons/icons/info-circle.svg";

View File

@ -27,10 +27,7 @@ const i18n = setupI18n({
export async function setupDeckOptions(did_: number): Promise<DeckOptionsPage> {
const did = BigInt(did_);
const [info] = await Promise.all([
getDeckConfigsForUpdate({ did }),
i18n,
]);
const [info] = await Promise.all([getDeckConfigsForUpdate({ did }), i18n]);
checkNightMode();
@ -48,11 +45,11 @@ export async function setupDeckOptions(did_: number): Promise<DeckOptionsPage> {
import { getDeckConfigsForUpdate } from "@tslib/backend";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import SwitchRow from "./SwitchRow.svelte";
export const components = {
TitledContainer,

View File

@ -1,8 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export type DeckOption = {
title: string;
help?: string;
url?: string;
};

View File

@ -0,0 +1,17 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let heading: string;
</script>
<h1>
{heading}
</h1>
<style lang="scss">
h1 {
padding-top: 0.5em;
}
</style>

View File

@ -0,0 +1,169 @@
<!--
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 {
ImportAnkiPackageOptions,
ImportResponse,
} from "@tslib/anki/import_export_pb";
import { importAnkiPackage } from "@tslib/backend";
import { importDone } from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal";
import BackendProgressIndicator from "components/BackendProgressIndicator.svelte";
import Container from "components/Container.svelte";
import EnumSelectorRow from "components/EnumSelectorRow.svelte";
import Row from "components/Row.svelte";
import HelpModal from "../components/HelpModal.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import StickyHeader from "../components/StickyHeader.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import ImportLogPage from "../import-log/ImportLogPage.svelte";
export let path: string;
export let options: ImportAnkiPackageOptions;
let importResponse: ImportResponse | undefined = undefined;
let importing = false;
const updateChoices = [
tr.importingUpdateIfNewer(),
tr.importingUpdateAlways(),
tr.importingUpdateNever(),
];
const settings = {
mergeNotetypes: {
title: tr.importingMergeNotetypes(),
help: tr.importingMergeNotetypesHelp(),
url: HelpPage.PackageImporting.updating,
},
updateNotes: {
title: tr.importingUpdateNotes(),
help: tr.importingUpdateNotesHelp(),
url: HelpPage.PackageImporting.updating,
},
updateNotetypes: {
title: tr.importingUpdateNotetypes(),
help: tr.importingUpdateNotetypesHelp(),
url: HelpPage.PackageImporting.updating,
},
withScheduling: {
title: tr.importingIncludeReviews(),
help: tr.importingIncludeReviewsHelp(),
url: HelpPage.PackageImporting.scheduling,
},
};
const helpSections = Object.values(settings) as HelpItem[];
let modal: Modal;
let carousel: Carousel;
async function onImport(): Promise<ImportResponse> {
const result = await importAnkiPackage({
packagePath: path,
options,
});
await importDone({});
importing = false;
return result;
}
function openHelpModal(index: number): void {
modal.show();
carousel.to(index);
}
</script>
{#if importing}
<BackendProgressIndicator task={onImport} bind:result={importResponse} />
{:else if importResponse}
<ImportLogPage response={importResponse} params={{ path }} />
{:else}
<StickyHeader {path} onImport={() => (importing = true)} />
<Container
breakpoint="sm"
--gutter-inline="0.25rem"
--gutter-block="0.75rem"
class="container-columns"
>
<Row class="d-block">
<TitledContainer title={tr.importingImportOptions()}>
<HelpModal
title={tr.importingImportOptions()}
url={HelpPage.PackageImporting.root}
slot="tooltip"
{helpSections}
on:mount={(e) => {
modal = e.detail.modal;
carousel = e.detail.carousel;
}}
/>
<SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("mergeNotetypes"),
)}
>
{settings.mergeNotetypes.title}
</SettingTitle>
</SwitchRow>
<EnumSelectorRow
bind:value={options.updateNotes}
defaultValue={0}
choices={updateChoices}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("updateNotes"))}
>
{settings.updateNotes.title}
</SettingTitle>
</EnumSelectorRow>
<EnumSelectorRow
bind:value={options.updateNotetypes}
defaultValue={0}
choices={updateChoices}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("updateNotetypes"),
)}
>
{settings.updateNotetypes.title}
</SettingTitle>
</EnumSelectorRow>
<SwitchRow bind:value={options.withScheduling} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("withScheduling"),
)}
>
{settings.withScheduling.title}
</SettingTitle>
</SwitchRow>
</TitledContainer>
</Row>
</Container>
{/if}
<style lang="scss">
:global(.row) {
// rows have negative margins by default
--bs-gutter-x: 0;
margin-bottom: 0.5rem;
}
</style>

View File

@ -0,0 +1,28 @@
@use "sass/bootstrap-dark";
@import "sass/base";
@import "bootstrap/scss/alert";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/transitions";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/carousel";
@import "sass/bootstrap-forms";
.night-mode {
@include bootstrap-dark.night-mode;
}
body {
min-height: 100vh;
width: min(100vw, 70em);
margin: 0 auto;
padding: 0 1em 1em 1em;
}
html {
height: initial;
}

View File

@ -0,0 +1,51 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./import-anki-package-base.scss";
import { getImportAnkiPackagePresets } from "@tslib/backend";
import { ModuleName, setupI18n } from "@tslib/i18n";
import { checkNightMode } from "@tslib/nightmode";
import { modalsKey } from "../components/context-keys";
import ImportAnkiPackagePage from "./ImportAnkiPackagePage.svelte";
const i18n = setupI18n({
modules: [
ModuleName.IMPORTING,
ModuleName.ACTIONS,
ModuleName.HELP,
ModuleName.DECK_CONFIG,
ModuleName.ADDING,
ModuleName.EDITING,
ModuleName.KEYBOARD,
],
});
export async function setupImportAnkiPackagePage(
path: string,
): Promise<ImportAnkiPackagePage> {
const [_, options] = await Promise.all([
i18n,
getImportAnkiPackagePresets({}),
]);
const context = new Map();
context.set(modalsKey, new Map());
checkNightMode();
return new ImportAnkiPackagePage({
target: document.body,
props: {
path,
options,
},
context,
});
}
// eg http://localhost:40000/_anki/pages/import-anki-package.html#test-/home/dae/foo.apkg
if (window.location.hash.startsWith("#test-")) {
const apkgPath = window.location.hash.replace("#test-", "");
setupImportAnkiPackagePage(apkgPath);
}

View File

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

View File

@ -21,6 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Container from "../components/Container.svelte";
import Row from "../components/Row.svelte";
import Spacer from "../components/Spacer.svelte";
import StickyHeader from "../components/StickyHeader.svelte";
import ImportLogPage from "../import-log/ImportLogPage.svelte";
import DeckDupeCheckSwitch from "./DeckDupeCheckSwitch.svelte";
import DeckSelector from "./DeckSelector.svelte";
@ -38,7 +39,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} from "./lib";
import NotetypeSelector from "./NotetypeSelector.svelte";
import Preview from "./Preview.svelte";
import StickyHeader from "./StickyHeader.svelte";
import Tags from "./Tags.svelte";
export let path: string;

View File

@ -7,6 +7,7 @@ import { getCsvMetadata, getDeckNames, getNotetypeNames } from "@tslib/backend";
import { ModuleName, setupI18n } from "@tslib/i18n";
import { checkNightMode } from "@tslib/nightmode";
import ErrorPage from "../components/ErrorPage.svelte";
import ImportCsvPage from "./ImportCsvPage.svelte";
import { tryGetDeckColumn, tryGetDeckId, tryGetGlobalNotetype, tryGetNotetypeColumn } from "./lib";
@ -24,44 +25,45 @@ const i18n = setupI18n({
],
});
export async function setupImportCsvPage(path: string): Promise<ImportCsvPage> {
const [notetypes, decks, metadata, _i18n] = await Promise.all([
export async function setupImportCsvPage(path: string): Promise<ImportCsvPage | ErrorPage> {
checkNightMode();
return Promise.all([
getNotetypeNames({}),
getDeckNames({
skipEmptyDefault: false,
includeFiltered: false,
}),
getCsvMetadata({ path }),
getCsvMetadata({ path }, { alertOnError: false }),
i18n,
]);
checkNightMode();
return new ImportCsvPage({
target: document.body,
props: {
path: path,
deckNameIds: decks.entries,
notetypeNameIds: notetypes.entries,
dupeResolution: metadata.dupeResolution,
matchScope: metadata.matchScope,
delimiter: metadata.delimiter,
forceDelimiter: metadata.forceDelimiter,
isHtml: metadata.isHtml,
forceIsHtml: metadata.forceIsHtml,
globalTags: metadata.globalTags,
updatedTags: metadata.updatedTags,
columnLabels: metadata.columnLabels,
tagsColumn: metadata.tagsColumn,
guidColumn: metadata.guidColumn,
preview: metadata.preview,
globalNotetype: tryGetGlobalNotetype(metadata),
// Unset oneof numbers default to 0, which also means n/a here,
// but it's vital to differentiate between unset and 0 when reserializing.
notetypeColumn: tryGetNotetypeColumn(metadata),
deckId: tryGetDeckId(metadata),
deckColumn: tryGetDeckColumn(metadata),
},
]).then(([notetypes, decks, metadata, _i18n]) => {
return new ImportCsvPage({
target: document.body,
props: {
path: path,
deckNameIds: decks.entries,
notetypeNameIds: notetypes.entries,
dupeResolution: metadata.dupeResolution,
matchScope: metadata.matchScope,
delimiter: metadata.delimiter,
forceDelimiter: metadata.forceDelimiter,
isHtml: metadata.isHtml,
forceIsHtml: metadata.forceIsHtml,
globalTags: metadata.globalTags,
updatedTags: metadata.updatedTags,
columnLabels: metadata.columnLabels,
tagsColumn: metadata.tagsColumn,
guidColumn: metadata.guidColumn,
preview: metadata.preview,
globalNotetype: tryGetGlobalNotetype(metadata),
// Unset oneof numbers default to 0, which also means n/a here,
// but it's vital to differentiate between unset and 0 when reserializing.
notetypeColumn: tryGetNotetypeColumn(metadata),
deckId: tryGetDeckId(metadata),
deckColumn: tryGetDeckColumn(metadata),
},
});
}).catch((error) => {
return new ErrorPage({ target: document.body, props: { error } });
});
}

View File

@ -4,12 +4,7 @@ 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 { importDone, importJsonFile, importJsonString } from "@tslib/backend";
import { bridgeCommand } from "@tslib/bridgecommand";
import * as tr from "@tslib/ftl";
@ -33,14 +28,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let result: ImportResponse | undefined;
try {
switch (params.type) {
case "apkg":
result = await importAnkiPackage(
{
packagePath: params.path,
},
postOptions,
);
break;
case "json_file":
result = await importJsonFile({ val: params.path }, postOptions);
break;

View File

@ -90,7 +90,7 @@ export function getSummaries(log: ImportResponse_Log): SummarizedLogQueues[] {
},
],
action: tr.importingSkipped(),
summaryTemplate: tr.importingConflictingNotesSkipped,
summaryTemplate: tr.importingConflictingNotesSkipped2,
canBrowse: false,
icon: closeBox,
},

View File

@ -33,4 +33,9 @@ export const HelpPage = {
Studying: {
siblingsAndBurying: "https://docs.ankiweb.net/studying.html#siblings-and-burying",
},
PackageImporting: {
root: "https://docs.ankiweb.net/importing/packaged-decks.html",
updating: "https://docs.ankiweb.net/importing/packaged-decks.html#updating",
scheduling: "https://docs.ankiweb.net/importing/packaged-decks.html#scheduling",
},
};