From cc6549505c75c1320039b6c22be587b79a3a5289 Mon Sep 17 00:00:00 2001 From: Philipp Stadler <hello@phstadler.com> Date: Sun, 21 Jul 2024 12:37:43 +0200 Subject: [PATCH] feat!: add special CSV deck field, comment support and skip fields by mapping to the empty string BREAKING CHANGE: `deck` in CSV is now not a field but a special value for the per-note deck pattern. Additionally, lines starting with hash in CSV are now ignored for better Anki compatibility. --- export_apkgs.py | 53 ++++++++++++------- test/fixtures/anki_text/.apkg-spec.yaml | 23 ++++++++ test/fixtures/anki_text/Tapirs.txt | 6 +++ test/fixtures/csv/.apkg-spec.yaml | 1 + .../csv_updated_content/.apkg-spec.yaml | 1 + test/fixtures/fields_static/.apkg-spec.yaml | 1 + test/test_export_apkgs.py | 43 +++++++++++++++ version.txt | 2 +- 8 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 test/fixtures/anki_text/.apkg-spec.yaml create mode 100644 test/fixtures/anki_text/Tapirs.txt diff --git a/export_apkgs.py b/export_apkgs.py index eaedd2e..e0d9b7b 100755 --- a/export_apkgs.py +++ b/export_apkgs.py @@ -42,6 +42,7 @@ class ImportApkg(BaseModel): class ImportCsv(BaseModel): content_version: datetime + delimiter: str note_type: str file_patterns: list[str]|None deck_name_pattern: str @@ -109,6 +110,12 @@ class ApkgExporter: added_note_type_ids: list[int] added_card_types: set[str] resource_pseudo_usages: str + """ + Note GUID against deck. + + Set from the deck field in CSV. + """ + note_deck_patterns: dict[str, str] def __init__(self, spec: ApkgSpec, content_dir: Path, intermediate_dir: Path, args: argparse.Namespace) -> None: self.spec = spec @@ -121,6 +128,7 @@ class ApkgExporter: self.added_note_type_ids = [] self.added_card_types = set() self.resource_pseudo_usages = '' + self.note_deck_patterns = {} def export(self) -> Path: @@ -319,7 +327,6 @@ class ApkgExporter: if 'import_csv' in content: import_csv : ImportCsv = content['import_csv'] deck_name = import_csv.deck_name_pattern - fields_mapping = import_csv.fields_mapping ntid = name_to_ntid[import_csv.note_type] with fake_time(format_time(import_csv.content_version)): for pattern in import_csv.file_patterns or ['**/*.csv']: @@ -330,8 +337,7 @@ class ApkgExporter: tmp_deck, import_csv.tags, csv_path, - fields_mapping, - import_csv.fields_static) + import_csv) self.move_to_target_decks(col, tmp_deck, deck_name, csv_path) elif 'import_apkg' in content: import_apkg : ImportApkg = content['import_apkg'] @@ -377,23 +383,30 @@ class ApkgExporter: tmp_deck: int, tags: list[str], csv_path: Path, - fields_mapping: list[str], - fields_static: dict[str, str]): + opts: ImportCsv): with open(csv_path) as csv_file: - reader = csv.reader(csv_file, delimiter=';') + delimiter = opts.delimiter or "\t" + reader = csv.reader(csv_file, delimiter=delimiter) for row in reader: - note = Note(col, note_type_id) - note = self.new_note(col, row[fields_mapping.index('guid')], note_type_id) - for idx, field in enumerate(fields_mapping): - if idx >= len(row): - raise Exception(f'CSV row is missing {field} at index {idx}:\n{row}') - if field != 'guid': - note[field] = row[idx] - for field, value in fields_static.items(): - note[field] = value - for tag in tags: - note.add_tag(tag) - col.add_note(note, tmp_deck) + # hash lines are comments + if len(row) >= 1 and not row[0].startswith('#'): + note = Note(col, note_type_id) + guid = row[opts.fields_mapping.index('guid')] + note = self.new_note(col, guid, note_type_id) + for idx, field in enumerate(opts.fields_mapping): + if field != '': + if idx >= len(row): + raise Exception(f'CSV row is missing {field} at index {idx}:\n{row}') + # deck overrides the pattern per note + if field == 'deck': + self.note_deck_patterns[guid] = row[idx] + elif field != 'guid': + note[field] = row[idx] + for field, value in opts.fields_static.items(): + note[field] = value + for tag in tags: + note.add_tag(tag) + col.add_note(note, tmp_deck) def field_columns(self, col: Collection, note_type_id: int, mapping: list[str]) -> list[int]: """ @@ -462,7 +475,9 @@ class ApkgExporter: for card_id in col.find_cards('deck:"' + TEMP_DECK_NAME + '"'): card = col.get_card(card_id) card_type_name = card.template()["name"] - desired_deck_name = self.format_deck_name(deck_name_pattern, csv_path, card_type_name) + guid = card.note().guid + pattern = self.note_deck_patterns[guid] if guid in self.note_deck_patterns else deck_name_pattern + desired_deck_name = self.format_deck_name(pattern, csv_path, card_type_name) card.did = self.get_or_create_deck_id(col, desired_deck_name) col.update_card(card) diff --git a/test/fixtures/anki_text/.apkg-spec.yaml b/test/fixtures/anki_text/.apkg-spec.yaml new file mode 100644 index 0000000..ead10a8 --- /dev/null +++ b/test/fixtures/anki_text/.apkg-spec.yaml @@ -0,0 +1,23 @@ +content_version: 1.0.0 + +templates: +- q_a + +content: +- import_csv: + content_version: 2024-01-19 19:00:00+00:00 + note_type: Q/A Testnotetype + file_patterns: + - '*.txt' + delimiter: "\t" + # Not needed because the CSV contains the deck name + deck_name_pattern: 'UNUSED' + fields_mapping: + - guid + - deck + - Question + - Answer + fields_static: {} + tags: [] + +resource_paths: [] diff --git a/test/fixtures/anki_text/Tapirs.txt b/test/fixtures/anki_text/Tapirs.txt new file mode 100644 index 0000000..7b13e86 --- /dev/null +++ b/test/fixtures/anki_text/Tapirs.txt @@ -0,0 +1,6 @@ +#separator:tab +#html:true +#guid column:1 +#deck column:1 +#tags column:4 +x,/Dh9c@Q^ Tapirs::Species Which tapir has a prominent mane? Tapirus terrestris diff --git a/test/fixtures/csv/.apkg-spec.yaml b/test/fixtures/csv/.apkg-spec.yaml index 0c13501..5c2d695 100644 --- a/test/fixtures/csv/.apkg-spec.yaml +++ b/test/fixtures/csv/.apkg-spec.yaml @@ -9,6 +9,7 @@ content: note_type: Q/A Testnotetype file_patterns: - '*.csv' + delimiter: ';' # and to making one deck per card type deck_name_pattern: '{{card_type}}' fields_mapping: diff --git a/test/fixtures/csv_updated_content/.apkg-spec.yaml b/test/fixtures/csv_updated_content/.apkg-spec.yaml index 18e0338..1e15dd2 100644 --- a/test/fixtures/csv_updated_content/.apkg-spec.yaml +++ b/test/fixtures/csv_updated_content/.apkg-spec.yaml @@ -9,6 +9,7 @@ content: note_type: Q/A Testnotetype file_patterns: - '*.csv' + delimiter: ';' # and to making one deck per card type deck_name_pattern: '{{card_type}}' fields_mapping: diff --git a/test/fixtures/fields_static/.apkg-spec.yaml b/test/fixtures/fields_static/.apkg-spec.yaml index 455ae6e..b4b47f8 100644 --- a/test/fixtures/fields_static/.apkg-spec.yaml +++ b/test/fixtures/fields_static/.apkg-spec.yaml @@ -9,6 +9,7 @@ content: note_type: Q/A Testnotetype file_patterns: - '*.csv' + delimiter: ';' # and to making one deck per card type deck_name_pattern: '{{card_type}}' fields_mapping: diff --git a/test/test_export_apkgs.py b/test/test_export_apkgs.py index 7ff7af3..aeb5a36 100644 --- a/test/test_export_apkgs.py +++ b/test/test_export_apkgs.py @@ -17,6 +17,7 @@ CONTENT_PATH_ANKI = 'test/fixtures/anki' CONTENT_PATH_CSV = 'test/fixtures/csv' CONTENT_PATH_CSV_UPDATED_CONTENT = 'test/fixtures/csv_updated_content' CONTENT_PATH_FIELDS_STATIC = 'test/fixtures/fields_static' +CONTENT_PATH_ANKI_TEXT = 'test/fixtures/anki_text' TEMPLATES_PATH = 'test/fixtures/templates' TEMPLATES_PATH_UPDATED = 'test/fixtures/templates_updated' @@ -362,6 +363,48 @@ class TestExportApkgs(unittest.TestCase): ) + def test_anki_text(self): + """ + Anki text export with deck name + """ + with TemporaryDirectory() as temp_collection_dir: + col = Collection(str(Path(temp_collection_dir) / "test.anki2")) + with TemporaryDirectory() as first_export_dir: + args_first = MockArgs( + content=CONTENT_PATH_ANKI_TEXT, + templates_dir=TEMPLATES_PATH, + output_dir=first_export_dir, + dry_run=False) + package = export_package_from_spec( + Path(CONTENT_PATH_ANKI_TEXT) / '.apkg-spec.yaml', + args_first) + + # import the first time, everything is new + result1 = col.import_anki_package(ImportAnkiPackageRequest( + package_path=str(package), + options=ImportAnkiPackageOptions( + merge_notetypes=True, + update_notes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + update_notetypes=ImportAnkiPackageUpdateCondition.IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER, + with_scheduling=False, + with_deck_configs=False, + ) + )) + self.assertEqual(len(result1.log.new), 1) + self.assertEqual( + result1.log.new[0].fields, + # the empty one is resources + [ + 'Which tapir has a prominent mane?', + 'Tapirus terrestris', + '' + ] + ) + self.assertEqual( + len(col.find_cards('deck:"Tapirs::Species"')), + 1) + + def find_template(col: Collection, name: str): template = None for name_and_id in col.models.all_names_and_ids(): diff --git a/version.txt b/version.txt index 4a36342..fcdb2e1 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.0.0 +4.0.0 -- GitLab