Allow creating deck via #deck:... if non-existent when importing (#4154)

* add deck name field to metadata protobuf msg

* fallback to creating new deck specified in `#deck:...`

* update tests

* create deck if it doesn't exist

* plumbing

* allow creating deck via `#deck:...`

* apply suggestion for protobuf
This commit is contained in:
llama 2025-07-08 01:46:04 +08:00 committed by GitHub
parent 037dfa1bc1
commit 80ff9a120c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 13 deletions

View file

@ -48,6 +48,7 @@ importing-merge-notetypes-help =
Warning: This will require a one-way sync, and may mark existing notes as modified. 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-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-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-added-from-file = Notes added from file: { $val }
importing-notes-found-in-file = Notes found in 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 } importing-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copies are already in your collection: { $val }

View file

@ -176,9 +176,12 @@ message CsvMetadata {
// to determine the number of columns. // to determine the number of columns.
repeated string column_labels = 5; repeated string column_labels = 5;
oneof deck { oneof deck {
// id of an existing deck
int64 deck_id = 6; int64 deck_id = 6;
// One-based. 0 means n/a. // One-based. 0 means n/a.
uint32 deck_column = 7; uint32 deck_column = 7;
// name of new deck to be created
string deck_name = 17;
} }
oneof notetype { oneof notetype {
// One notetype for all rows with given column mapping. // One notetype for all rows with given column mapping.

View file

@ -61,6 +61,7 @@ impl CsvDeckExt for CsvDeck {
match self { match self {
Self::DeckId(did) => NameOrId::Id(*did), Self::DeckId(did) => NameOrId::Id(*did),
Self::DeckColumn(_) => NameOrId::default(), Self::DeckColumn(_) => NameOrId::default(),
Self::DeckName(name) => NameOrId::Name(name.into()),
} }
} }
@ -68,6 +69,7 @@ impl CsvDeckExt for CsvDeck {
match self { match self {
Self::DeckId(_) => None, Self::DeckId(_) => None,
Self::DeckColumn(column) => Some(*column as usize), Self::DeckColumn(column) => Some(*column as usize),
Self::DeckName(_) => None,
} }
} }
} }

View file

