diff --git a/ftl/core/importing.ftl b/ftl/core/importing.ftl index 3b9f7c401..b23d12654 100644 --- a/ftl/core/importing.ftl +++ b/ftl/core/importing.ftl @@ -48,6 +48,7 @@ importing-merge-notetypes-help = 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-new-deck-will-be-created = A new deck will be created: { $name } importing-notes-added-from-file = Notes added from file: { $val } 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 } diff --git a/proto/anki/import_export.proto b/proto/anki/import_export.proto index 88a7ad163..3273a57bb 100644 --- a/proto/anki/import_export.proto +++ b/proto/anki/import_export.proto @@ -176,9 +176,12 @@ message CsvMetadata { // to determine the number of columns. repeated string column_labels = 5; oneof deck { + // id of an existing deck int64 deck_id = 6; // One-based. 0 means n/a. uint32 deck_column = 7; + // name of new deck to be created + string deck_name = 17; } oneof notetype { // One notetype for all rows with given column mapping. diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs index f8422f6bd..e45bbca1b 100644 --- a/rslib/src/import_export/text/csv/import.rs +++ b/rslib/src/import_export/text/csv/import.rs @@ -61,6 +61,7 @@ impl CsvDeckExt for CsvDeck { match self { Self::DeckId(did) => NameOrId::Id(*did), Self::DeckColumn(_) => NameOrId::default(), + Self::DeckName(name) => NameOrId::Name(name.into()), } } @@ -68,6 +69,7 @@ impl CsvDeckExt for CsvDeck { match self { Self::DeckId(_) => None, Self::DeckColumn(column) => Some(*column as usize), + Self::DeckName(_) => None, } } } diff --git a/rslib/src/import_export/text/csv/metadata.rs b/rslib/src/import_export/text/csv/metadata.rs index 7e2f64f5e..d505c60d2 100644 --- a/rslib/src/import_export/text/csv/metadata.rs +++ b/rslib/src/import_export/text/csv/metadata.rs @@ -163,6 +163,8 @@ impl Collection { "deck" => { if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) { metadata.deck = Some(CsvDeck::DeckId(did.0)); + } else if !value.is_empty() { + metadata.deck = Some(CsvDeck::DeckName(value.to_string())); } } "notetype column" => { @@ -626,6 +628,7 @@ pub(in crate::import_export) mod test { pub trait CsvMetadataTestExt { fn defaults_for_testing() -> Self; fn unwrap_deck_id(&self) -> i64; + fn unwrap_deck_name(&self) -> &str; fn unwrap_notetype_id(&self) -> i64; fn unwrap_notetype_map(&self) -> &[u32]; } @@ -660,6 +663,13 @@ pub(in crate::import_export) mod test { } } + fn unwrap_deck_name(&self) -> &str { + match &self.deck { + Some(CsvDeck::DeckName(name)) => name, + _ => panic!("no deck name"), + } + } + fn unwrap_notetype_id(&self) -> i64 { match self.notetype { Some(CsvNotetype::GlobalNotetype(ref nt)) => nt.id, @@ -683,8 +693,11 @@ pub(in crate::import_export) mod test { metadata!(col, format!("#deck:{deck_id}\n")).unwrap_deck_id(), deck_id ); + // unknown deck + assert_eq!(metadata!(col, "#deck:foo\n").unwrap_deck_name(), "foo"); + assert_eq!(metadata!(col, "#deck:1234\n").unwrap_deck_name(), "1234"); // fallback - assert_eq!(metadata!(col, "#deck:foo\n").unwrap_deck_id(), 1); + assert_eq!(metadata!(col, "#deck:\n").unwrap_deck_id(), 1); assert_eq!(metadata!(col, "\n").unwrap_deck_id(), 1); } @@ -726,8 +739,8 @@ pub(in crate::import_export) mod test { numeric_deck_2_id ); assert_eq!( - metadata!(col, format!("#deck:1234\n")).unwrap_deck_id(), - 1 // default deck + metadata!(col, format!("#deck:1234\n")).unwrap_deck_name(), + "1234" ); } diff --git a/rslib/src/import_export/text/import.rs b/rslib/src/import_export/text/import.rs index 4425bb386..202189eb6 100644 --- a/rslib/src/import_export/text/import.rs +++ b/rslib/src/import_export/text/import.rs @@ -147,7 +147,7 @@ impl Duplicate { } impl DeckIdsByNameOrId { - fn new(col: &mut Collection, default: &NameOrId) -> Result { + fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result { let names: HashMap, DeckId> = col .get_all_normal_deck_names(false)? .into_iter() @@ -160,6 +160,13 @@ impl DeckIdsByNameOrId { default: None, }; new.default = new.get(default); + if new.default.is_none() && *default != NameOrId::default() { + let mut deck = Deck::new_normal(); + deck.name = NativeDeckName::from_human_name(default.to_string()); + col.add_deck_inner(&mut deck, usn)?; + new.insert(deck.id, deck.human_name()); + new.default = Some(deck.id); + } Ok(new) } @@ -193,7 +200,7 @@ impl<'a> Context<'a> { NameOrId::default(), col.notetype_by_name_or_id(&data.default_notetype)?, ); - let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck)?; + let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck, usn)?; let existing_checksums = ExistingChecksums::new(col, data.match_scope)?; let existing_guids = col.storage.all_notes_by_guid()?; diff --git a/rslib/src/import_export/text/mod.rs b/rslib/src/import_export/text/mod.rs index e9e2da766..fdf94971a 100644 --- a/rslib/src/import_export/text/mod.rs +++ b/rslib/src/import_export/text/mod.rs @@ -83,6 +83,15 @@ impl From for NameOrId { } } +impl std::fmt::Display for NameOrId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NameOrId::Id(did) => write!(f, "{did}"), + NameOrId::Name(name) => write!(f, "{name}"), + } + } +} + impl ForeignNote { pub(crate) fn into_log_note(self) -> LogNote { LogNote { diff --git a/ts/routes/import-csv/ImportOptions.svelte b/ts/routes/import-csv/ImportOptions.svelte index 2acde50eb..5e7c76b28 100644 --- a/ts/routes/import-csv/ImportOptions.svelte +++ b/ts/routes/import-csv/ImportOptions.svelte @@ -17,12 +17,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { dupeResolutionChoices, matchScopeChoices } from "./choices"; import type { ImportCsvState } from "./lib"; + import Warning from "../deck-options/Warning.svelte"; export let state: ImportCsvState; const metadata = state.metadata; const globalNotetype = state.globalNotetype; const deckId = state.deckId; + const deckName = state.newDeckName; const settings = { notetype: { @@ -64,6 +66,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html modal.show(); carousel.to(index); } + + const choices = state.deckNameIds.map(({ id, name }) => { + return { label: name, value: id }; + }); + + if (deckName) { + choices.push({ + label: deckName, + value: 0n, + }); + } + + $: newDeckCreationNotice = + deckName && $deckId === 0n + ? tr.importingNewDeckWillBeCreated({ name: deckName }) + : ""; @@ -95,13 +113,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} - {#if $deckId !== null} + {#if deckName || $deckId} { - return { label: name, value: id }; - })} + {choices} > openHelpModal(Object.keys(settings).indexOf("deck"))} @@ -111,6 +127,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} + + ; readonly globalNotetype: Writable; @@ -83,6 +88,7 @@ export class ImportCsvState { this.defaultIsHtml = metadata.isHtml; this.defaultNotetypeId = this.lastGlobalNotetype?.id || null; this.defaultDeckId = this.lastDeckId; + this.newDeckName = getDeckName(metadata); } doImport(): Promise { @@ -104,7 +110,7 @@ export class ImportCsvState { path: this.path, delimiter: changed.delimiter, notetypeId: getGlobalNotetype(changed)?.id, - deckId: getDeckId(changed) ?? undefined, + deckId: getDeckId(changed) || undefined, isHtml: changed.isHtml, }); // carry over tags @@ -157,7 +163,13 @@ export class ImportCsvState { this.lastDeckId = deckId; if (deckId !== null) { this.metadata.update((metadata) => { - metadata.deck.value = deckId; + if (deckId !== 0n) { + metadata.deck.case = "deckId"; + metadata.deck.value = deckId; + } else { + metadata.deck.case = "deckName"; + metadata.deck.value = this.newDeckName!; + } return metadata; }); }