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

View file

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

View file

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

View file

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

View file

@ -147,7 +147,7 @@ impl Duplicate {
}
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
.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()?;

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 {
pub(crate) fn into_log_note(self) -> 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 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 })
: "";
</script>
<TitledContainer title={tr.importingImportOptions()}>
@ -95,13 +113,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</EnumSelectorRow>
{/if}
{#if $deckId !== null}
{#if deckName || $deckId}
<EnumSelectorRow
bind:value={$deckId}
defaultValue={state.defaultDeckId}
choices={state.deckNameIds.map(({ id, name }) => {
return { label: name, value: id };
})}
{choices}
>
<SettingTitle
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>
{/if}
<Warning warning={newDeckCreationNotice} className="alert-info" />
<EnumSelectorRow
bind:value={$metadata.dupeResolution}
defaultValue={0}

View file

@ -22,8 +22,12 @@ export function getGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype
return meta.notetype.case === "globalNotetype" ? meta.notetype.value : null;
}
export function getDeckId(meta: CsvMetadata): bigint | null {
return meta.deck.case === "deckId" ? meta.deck.value : null;
export function getDeckId(meta: CsvMetadata): bigint {
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 {
@ -35,6 +39,7 @@ export class ImportCsvState {
readonly defaultIsHtml: boolean;
readonly defaultNotetypeId: bigint | null;
readonly defaultDeckId: bigint | null;
readonly newDeckName: string | null;
readonly metadata: Writable<CsvMetadata>;
readonly globalNotetype: Writable<CsvMetadata_MappedNotetype | null>;
@ -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<ImportResponse> {
@ -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;
});
}