diff --git a/src/apyanki/anki.py b/src/apyanki/anki.py index 692f85e..2f30c83 100644 --- a/src/apyanki/anki.py +++ b/src/apyanki/anki.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import pickle import re @@ -550,7 +551,8 @@ def add_notes_from_file( tags: str = "", deck: str | None = None, update_origin_file: bool = False, - respect_note_ids: bool = True, + respect_note_ids: bool = False, + link_duplicates: bool = False, ) -> list[Note]: """Add notes from Markdown file @@ -562,6 +564,8 @@ def add_notes_from_file( respect_note_ids: If True, then this function looks for nid: or cid: headers in the file to determine if a note should be updated rather than added. + link_duplicates: If True, when a duplicate is detected, find the existing + note and update the IDs file with its nid. Returns: List of notes that were updated or added @@ -571,8 +575,10 @@ def add_notes_from_file( has_missing_nids: bool = False notes: list[Note] = [] + external_ids_map: dict[str, str] = {} + internal_ids_map: dict[int, str] = {} - for note_data in markdown_file_to_notes(filename): + for idx, note_data in enumerate(markdown_file_to_notes(filename)): if tags: note_data.tags = f"{tags} {note_data.tags}" @@ -586,16 +592,38 @@ def add_notes_from_file( else: note = note_data.add_to_collection(self) - notes.append(note) + if note[1] == "duplicate" and not link_duplicates: + continue + + notes.append(note[0]) + + nid = str(note[0].n.id) + if note_data.external_id: + external_ids_map[note_data.external_id] = nid + else: + internal_ids_map[idx] = nid - # Update the original file with note IDs if requested if update_origin_file and has_missing_nids: - self._update_file_with_note_ids(filename, original_content, notes) + if len(external_ids_map) > 0: + self._update_external_ids_file( + filename, + original_content, + external_ids_map, + ) + else: + self._update_file_with_note_ids( + filename, + original_content, + internal_ids_map, + ) return notes def _update_file_with_note_ids( - self, filename: str, content: str, notes: list[Note] + self, + filename: str, + content: str, + note_id_map: dict[int, str], ) -> None: """Update the original markdown file with note IDs @@ -604,7 +632,8 @@ def _update_file_with_note_ids( Args: filename: Path to the markdown file content: Original content of the file - notes: List of notes that were added/updated + note_id_map: A dict from note index to note ids for notes that were + added/updated """ # Find all '# Note' or similar headers in the file note_headers = re.finditer(r"^# .*$", content, re.MULTILINE) @@ -643,9 +672,8 @@ def _update_file_with_note_ids( insert_pos = j + 1 # Insert after this line # If we have a note ID for this position, insert it - if i < len(notes): - note_id = notes[i].n.id - lines.insert(insert_pos, f"nid: {note_id}") + if i in note_id_map: + lines.insert(insert_pos, f"nid: {note_id_map[i]}") updated_content.append("\n".join(lines)) else: # Couldn't match this section to a note, keep unchanged @@ -655,6 +683,35 @@ def _update_file_with_note_ids( with open(filename, "w", encoding="utf-8") as f: _ = f.write("".join(updated_content)) + def _update_external_ids_file( + self, filename: str, content: str, external_ids_map: dict[str, str] + ) -> None: + """Update the external IDs JSON file with new note IDs + + This function updates the external IDs file when using external-ids mode. + + Args: + filename: Path to the markdown file + content: Original content of the file (unused, kept for signature consistency) + external_ids_map: Dictionary mapping external IDs to NIDs + """ + match = re.search(r"external-ids:\s*(.+)", content) + if not match: + return + + ids_filename = match.group(1).strip() + ids_file_path = Path(filename).parent / ids_filename + + existing_ids: dict[str, str] = {} + if ids_file_path.exists(): + with open(ids_file_path, "r", encoding="utf-8") as f: + existing_ids = json.load(f) + + existing_ids.update(external_ids_map) + + with open(ids_file_path, "w", encoding="utf-8") as f: + json.dump(existing_ids, f, indent=2) + def add_notes_from_list( self, parsed_notes: list[NoteData], @@ -667,7 +724,7 @@ def add_notes_from_list( if note.deck is None: note.deck = deck note.tags = f"{tags} {note.tags}" - notes.append(note.add_to_collection(self)) + notes.append(note.add_to_collection(self)[0]) return notes @@ -692,4 +749,4 @@ def add_notes_single( fields = dict(zip(field_names, field_values)) new_note = NoteData(model_name, tags, fields, markdown, deck) - return new_note.add_to_collection(self) + return new_note.add_to_collection(self)[0] diff --git a/src/apyanki/cli.py b/src/apyanki/cli.py index ccfab4c..9731b3a 100644 --- a/src/apyanki/cli.py +++ b/src/apyanki/cli.py @@ -3,7 +3,7 @@ import os import sys from pathlib import Path -from typing import Any +from typing import Any, Literal import click @@ -130,7 +130,7 @@ def add(tags: str, model_name: str, deck: str) -> None: """ with Anki(**cfg) as a: notes = a.add_notes_with_editor(tags, model_name, deck) - _added_notes_postprocessing(a, notes) + _added_notes_postprocessing(a, notes, "Added") @main.command("update-from-file") @@ -138,33 +138,36 @@ def add(tags: str, model_name: str, deck: str) -> None: @click.option("-t", "--tags", default="", help="Specify default tags for cards.") @click.option("-d", "--deck", help="Specify default deck for cards.") @click.option( - "-u", "--update-file", is_flag=True, help="Update original file with note IDs." + "-l", + "--link-duplicates", + is_flag=True, + help="Link duplicates to existing notes in IDs file.", ) -def update_from_file(file: Path, tags: str, deck: str, update_file: bool) -> None: +def update_from_file(file: Path, tags: str, deck: str, link_duplicates: bool) -> None: """Update existing notes or add new notes from Markdown file. - This command will update existing notes if a note ID (nid) or card ID (cid) - is provided in the file header, otherwise it will add new notes. - - With the --update-file option, the original file will be updated to include - note IDs for any new notes added. + This command will update existing notes when a note ID (nid) is available. + There are two modes: - The syntax is similar to add-from-file, but with two additional keys: - - \b - * nid: The note ID to update (optional) - * cid: The card ID to update (optional, used if nid is not provided) + * External IDs: Each note has a unique `id` key in the note header, and + the `nid` is found in an external JSON file. When adding new cards, the + Markdown file will be updated to add missing `id`, and the external ID + file will be updated accordingly with the corresponding `nid` values. + * Internal IDs: Each note has a `nid` key in the note header. When adding + new notes, the Markdown file will be updated with the added `nid` value + for the new cards. - If neither nid nor cid is provided, a new note will be created. + INTERNAL IDS MODE - Here is an example Markdown input for updating: + Here is an example of an internal ID Markdown input: // example.md model: Basic tags: marked - nid: 1619153168151 # Note 1 + nid: 1619153168151 + ## Front Updated question? @@ -172,7 +175,7 @@ def update_from_file(file: Path, tags: str, deck: str, update_file: bool) -> Non Updated answer. # Note 2 - cid: 1619153168152 + nid: 1619153168152 ## Front Another updated question? @@ -188,10 +191,55 @@ def update_from_file(file: Path, tags: str, deck: str, update_file: bool) -> Non ## Back New note content + + EXTERNAL IDS MODE + + For collaborative workflows (e.g., sharing markdown files via Git), you can + store note IDs in a separate external JSON file instead of in the Markdown + file itself. + + To activate external IDs mode, add an 'external-ids:' header at the top of + the markdown file pointing to the JSON file: + + // notes.md + external-ids: .anki-ids.json + + # Note 1 + id: note1 + + ## Front + Question 1 + + ## Back + Answer 1 + + # Note 2 + id: note2 + + ## Front + Question 2 + + ## Back + Answer 2 + + The external IDs file (e.g., .anki-ids.json) maps user-defined IDs to Anki + note IDs: + + { + "note1": "1619153168151", + "note2": "1619153168152" + } """ with Anki(**cfg) as a: - notes = a.add_notes_from_file(str(file), tags, deck, update_file) - _added_notes_postprocessing(a, notes) + notes = a.add_notes_from_file( + str(file), + tags, + deck, + update_origin_file=True, + respect_note_ids=True, + link_duplicates=link_duplicates, + ) + _added_notes_postprocessing(a, notes, "Updated/added") # Create an alias for backward compatibility @@ -199,24 +247,59 @@ def update_from_file(file: Path, tags: str, deck: str, update_file: bool) -> Non @click.argument("file", type=click.Path(exists=True, dir_okay=False)) @click.option("-t", "--tags", default="", help="Specify default tags for new cards.") @click.option("-d", "--deck", help="Specify default deck for new cards.") -@click.option( - "-u", "--update-file", is_flag=True, help="Update original file with note IDs." -) -def add_from_file(file: Path, tags: str, deck: str, update_file: bool) -> None: - """Add notes from Markdown file. +def add_from_file(file: Path, tags: str, deck: str) -> None: + """Add new notes from Markdown file. - With the --update-file option, the original file will be updated to include - note IDs for any new notes added. + This command will add new notes to the collection. Unlike update-from-file, + it will not update existing notes based on IDs - all notes are treated as new. + The file (or external IDs file when using external-ids mode) will NOT be + updated with note IDs. - This command is an alias for update-from-file, which can both add new notes - and update existing ones. + This command is useful for importing notes without modifying the source file. + + Here is an example Markdown input for adding notes: + + // example.md + model: Basic + tags: marked + + # Note 1 + nid: 1619153168151 <- NB! This is IGNORED! + + ## Front + Updated question? + + ## Back + Updated answer. + + # Note 2 + + ## Front + Another updated question? + + ## Back + Another updated answer. + + # Note 3 + model: Basic + tags: newtag + + ## Front + This will be a new note (no ID provided) + + ## Back + New note content """ with Anki(**cfg) as a: - notes = a.add_notes_from_file(str(file), tags, deck, update_file) - _added_notes_postprocessing(a, notes) + notes = a.add_notes_from_file(str(file), tags, deck) + _added_notes_postprocessing(a, notes, "Added") -def _added_notes_postprocessing(a: Anki, notes: list[Note]) -> None: +def _added_notes_postprocessing( + a: Anki, + notes: list[Note], + action_word: Literal["Updated/added", "Added"], +) -> None: """Common postprocessing after 'apy add[-from-file]' or 'apy update-from-file'.""" n_notes = len(notes) if n_notes == 0: @@ -229,18 +312,6 @@ def _added_notes_postprocessing(a: Anki, notes: list[Note]) -> None: console.print("No notes added or updated") return - # Check if the command is update or add (based on caller function name) - import inspect - - caller_frame = inspect.currentframe() - if caller_frame is not None and caller_frame.f_back is not None: - caller_function = caller_frame.f_back.f_code.co_name - else: - caller_function = "" - is_update = "update" in caller_function.lower() - - action_word = "Updated/added" if is_update else "Added" - if a.n_decks > 1: if n_notes == 1: console.print(f"{action_word} note to deck: {decks[0]}") @@ -253,9 +324,12 @@ def _added_notes_postprocessing(a: Anki, notes: list[Note]) -> None: for note in notes: cards = note.n.cards() - console.print(f"* nid: {note.n.id} (with {len(cards)} cards)") - for card in note.n.cards(): - console.print(f" * cid: {card.id}") + if (n := len(cards)) == 1: + console.print(f"* nid: {note.n.id} / cid: {cards[0].id}") + else: + console.print(f"* nid: {note.n.id} / with {n} cards:") + for card in cards: + console.print(f" * cid: {card.id}") @main.command("check-media") diff --git a/src/apyanki/note.py b/src/apyanki/note.py index 9161d04..e8ebfc9 100644 --- a/src/apyanki/note.py +++ b/src/apyanki/note.py @@ -2,9 +2,11 @@ from __future__ import annotations +import json import os import re import tempfile +import uuid from dataclasses import dataclass from pathlib import Path from subprocess import DEVNULL, Popen @@ -319,7 +321,7 @@ def change_model(self) -> Note | None: deck=self.get_deck(), ) - new_note = note_data.add_to_collection(self.a) + new_note = note_data.add_to_collection(self.a)[0] new_note.edit() self.a.delete_notes(self.n.id) @@ -597,6 +599,9 @@ def review( continue +type NoteAddResult = tuple[Note, Literal["added", "updated", "duplicate"]] + + @dataclass class NoteData: """Dataclass to contain data for a single note""" @@ -607,9 +612,9 @@ class NoteData: markdown: bool = True deck: str | None = None nid: str | None = None - cid: str | None = None + external_id: str | None = None - def add_to_collection(self, anki: Anki) -> Note: + def add_to_collection(self, anki: Anki) -> NoteAddResult: """Add note to collection Returns: The new note @@ -640,29 +645,32 @@ def add_to_collection(self, anki: Anki) -> Note: for tag in self.tags.strip().split(): new_note.add_tag(tag) - if not new_note.duplicate_or_empty(): - _ = anki.col.addNote(new_note) - anki.modified = True - else: + check_duplicate = new_note.duplicate_or_empty() + if check_duplicate == 2: field_name, field_value = list(self.fields.items())[0] - console.print("[red]Dupe detected, new_note was not added!") + console.print("[red]Dupe detected: note was not added!") console.print(f"First field: {field_name}") console.print(f"First value: {field_value}") - return Note(anki, new_note) + # Find the duplicate note + nids = anki.col.find_notes(f'{field_name}:"{field_value}"') + if len(nids) == 1: + existing_note = anki.col.get_note(nids[0]) + return (Note(anki, existing_note), "duplicate") + + return (Note(anki, new_note), "duplicate") + + _ = anki.col.addNote(new_note) + anki.modified = True + return (Note(anki, new_note), "added") - def update_or_add_to_collection(self, anki: Anki) -> Note: + def update_or_add_to_collection(self, anki: Anki) -> NoteAddResult: """Update existing note in collection if ID is provided, otherwise add as new Returns: The updated or new note """ - # First try to find the note by nid or cid - existing_note = None - if self.nid: - # Try to find the note by its note ID try: - # Import NoteId here to avoid circular imports at module level from anki.notes import NoteId note_id = NoteId(int(self.nid)) @@ -677,30 +685,9 @@ def update_or_add_to_collection(self, anki: Anki) -> Note: f"[yellow]Note with ID {self.nid} not found: {e}. Will create a new note.[/yellow]" ) - if not existing_note and self.cid: - # Try to find the note by card ID - try: - # Import CardId here to avoid circular imports at module level - from anki.cards import CardId - - card_id = CardId(int(self.cid)) - card = anki.col.get_card(card_id) - if card: - existing_note = card.note() - return self._update_note(anki, existing_note) - except ValueError, TypeError: - console.print( - f"[yellow]Invalid card ID format: {self.cid}. Will create a new note.[/yellow]" - ) - except Exception as e: - console.print( - f"[yellow]Card with ID {self.cid} not found: {e}. Will create a new note.[/yellow]" - ) - - # If no existing note found or ID not provided, add as new return self.add_to_collection(anki) - def _update_note(self, anki: Anki, existing_note: Any) -> Note: + def _update_note(self, anki: Anki, existing_note: Any) -> NoteAddResult: """Update an existing note with new field values Returns: The updated note @@ -755,7 +742,7 @@ def _update_note(self, anki: Anki, existing_note: Any) -> Note: _ = anki.col.update_note(existing_note) anki.modified = True - return Note(anki, existing_note) + return (Note(anki, existing_note), "updated") def markdown_file_to_notes(filename: str) -> list[NoteData]: @@ -769,7 +756,7 @@ def markdown_file_to_notes(filename: str) -> list[NoteData]: markdown=x["markdown"], deck=x["deck"], nid=x["nid"], - cid=x["cid"], + external_id=x.get("external_id"), ) for x in _parse_markdown_file(filename) ] @@ -784,7 +771,7 @@ def markdown_file_to_notes(filename: str) -> list[NoteData]: def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: """Parse the content of a Markdown file - This must adhere to the specification of {add_from_file} from cli.py! + This must adhere to the specification of {update_from_file} from cli.py! """ defaults: dict[str, Any] = { "model": "Basic", @@ -792,7 +779,7 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: "tags": "", "deck": None, "nid": None, - "cid": None, + "external_ids_file": None, } with open(filename, "r", encoding="utf8") as f: for line in f: @@ -800,7 +787,7 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: if match: break - match = re.match(r"(\w+): (.*)", line) + match = re.match(r"([\w-]+): (.*)", line) if match: k, v = match.groups() k = k.lower() @@ -811,15 +798,29 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: defaults["markdown"] = v in ("true", "yes") elif k == "nid": defaults["nid"] = v - elif k == "cid": - defaults["cid"] = v + elif k == "external-ids": + defaults["external_ids_file"] = v else: defaults[k] = v + external_ids_map: dict[str, dict[str, Any]] = {} + if defaults["external_ids_file"]: + ids_file_path = Path(filename).parent / defaults["external_ids_file"] + if ids_file_path.exists(): + with open(ids_file_path, "r", encoding="utf8") as f: + external_ids_map = json.load(f) + notes: list[dict[str, Any]] = [] current_note: dict[str, Any] = {} current_field: str | None = None is_in_codeblock = False + + if defaults["external_ids_file"] and defaults["nid"]: + console.print( + "[red]Error: Cannot use nid in file header when external-ids is set.[/red]" + ) + raise Abort() + with open(filename, "r", encoding="utf8") as f: for line in f: if is_in_codeblock: @@ -844,7 +845,6 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: k = k.lower() v = v.strip() if k in ("tag", "tags"): - # Merge global tags with note-specific tags current_tags = current_note.get("tags", "").strip() if current_tags: current_note["tags"] = ( @@ -854,6 +854,18 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: current_note["tags"] = v.replace(",", "") elif k in ("markdown", "md"): current_note["markdown"] = v in ("true", "yes") + elif k == "id": + current_note["external_id"] = v + if defaults["external_ids_file"]: + if v in external_ids_map: + current_note["nid"] = str(external_ids_map[v]) + elif k == "nid": + if defaults["external_ids_file"]: + console.print( + f"[red]Error: Cannot use {k} in note when external-ids mode is active.[/red]" + ) + raise Abort() + current_note[k] = v else: current_note[k] = v @@ -874,6 +886,10 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: current_note = {"title": title, "fields": {}, **defaults} current_field = None + if defaults["external_ids_file"] and not current_note.get( + "external_id" + ): + current_note["external_id"] = str(uuid.uuid4()) continue if len(level) == 2: diff --git a/tests/data/basic.md b/tests/data/basic.md index 841119d..1ced479 100644 --- a/tests/data/basic.md +++ b/tests/data/basic.md @@ -3,6 +3,7 @@ model: Basic (type in the answer) # Basic note model: Basic +nid: 1776002781034 ## Front Question 1 @@ -12,6 +13,7 @@ Answer 1 # Basic note tags: cool, works +nid: 1776002781035 ## Front Question 2 diff --git a/tests/data/duplicate_test.md b/tests/data/duplicate_test.md new file mode 100644 index 0000000..15dd3f3 --- /dev/null +++ b/tests/data/duplicate_test.md @@ -0,0 +1,10 @@ +external-ids: external_ids.json + +# New Note +id: note1 + +## Front +Question 1 + +## Back +Answer 1 diff --git a/tests/data/duplicate_test_2.md b/tests/data/duplicate_test_2.md new file mode 100644 index 0000000..c9f3abc --- /dev/null +++ b/tests/data/duplicate_test_2.md @@ -0,0 +1,23 @@ +# Note One + +## Front +Question 1 + +## Back +Answer 1 + +# Note Two + +## Front +Question 1 + +## Back +Answer 2 + +# Note Two + +## Front +Question 2 + +## Back +Answer 2 diff --git a/tests/data/empty.md b/tests/data/empty.md index 9d5a6b4..388830f 100644 --- a/tests/data/empty.md +++ b/tests/data/empty.md @@ -1,9 +1,8 @@ model: Basic deck: Default -tags: # Note ## Front -## Back +## Back diff --git a/tests/data/external_ids.json b/tests/data/external_ids.json new file mode 100644 index 0000000..963690e --- /dev/null +++ b/tests/data/external_ids.json @@ -0,0 +1,4 @@ +{ + "note1": "1234567890123", + "note2": "1234567890124" +} diff --git a/tests/data/external_ids.md b/tests/data/external_ids.md new file mode 100644 index 0000000..637e775 --- /dev/null +++ b/tests/data/external_ids.md @@ -0,0 +1,19 @@ +external-ids: external_ids.json + +# Note One +id: note1 + +## Front +Question 1 + +## Back +Answer 1 + +# Note Two +id: note2 + +## Front +Question 2 + +## Back +Answer 2 diff --git a/tests/data/external_ids_auto.md b/tests/data/external_ids_auto.md new file mode 100644 index 0000000..b06ecc5 --- /dev/null +++ b/tests/data/external_ids_auto.md @@ -0,0 +1,18 @@ +external-ids: external_ids_auto.json + +# Note With ID +id: note1 + +## Front +Question 1 + +## Back +Answer 1 + +# Note Without ID + +## Front +Question 2 + +## Back +Answer 2 diff --git a/tests/data/external_ids_conflict.md b/tests/data/external_ids_conflict.md new file mode 100644 index 0000000..8a1b5a4 --- /dev/null +++ b/tests/data/external_ids_conflict.md @@ -0,0 +1,11 @@ +external-ids: external_ids.json + +# Note With Conflict +id: note1 +nid: 1234567890123 + +## Front +Question 1 + +## Back +Answer 1 diff --git a/tests/test_batch_edit.py b/tests/test_batch_edit.py index c8450f6..06c282f 100644 --- a/tests/test_batch_edit.py +++ b/tests/test_batch_edit.py @@ -103,7 +103,7 @@ def test_update_from_file(collection): ) # Update the note - updated_note = a.add_notes_from_file("test_update.md")[0] + updated_note = a.add_notes_from_file("test_update.md", respect_note_ids=True)[0] # Verify it's the same note but updated assert updated_note.n.id == note_id @@ -117,64 +117,6 @@ def test_update_from_file(collection): os.remove("test_update.md") -def test_update_from_file_by_cid(collection): - """Test updating a note from a Markdown file using card ID.""" - # First create a note - with open("test.md", "w") as f: - f.write( - textwrap.dedent( - """\ - model: Basic - tags: marked - - # Note 1 - ## Front - Original question? - - ## Back - Original answer. - """ - ) - ) - - with Anki(collection_db_path=collection) as a: - # Add initial note - note = a.add_notes_from_file("test.md")[0] - card_id = note.n.cards()[0].id - - # Now create update file with the card ID - with open("test_update_cid.md", "w") as f: - f.write( - textwrap.dedent( - f"""\ - model: Basic - tags: marked card-updated - cid: {card_id} - - # Note 1 - ## Front - Updated by card ID! - - ## Back - Updated answer via card ID. - """ - ) - ) - - # Update the note - updated_note = a.add_notes_from_file("test_update_cid.md")[0] - - # Verify it's the same note but updated - assert updated_note.n.id == note.n.id - assert sorted(updated_note.n.tags) == ["card-updated", "marked"] - assert "Updated by card ID!" in updated_note.n.fields[0] - assert "Updated answer via card ID." in updated_note.n.fields[1] - - # Clean up - os.remove("test.md") - os.remove("test_update_cid.md") - - def test_update_from_file_new_and_existing(collection): """Test updating a file with both new and existing notes.""" # First create a note @@ -231,7 +173,7 @@ def test_update_from_file_new_and_existing(collection): ) # Update the note - updated_notes = a.add_notes_from_file("test_mixed.md") + updated_notes = a.add_notes_from_file("test_mixed.md", respect_note_ids=True) # Verify we have two notes assert len(updated_notes) == 2 @@ -359,7 +301,11 @@ def test_update_file_with_mixed_notes(collection): ) # Update notes with update_file=True - notes = a.add_notes_from_file("test_update_mix.md", update_origin_file=True) + notes = a.add_notes_from_file( + "test_update_mix.md", + respect_note_ids=True, + update_origin_file=True, + ) # Verify two notes were affected assert len(notes) == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 82a1e59..0363508 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,8 @@ """Test the CLI""" -import tempfile +import json import shutil +import tempfile import pytest from click.testing import CliRunner @@ -27,24 +28,17 @@ def test_cli_base_directory(): assert result.exit_code == 0 -# List of files that apy add-from-file should be able to successfully parse -note_files_input = [ - "basic.md", - "empty.md", -] - - -@pytest.mark.parametrize( - "note_files", [test_data_dir + file for file in note_files_input] -) -def test_cli_update_from_file(note_files): +@pytest.mark.parametrize("infile", ["basic.md", "empty.md"]) +def test_cli_update_from_file(infile): """Test 'apy update-from-file' for various note file inputs.""" runner = CliRunner() with tempfile.TemporaryDirectory() as tmpdirname: shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) - result = runner.invoke(main, ["-b", tmpdirname, "update-from-file", note_files]) - + shutil.copy(test_data_dir + infile, tmpdirname) + result = runner.invoke( + main, ["-b", tmpdirname, "update-from-file", f"{tmpdirname}/{infile}"] + ) assert result.exit_code == 0 @@ -80,3 +74,166 @@ def test_cli_add_single(): ], ) assert result.exit_code == 0 + + +def test_cli_update_file_with_duplicates(): + """Test duplicate behaviour with update-from-file""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "duplicate_test_2.md", tmpdirname) + + filename = tmpdirname + "/duplicate_test_2.md" + result = runner.invoke( + main, + ["-b", tmpdirname, "update-from-file", filename], + ) + + assert result.exit_code == 0 + assert "Dupe detected" in result.output + + with open(filename, "r") as f: + updated_content = f.readlines() + + nid_lines = [line for line in updated_content if "nid:" in line] + assert len(nid_lines) == 2 + + +def test_external_ids_mode(): + """Test update-from-file with external IDs mode.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "external_ids.md", tmpdirname) + shutil.copy(test_data_dir + "external_ids.json", tmpdirname) + + result = runner.invoke( + main, + ["-b", tmpdirname, "update-from-file", tmpdirname + "/external_ids.md"], + ) + + assert result.exit_code == 0 + assert "Updated/added" in result.output + assert "nid:" in result.output + + +def test_external_ids_missing_id_header(): + """Test auto-generation of UUID when id header missing.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "external_ids_auto.md", tmpdirname) + + result = runner.invoke( + main, + [ + "-b", + tmpdirname, + "update-from-file", + tmpdirname + "/external_ids_auto.md", + ], + ) + + assert result.exit_code == 0 + assert "Updated/added" in result.output + assert "nid:" in result.output + + +def test_external_ids_conflict_error(): + """Test error when mixing external-ids with nid/cid headers.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "external_ids_conflict.md", tmpdirname) + + result = runner.invoke( + main, + [ + "-b", + tmpdirname, + "update-from-file", + tmpdirname + "/external_ids_conflict.md", + ], + ) + + assert result.exit_code != 0 + assert "Cannot use" in result.output + + +def test_external_ids_update_file(): + """Test that update-from-file automatically updates the JSON file.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "external_ids.md", tmpdirname) + + json_file = tmpdirname + "/external_ids.json" + with open(json_file, "w") as f: + json.dump({}, f) + + result = runner.invoke( + main, + ["-b", tmpdirname, "update-from-file", tmpdirname + "/external_ids.md"], + ) + + assert result.exit_code == 0 + + with open(json_file, "r") as f: + updated_ids = json.load(f) + + assert len(updated_ids) > 0 + assert "note1" in updated_ids + assert "note2" in updated_ids + assert updated_ids["note1"] != "" + assert updated_ids["note2"] != "" + + +def test_link_duplicates(): + """Test that --link-duplicates updates IDs file with existing nid on duplicate.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + shutil.copy(test_data_dir + "duplicate_test.md", tmpdirname) + + external_ids_file = tmpdirname + "/external_ids.json" + with open(external_ids_file, "w") as f: + json.dump({}, f) + + result1 = runner.invoke( + main, + ["-b", tmpdirname, "update-from-file", tmpdirname + "/duplicate_test.md"], + ) + assert result1.exit_code == 0 + + with open(external_ids_file, "r") as f: + ids_after_first = json.load(f) + + assert "note1" in ids_after_first + original_nid = ids_after_first["note1"] + + with open(external_ids_file, "w") as f: + json.dump({}, f) + + result2 = runner.invoke( + main, + [ + "-b", + tmpdirname, + "update-from-file", + "-l", + tmpdirname + "/duplicate_test.md", + ], + ) + assert result2.exit_code == 0 + assert "Dupe detected" in result2.output + + with open(external_ids_file, "r") as f: + ids_after_link = json.load(f) + + assert ids_after_link["note1"] == original_nid