@ -163,6 +163,8 @@ impl Collection {
"deck" => { "deck" => {
if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) { if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) {
metadata.deck = Some(CsvDeck::DeckId(did.0)); metadata.deck = Some(CsvDeck::DeckId(did.0));
} else if !value.is_empty() {
metadata.deck = Some(CsvDeck::DeckName(value.to_string()));
} }
} }
"notetype column" => { "notetype column" => {
@ -626,6 +628,7 @@ pub(in crate::import_export) mod test {
pub trait CsvMetadataTestExt { pub trait CsvMetadataTestExt {
fn defaults_for_testing() -> Self; fn defaults_for_testing() -> Self;
fn unwrap_deck_id(&self) -> i64; fn unwrap_deck_id(&self) -> i64;
fn unwrap_deck_name(&self) -> &str;
fn unwrap_notetype_id(&self) -> i64; fn unwrap_notetype_id(&self) -> i64;
fn unwrap_notetype_map(&self) -> &[u32]; 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 { fn unwrap_notetype_id(&self) -> i64 {
match self.notetype { match self.notetype {
Some(CsvNotetype::GlobalNotetype(ref nt)) => nt.id, 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(), metadata!(col, format!("#deck:{deck_id}\n")).unwrap_deck_id(),
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 // 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); assert_eq!(metadata!(col, "\n").unwrap_deck_id(), 1);
} }
@ -726,8 +739,8 @@ pub(in crate::import_export) mod test {
numeric_deck_2_id numeric_deck_2_id
); );
assert_eq!( assert_eq!(
metadata!(col, format!("#deck:1234\n")).unwrap_deck_id(), metadata!(col, format!("#deck:1234\n")).unwrap_deck_name(),
1 // default deck "1234"
); );
} }

View file

@ -147,7 +147,7 @@ impl Duplicate {
} }
impl DeckIdsByNameOrId { impl DeckIdsByNameOrId {
fn new(col: &mut Collection, default: &NameOrId) -> Result<Self> { fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result<Self> {
let names: HashMap<UniCase<String>, DeckId> = col let names: HashMap<UniCase<String>, DeckId> = col
.get_all_normal_deck_names(false)? .get_all_normal_deck_names(false)?
.into_iter() .into_iter()
@ -160,6 +160,13 @@ impl DeckIdsByNameOrId {
default: None, default: None,
}; };
new.default = new.get(default); 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) Ok(new)
} }
@ -193,7 +200,7 @@ impl<'a> Context<'a> {
NameOrId::default(), NameOrId::default(),
col.notetype_by_name_or_id(&data.default_notetype)?, 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_checksums = ExistingChecksums::new(col, data.match_scope)?;
let existing_guids = col.storage.all_notes_by_guid()?; let existing_guids = col.storage.all_notes_by_guid()?;

View file

@ -83,6 +83,15 @@ impl From<String> 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 { impl ForeignNote {
pub(crate) fn into_log_note(self) -> LogNote { pub(crate) fn into_log_note(self) -> LogNote {
LogNote { LogNote {

View file

@ -17,12 +17,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { dupeResolutionChoices, matchScopeChoices } from "./choices"; import { dupeResolutionChoices, matchScopeChoices } from "./choices";
import type { ImportCsvState } from "./lib"; import type { ImportCsvState } from "./lib";
import Warning from "../deck-options/Warning.svelte";
export let state: ImportCsvState; export let state: ImportCsvState;
const metadata = state.metadata; const metadata = state.metadata;
const globalNotetype = state.globalNotetype; const globalNotetype = state.globalNotetype;
const deckId = state.deckId; const deckId = state.deckId;
const deckName = state.newDeckName;
const settings = { const settings = {
notetype: { notetype: {
@ -64,6 +66,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
modal.show(); modal.show();
carousel.to(index); 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 })
: "";
</script> </script>
<TitledContainer title={tr.importingImportOptions()}> <TitledContainer title={tr.importingImportOptions()}>
@ -95,13 +113,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</EnumSelectorRow> </EnumSelectorRow>
{/if} {/if}
{#if $deckId !== null} {#if deckName || $deckId}
<EnumSelectorRow <EnumSelectorRow
bind:value={$deckId} bind:value={$deckId}
defaultValue={state.defaultDeckId} defaultValue={state.defaultDeckId}
choices={state.deckNameIds.map(({ id, name }) => { {choices}
return { label: name, value: id };
})}
> >
<SettingTitle <SettingTitle
on:click={() => openHelpModal(Object.keys(settings).indexOf("deck"))} on:click={() => openHelpModal(Object.keys(settings).indexOf("deck"))}
@ -111,6 +127,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</EnumSelectorRow> </EnumSelectorRow>
{/if} {/if}
<Warning warning={newDeckCreationNotice} className="alert-info" />
<EnumSelectorRow <EnumSelectorRow
bind:value={$metadata.dupeResolution} bind:value={$metadata.dupeResolution}
defaultValue={0} defaultValue={0}

View file

@ -22,8 +22,12 @@ export function getGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype
return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null; return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null;
} }
export function getDeckId(meta: CsvMetadata): bigint | null { export function getDeckId(meta: CsvMetadata): bigint {
return meta.deck.case === "deckId" ? meta.deck.value : null; return meta.deck.case === "deckId" ? meta.deck.value : 0n;
}
export function getDeckName(meta: CsvMetadata): string | null {
return meta.deck.case === "deckName" ? meta.deck.value : null;
} }
export class ImportCsvState { export class ImportCsvState {
@ -35,6 +39,7 @@ export class ImportCsvState {
readonly defaultIsHtml: boolean; readonly defaultIsHtml: boolean;
readonly defaultNotetypeId: bigint | null; readonly defaultNotetypeId: bigint | null;
readonly defaultDeckId: bigint | null; readonly defaultDeckId: bigint | null;
readonly newDeckName: string | null;
readonly metadata: Writable<CsvMetadata>; readonly metadata: Writable<CsvMetadata>;
readonly globalNotetype: Writable<CsvMetadata_MappedNotetype | null>; readonly globalNotetype: Writable<CsvMetadata_MappedNotetype | null>;
@ -83,6 +88,7 @@ export class ImportCsvState {
this.defaultIsHtml = metadata.isHtml; this.defaultIsHtml = metadata.isHtml;
this.defaultNotetypeId = this.lastGlobalNotetype?.id || null; this.defaultNotetypeId = this.lastGlobalNotetype?.id || null;
this.defaultDeckId = this.lastDeckId; this.defaultDeckId = this.lastDeckId;
this.newDeckName = getDeckName(metadata);
} }
doImport(): Promise<ImportResponse> { doImport(): Promise<ImportResponse> {
@ -104,7 +110,7 @@ export class ImportCsvState {
path: this.path, path: this.path,
delimiter: changed.delimiter, delimiter: changed.delimiter,
notetypeId: getGlobalNotetype(changed)?.id, notetypeId: getGlobalNotetype(changed)?.id,
deckId: getDeckId(changed) ?? undefined, deckId: getDeckId(changed) || undefined,
isHtml: changed.isHtml, isHtml: changed.isHtml,
}); });
// carry over tags // carry over tags
@ -157,7 +163,13 @@ export class ImportCsvState {
this.lastDeckId = deckId; this.lastDeckId = deckId;
if (deckId !== null) { if (deckId !== null) {
this.metadata.update((metadata) => { this.metadata.update((metadata) => {
if (deckId !== 0n) {
metadata.deck.case = "deckId";
metadata.deck.value = deckId; metadata.deck.value = deckId;
} else {
metadata.deck.case = "deckName";
metadata.deck.value = this.newDeckName!;
}
return metadata; return metadata;
}); });
} }