diff --git a/README.md b/README.md index eed1e8c..2b457c9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Anki. It does not require Anki to be running at the same time. * [Usage](#usage) * [Configuration](#configuration) * [Zsh completion](#zsh-completion) +* [fish completion](#fish-completion) * [Changelog](#changelog) * [Relevant resources](#relevant-resources) * [Alternatives](#alternatives) @@ -173,6 +174,15 @@ Then add the following line to ones `.zshrc` file: fpath=($HOME/.local/zsh-functions $fpath) ``` +## Fish completion + +There is also a fish completion file available. To use it, one may symlink or +copy it to `~/.config/fish/completions/` directory: + +``` +ln -s /path/to/apy/completion/apy.fish ~/.config/fish/completions/ +``` + ## Changelog See the [release history on GitHub](https://github.com/lervag/apy/releases). diff --git a/completion/_apy b/completion/_apy index 7659eff..812f1f3 100644 --- a/completion/_apy +++ b/completion/_apy @@ -47,7 +47,8 @@ _apy() { subcmds=( \ 'add:Add notes interactively from terminal' \ 'add-single:Add a single note from command line arguments' \ - 'add-from-file:Add notes from Markdown file For input file' \ + 'add-from-file:Add notes from Markdown file (alias for update-from-file)' \ + 'update-from-file:Update existing or add new notes from Markdown file' \ 'check-media:Check media' \ 'info:Print some basic statistics' \ 'model:Interact with the models' \ @@ -82,10 +83,12 @@ _apy() { '::Fields' \ $opts_help \ );; - add-from-file) + add-from-file|update-from-file) opts=( \ '::Markdown input file:_files -g "*.md"' \ '(-t --tags)'{-t,--tags}'[Specify tags]:tags:' \ + '(-d --deck)'{-d,--deck}'[Specify deck]:deck:' \ + '(-u --update-file)'{-u,--update-file}'[Update original file with note IDs]' \ $opts_help \ );; info) diff --git a/completion/apy.fish b/completion/apy.fish new file mode 100644 index 0000000..9c19462 --- /dev/null +++ b/completion/apy.fish @@ -0,0 +1,106 @@ +# Fish shell completion for apy +# Copy this file to ~/.config/fish/completions/ + +function __fish_apy_no_subcommand + set -l cmd (commandline -opc) + if [ (count $cmd) -eq 1 ] + return 0 + end + return 1 +end + +function __fish_apy_using_command + set -l cmd (commandline -opc) + if [ (count $cmd) -gt 1 ] + if [ $argv[1] = $cmd[2] ] + return 0 + end + end + return 1 +end + +# Main apy command +complete -f -c apy -n '__fish_apy_no_subcommand' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_no_subcommand' -l base-path -s b -d 'Set Anki base directory' -a '(__fish_complete_directories)' +complete -f -c apy -n '__fish_apy_no_subcommand' -l profile-name -s p -d 'Specify name of Anki profile to use' +complete -f -c apy -n '__fish_apy_no_subcommand' -l version -s V -d 'Show apy version' + +# Subcommands +complete -f -c apy -n '__fish_apy_no_subcommand' -a add -d 'Add notes interactively from terminal' +complete -f -c apy -n '__fish_apy_no_subcommand' -a add-single -d 'Add a single note from command line arguments' +complete -f -c apy -n '__fish_apy_no_subcommand' -a add-from-file -d 'Add notes from Markdown file (alias for update-from-file)' +complete -f -c apy -n '__fish_apy_no_subcommand' -a update-from-file -d 'Update existing or add new notes from Markdown file' +complete -f -c apy -n '__fish_apy_no_subcommand' -a check-media -d 'Check media' +complete -f -c apy -n '__fish_apy_no_subcommand' -a info -d 'Print some basic statistics' +complete -f -c apy -n '__fish_apy_no_subcommand' -a model -d 'Interact with the models' +complete -f -c apy -n '__fish_apy_no_subcommand' -a list -d 'Print cards that match query' +complete -f -c apy -n '__fish_apy_no_subcommand' -a review -d 'Review/Edit notes that match query' +complete -f -c apy -n '__fish_apy_no_subcommand' -a reposition -d 'Reposition new card with given CID' +complete -f -c apy -n '__fish_apy_no_subcommand' -a sync -d 'Synchronize collection with AnkiWeb' +complete -f -c apy -n '__fish_apy_no_subcommand' -a tag -d 'Add or remove tags from notes that match query' +complete -f -c apy -n '__fish_apy_no_subcommand' -a edit -d 'Edit notes that match query directly' +complete -f -c apy -n '__fish_apy_no_subcommand' -a backup -d 'Backup Anki database to specified target file' + +# add options +complete -f -c apy -n '__fish_apy_using_command add' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command add' -l tags -s t -d 'Specify default tags for new cards' +complete -f -c apy -n '__fish_apy_using_command add' -l model -s m -d 'Specify default model for new cards' +complete -f -c apy -n '__fish_apy_using_command add' -l deck -s d -d 'Specify default deck for new cards' + +# add-single options +complete -f -c apy -n '__fish_apy_using_command add-single' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command add-single' -l parse-markdown -s p -d 'Parse input as Markdown' +complete -f -c apy -n '__fish_apy_using_command add-single' -l preset -s s -d 'Specify a preset' +complete -f -c apy -n '__fish_apy_using_command add-single' -l tags -s t -d 'Specify default tags for new cards' +complete -f -c apy -n '__fish_apy_using_command add-single' -l model -s m -d 'Specify default model for new cards' +complete -f -c apy -n '__fish_apy_using_command add-single' -l deck -s d -d 'Specify default deck for new cards' + +# add-from-file and update-from-file options +for cmd in add-from-file update-from-file + complete -f -c apy -n "__fish_apy_using_command $cmd" -l help -s h -d 'Show help' + complete -f -c apy -n "__fish_apy_using_command $cmd" -l tags -s t -d 'Specify default tags for cards' + complete -f -c apy -n "__fish_apy_using_command $cmd" -l deck -s d -d 'Specify default deck for cards' + complete -f -c apy -n "__fish_apy_using_command $cmd" -l update-file -s u -d 'Update original file with note IDs' + # File argument + complete -f -c apy -n "__fish_apy_using_command $cmd" -k -a "(__fish_complete_suffix .md)" +end + +# list options +complete -f -c apy -n '__fish_apy_using_command list' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command list' -l show-answer -s a -d 'Display answer' +complete -f -c apy -n '__fish_apy_using_command list' -l show-model -s m -d 'Display model' +complete -f -c apy -n '__fish_apy_using_command list' -l show-cid -s c -d 'Display card ids' +complete -f -c apy -n '__fish_apy_using_command list' -l show-due -s d -d 'Display card due time in days' +complete -f -c apy -n '__fish_apy_using_command list' -l show-type -s t -d 'Display card type' +complete -f -c apy -n '__fish_apy_using_command list' -l show-ease -s e -d 'Display card ease' +complete -f -c apy -n '__fish_apy_using_command list' -l show-lapses -s l -d 'Display card number of lapses' + +# tag options +complete -f -c apy -n '__fish_apy_using_command tag' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command tag' -l add-tags -s a -d 'Add specified tags to matched notes' +complete -f -c apy -n '__fish_apy_using_command tag' -l remove-tags -s r -d 'Remove specified tags from matched notes' +complete -f -c apy -n '__fish_apy_using_command tag' -l sort-by-count -s c -d 'When listing tags, sort by note count' +complete -f -c apy -n '__fish_apy_using_command tag' -l purge -s p -d 'Remove all unused tags' + +# review options +complete -f -c apy -n '__fish_apy_using_command review' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command review' -l check-markdown-consistency -s m -d 'Check for Markdown consistency' +complete -f -c apy -n '__fish_apy_using_command review' -l cmc-range -s n -d 'Number of days backwards to check consistency' + +# edit options +complete -f -c apy -n '__fish_apy_using_command edit' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command edit' -l force-multiple -s f -d 'Allow editing multiple notes' + +# backup options +complete -f -c apy -n '__fish_apy_using_command backup' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command backup' -l include-media -s m -d 'Include media files in backup' +complete -f -c apy -n '__fish_apy_using_command backup' -l legacy -s l -d 'Support older Anki versions' + +# model subcommands +complete -f -c apy -n '__fish_apy_using_command model' -a edit-css -d 'Edit the CSS template for the specified model' +complete -f -c apy -n '__fish_apy_using_command model' -a rename -d 'Rename model from old_name to new_name' + +# model edit-css options +complete -f -c apy -n '__fish_apy_using_command model; and __fish_seen_subcommand_from edit-css' -l help -s h -d 'Show help' +complete -f -c apy -n '__fish_apy_using_command model; and __fish_seen_subcommand_from edit-css' -l model-name -s m -d 'Specify for which model to edit CSS template' +complete -f -c apy -n '__fish_apy_using_command model; and __fish_seen_subcommand_from edit-css' -l sync-after -s s -d 'Perform sync after any change' \ No newline at end of file diff --git a/src/apyanki/anki.py b/src/apyanki/anki.py index c271fa9..188cde6 100644 --- a/src/apyanki/anki.py +++ b/src/apyanki/anki.py @@ -4,6 +4,7 @@ import os from pathlib import Path import pickle +import re import sqlite3 import tempfile import time @@ -506,11 +507,139 @@ def add_notes_with_editor( return self.add_notes_from_file(tf.name) def add_notes_from_file( - self, filename: str, tags: str = "", deck: Optional[str] = None + self, + filename: str, + tags: str = "", + deck: Optional[str] = None, + update_file: bool = False, ) -> list[Note]: - """Add new notes to collection from Markdown file""" - notes = markdown_file_to_notes(filename) - return self.add_notes_from_list(notes, tags, deck) + """Add new notes to collection from Markdown file + + Args: + filename: Path to the markdown file containing notes + tags: Additional tags to add to the notes + deck: Default deck for notes without a deck specified + update_file: If True, update the original file with note IDs + + Returns: + List of notes that were added + """ + # Reuse update_notes_from_file since it handles both adding new notes and updating existing ones + # For add_notes_from_file, we're essentially just adding new notes + return self.update_notes_from_file(filename, tags, deck, update_file) + + def update_notes_from_file( + self, + filename: str, + tags: str = "", + deck: Optional[str] = None, + update_file: bool = False, + ) -> list[Note]: + """Update existing notes or add new notes from Markdown file + + This function looks for nid: or cid: headers in the file to determine + if a note should be updated rather than added. + + Args: + filename: Path to the markdown file containing notes + tags: Additional tags to add to the notes + deck: Default deck for notes without a deck specified + update_file: If True, update the original file with note IDs + + Returns: + List of notes that were updated or added + """ + with open(filename, "r", encoding="utf-8") as f: + original_content = f.read() + + notes_data = markdown_file_to_notes(filename) + updated_notes = [] + + # Track if any notes were added that need IDs + needs_update = False + + for note_data in notes_data: + if tags: + note_data.tags = f"{tags} {note_data.tags}" + + if deck and not note_data.deck: + note_data.deck = deck + + # Check if this note already has an ID + had_id = bool(note_data.nid) + + note = note_data.update_or_add_to_collection(self) + updated_notes.append(note) + + # Mark for file update if this was a new note without an ID + if not had_id and update_file: + needs_update = True + + # Update the original file with note IDs if requested + if update_file and needs_update: + self._update_file_with_note_ids(filename, original_content, updated_notes) + + return updated_notes + + def _update_file_with_note_ids( + self, filename: str, content: str, notes: list[Note] + ) -> None: + """Update the original markdown file with note IDs + + This function adds nid: headers to notes in the file that don't have them. + + Args: + filename: Path to the markdown file + content: Original content of the file + notes: List of notes that were added/updated + """ + # Find all '# Note' or similar headers in the file + note_headers = re.finditer(r"^# .*$", content, re.MULTILINE) + note_positions = [match.start() for match in note_headers] + + if not note_positions: + return # No notes found in file + + # Add an extra position at the end to simplify boundary handling + note_positions.append(len(content)) + + # Extract each note's section and check if it needs to be updated + updated_content = [] + for i in range(len(note_positions) - 1): + start = note_positions[i] + end = note_positions[i + 1] + + # Get the section for this note + section = content[start:end] + + # Check if this section already has an nid + if re.search(r"^nid:", section, re.MULTILINE): + # Already has an ID, keep as is + updated_content.append(section) + else: + # No ID, add the note ID from our updated notes + # We need to find where to insert the ID line (after model, tags, etc.) + lines = section.split("\n") + + # Find a good position to insert the ID (after model, tags, deck) + insert_pos = 1 # Default: after the first line (the title) + for j, line in enumerate(lines[1:], 1): + # Look for model:, tags:, deck: lines + if re.match(r"^(model|tag[s]?|deck|markdown|md):", line): + 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}") + updated_content.append("\n".join(lines)) + else: + # Couldn't match this section to a note, keep unchanged + updated_content.append(section) + + # Write back the updated content + with open(filename, "w", encoding="utf-8") as f: + f.write("".join(updated_content)) def add_notes_from_list( self, diff --git a/src/apyanki/cli.py b/src/apyanki/cli.py index 426526c..f17e99e 100644 --- a/src/apyanki/cli.py +++ b/src/apyanki/cli.py @@ -133,86 +133,123 @@ def add(tags: str, model_name: str, deck: str) -> None: _added_notes_postprocessing(a, notes) -@main.command("add-from-file") +@main.command("update-from-file") @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.") -def add_from_file(file: Path, tags: str, deck: str) -> None: - """Add notes from Markdown file. +@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." +) +def update_from_file(file: Path, tags: str, deck: str, update_file: 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. - The example below should adequately specify the syntax. Any initial "key: value" - pairs specify default values for all the following notes. The following keys are - accepted: + The syntax is similar to add-from-file, but with two additional keys: \b - * model: The note model (required) - * tags: The note model (optional) - * deck: Which deck the note should be added to (optional) - * markdown: Set to "false" or "no" if apy should not use a markdown converter - while converting the input note to an Anki note. (optional) + * nid: The note ID to update (optional) + * cid: The card ID to update (optional, used if nid is not provided) - Here is the example Markdown input: + If neither nid nor cid is provided, a new note will be created. + + Here is an example Markdown input for updating: // example.md model: Basic tags: marked + nid: 1619153168151 # Note 1 ## Front - Question? + Updated question? ## Back - Answer. + Updated answer. # Note 2 - tag: silly-tag + cid: 1619153168152 ## Front - Question? + Another updated question? ## Back - Answer + Another updated answer. # Note 3 - model: NewModel - markdown: false (default is true) + model: Basic - ## NewFront - FieldOne + ## Front + This will be a new note (no ID provided) - ## NewBack - FieldTwo + ## Back + New note content + """ + with Anki(**cfg) as a: + notes = a.update_notes_from_file(str(file), tags, deck, update_file) + _added_notes_postprocessing(a, notes) - ## FieldThree - FieldThree + +# Create an alias for backward compatibility +@main.command("add-from-file") +@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. + + With the --update-file option, the original file will be updated to include + note IDs for any new notes added. + + This command is an alias for update-from-file, which can both add new notes + and update existing ones. """ with Anki(**cfg) as a: - notes = a.add_notes_from_file(str(file), tags, deck) + notes = a.update_notes_from_file(str(file), tags, deck, update_file) _added_notes_postprocessing(a, notes) def _added_notes_postprocessing(a: Anki, notes: list[Note]) -> None: - """Common postprocessing after 'apy add[-from-file]'.""" + """Common postprocessing after 'apy add[-from-file]' or 'apy update-from-file'.""" n_notes = len(notes) if n_notes == 0: - console.print("No notes added") + console.print("No notes added or updated") return decks = [a.col.decks.name(c.did) for n in notes for c in n.n.cards()] - n_decks = len(decks) + n_decks = len(set(decks)) if n_decks == 0: - console.print("No notes added") + 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"Added note to deck: {decks[0]}") + console.print(f"{action_word} note to deck: {decks[0]}") elif n_decks > 1: - console.print(f"Added {n_notes} notes to {n_decks} different decks") + console.print(f"{action_word} {n_notes} notes to {n_decks} different decks") else: - console.print(f"Added {n_notes} notes to deck: {decks[0]}") + console.print(f"{action_word} {n_notes} notes to deck: {decks[0]}") else: - console.print(f"Added {n_notes} notes") + console.print(f"{action_word} {n_notes} notes") for note in notes: cards = note.n.cards() diff --git a/src/apyanki/note.py b/src/apyanki/note.py index 89ff8dd..87e3726 100644 --- a/src/apyanki/note.py +++ b/src/apyanki/note.py @@ -17,6 +17,10 @@ from rich.table import Table from rich.text import Text +if TYPE_CHECKING: + from anki.notes import NoteId + from anki.cards import CardId + from apyanki import cards from apyanki.config import cfg from apyanki.console import console, consolePlain @@ -58,6 +62,7 @@ def __repr__(self) -> str: "# Note", f"model: {self.model_name}", f"tags: {self.get_tag_string()}", + f"nid: {self.n.id}", ] if self.a.n_decks > 1: @@ -189,6 +194,7 @@ def edit(self) -> None: with tempfile.NamedTemporaryFile( mode="w+", dir=os.getcwd(), prefix="edit_note_", suffix=".md" ) as tf: + # Write the note content (includes note ID from __repr__) tf.write(str(self)) tf.flush() @@ -203,29 +209,43 @@ def edit(self) -> None: console.print("[red]Something went wrong when editing note![/red]") return + # Handle additional notes created during editing if len(notes) > 1: added_notes = self.a.add_notes_from_list(notes[1:]) - console.print( - f"[green]Added {len(added_notes)} new notes while editing.[/green]" - ) - console.wait_for_keypress() + if added_notes: + console.print( + f"[green]Added {len(added_notes)} new notes while editing.[/green]" + ) + for added_note in added_notes: + cards = added_note.n.cards() + console.print(f"* nid: {added_note.n.id} (with {len(cards)} cards)") + console.wait_for_keypress() + # Update the current note from the first note in the file note = notes[0] + # Update tags if changed new_tags = note.tags.split() - if new_tags != self.n.tags: + if sorted(new_tags) != sorted(self.n.tags): self.n.tags = new_tags + # Update deck if changed if note.deck is not None and note.deck != self.get_deck(): self.set_deck(note.deck) + # Update fields if changed for i, text in enumerate(note.fields.values()): - self.n.fields[i] = convert_text_to_field(text, use_markdown=note.markdown) + new_field = convert_text_to_field(text, use_markdown=note.markdown) + if new_field != self.n.fields[i]: + self.n.fields[i] = new_field + # Save changes self.a.col.update_note(self.n) self.a.modified = True + + # Check for duplication issues if self.n.dupeOrEmpty(): - console.print("The updated note is now a dupe!") + console.print("[red]Warning: The updated note is now a dupe![/red]") console.wait_for_keypress() def delete(self) -> None: @@ -553,6 +573,8 @@ class NoteData: fields: dict[str, str] markdown: bool = True deck: Optional[str] = None + nid: Optional[str] = None + cid: Optional[str] = None def add_to_collection(self, anki: Anki) -> Note: """Add note to collection @@ -597,6 +619,111 @@ def add_to_collection(self, anki: Anki) -> Note: return Note(anki, new_note) + def update_or_add_to_collection(self, anki: Anki) -> Note: + """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)) + existing_note = anki.col.get_note(note_id) + return self._update_note(anki, existing_note) + except (ValueError, TypeError): + console.print( + f"[yellow]Invalid note ID format: {self.nid}. Will create a new note.[/yellow]" + ) + except Exception as e: + console.print( + 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: + """Update an existing note with new field values + + Returns: The updated note + """ + # Verify model match + note_type = existing_note.note_type() + if note_type and note_type["name"] != self.model: + console.print( + f"[yellow]Warning: Model mismatch. File specifies '{self.model}', note has '{note_type['name']}'.[/yellow]" + ) + if not console.confirm("Continue with update anyway?"): + console.print( + "[yellow]Update canceled. Adding as new note instead.[/yellow]" + ) + return self.add_to_collection(anki) + + # Update tags + existing_note.tags = self.tags.strip().split() + + # Update deck if specified + if self.deck is not None: + try: + # Get first card and update its deck + cards = existing_note.cards() + if cards: + # Explicitly cast to int to satisfy mypy + deck_id = anki.deck_name_to_id.get(self.deck, None) # type: ignore + if deck_id is not None: # Make sure deck_id exists and is not None + card_ids = [c.id for c in cards] + anki.col.set_deck(card_ids, deck_id) + except Exception as e: + console.print(f"[yellow]Failed to update deck: {e}[/yellow]") + + # Update fields + field_names = list(existing_note.keys()) + for i, field_name in enumerate(field_names): + # Match field names from the file to the existing note + matching_field = None + for file_field_name, content in self.fields.items(): + clean_name = file_field_name.replace(" (markdown)", "") + if clean_name.lower() == field_name.lower(): + matching_field = content + break + + if matching_field is not None: + existing_note.fields[i] = convert_text_to_field( + matching_field, use_markdown=self.markdown + ) + + # Save the updated note + anki.col.update_note(existing_note) + anki.modified = True + + return Note(anki, existing_note) + def markdown_file_to_notes(filename: str) -> list[NoteData]: """Parse note data from a Markdown file""" @@ -608,6 +735,8 @@ def markdown_file_to_notes(filename: str) -> list[NoteData]: fields=x["fields"], markdown=x["markdown"], deck=x["deck"], + nid=x["nid"], + cid=x["cid"], ) for x in _parse_markdown_file(filename) ] @@ -629,6 +758,8 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: "markdown": True, "tags": "", "deck": None, + "nid": None, + "cid": None, } with open(filename, "r", encoding="utf8") as f: for line in f: @@ -645,6 +776,10 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: defaults["tags"] = v.replace(",", "") elif k in ("markdown", "md"): defaults["markdown"] = v in ("true", "yes") + elif k == "nid": + defaults["nid"] = v + elif k == "cid": + defaults["cid"] = v else: defaults[k] = v @@ -676,7 +811,14 @@ def _parse_markdown_file(filename: str) -> list[dict[str, Any]]: k = k.lower() v = v.strip() if k in ("tag", "tags"): - current_note["tags"] = v.replace(",", "") + # Merge global tags with note-specific tags + current_tags = current_note.get("tags", "").strip() + if current_tags: + current_note["tags"] = ( + f"{current_tags} {v.replace(',', '')}" + ) + else: + current_note["tags"] = v.replace(",", "") elif k in ("markdown", "md"): current_note["markdown"] = v in ("true", "yes") else: diff --git a/tests/common.py b/tests/common.py index 521cde6..bea9e77 100644 --- a/tests/common.py +++ b/tests/common.py @@ -4,12 +4,26 @@ import os import tempfile import shutil +import pytest from apyanki.anki import Anki testDir = os.path.dirname(__file__) +@pytest.fixture +def collection(): + """Create a temporary Anki collection for testing.""" + tmppath = os.path.join(tempfile.gettempdir(), "tempfile_test.anki2") + shutil.copy2(testDir + "/data/test_base/Test/collection.anki2", tmppath) + + yield tmppath + + # Clean up after test + if os.path.exists(tmppath): + os.remove(tmppath) + + class AnkiTest: """Create Anki collection wrapper""" diff --git a/tests/test_batch_edit.py b/tests/test_batch_edit.py index 803c543..ac25d60 100644 --- a/tests/test_batch_edit.py +++ b/tests/test_batch_edit.py @@ -1,8 +1,11 @@ """Test batch editing""" +import os import pytest +import textwrap -from common import testDir, AnkiSimple +from common import testDir, AnkiSimple, collection +from apyanki.anki import Anki pytestmark = pytest.mark.filterwarnings("ignore") @@ -20,3 +23,355 @@ def test_change_tags(): a.change_tags(query, "test", add=False) assert len(list(a.find_notes(query))) == 0 + + +def test_add_from_file(collection): + """Test adding a note from a Markdown file.""" + with open("test.md", "w") as f: + f.write( + textwrap.dedent( + """ + model: Basic + tags: marked + + # Note 1 + ## Front + Question? + + ## Back + Answer. + """ + ) + ) + + with Anki(collection_db_path=collection) as a: + # Add note + note = a.add_notes_from_file("test.md")[0] + assert note.n is not None + assert note.model_name == "Basic" + assert note.n.tags == ["marked"] + assert "Question?" in note.n.fields[0] + assert "Answer." in note.n.fields[1] + + # Clean up + os.remove("test.md") + + +def test_update_from_file(collection): + """Test updating a note from a Markdown file.""" + # 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] + note_id = note.n.id + + # Now create update file with the note ID + with open("test_update.md", "w") as f: + f.write( + textwrap.dedent( + f"""\ + model: Basic + tags: marked updated + nid: {note_id} + + # Note 1 + ## Front + Updated question? + + ## Back + Updated answer. + """ + ) + ) + + # Update the note + updated_note = a.update_notes_from_file("test_update.md")[0] + + # Verify it's the same note but updated + assert updated_note.n.id == note_id + assert updated_note.model_name == "Basic" + assert sorted(updated_note.n.tags) == ["marked", "updated"] + assert "Updated question?" in updated_note.n.fields[0] + assert "Updated answer." in updated_note.n.fields[1] + + # Clean up + os.remove("test.md") + 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.update_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 + 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] + note_id = note.n.id + + # Now create update file with both the existing note and a new note + with open("test_mixed.md", "w") as f: + f.write( + textwrap.dedent( + f"""\ + model: Basic + tags: common-tag + + # Existing Note + nid: {note_id} + tags: existing-updated + + ## Front + Updated existing note. + + ## Back + Updated content. + + # New Note + tags: new-note + + ## Front + This is a new note. + + ## Back + Brand new content. + """ + ) + ) + + # Update the note + updated_notes = a.update_notes_from_file("test_mixed.md") + + # Verify we have two notes + assert len(updated_notes) == 2 + + # Find the existing and new notes + existing_note = next((n for n in updated_notes if n.n.id == note_id), None) + new_note = next((n for n in updated_notes if n.n.id != note_id), None) + + # Verify existing note was updated + assert existing_note is not None + assert sorted(existing_note.n.tags) == ["common-tag", "existing-updated"] + assert "Updated existing note." in existing_note.n.fields[0] + assert "Updated content." in existing_note.n.fields[1] + + # Verify new note was created + assert new_note is not None + assert sorted(new_note.n.tags) == ["common-tag", "new-note"] + assert "This is a new note." in new_note.n.fields[0] + assert "Brand new content." in new_note.n.fields[1] + + # Clean up + os.remove("test.md") + os.remove("test_mixed.md") + + +def test_update_file_with_note_ids(collection): + """Test that --update-file option updates the original file with note IDs.""" + # First create a note file without IDs + with open("test_no_ids.md", "w") as f: + f.write( + textwrap.dedent( + """\ + model: Basic + tags: test-update-file + + # Note 1 + ## Front + Test question for auto-update + + ## Back + Test answer for auto-update + + # Note 2 + ## Front + Another test question + + ## Back + Another test answer + """ + ) + ) + + with Anki(collection_db_path=collection) as a: + # Add notes with update_file=True + notes = a.add_notes_from_file("test_no_ids.md", update_file=True) + + # Verify two notes were added + assert len(notes) == 2 + + # Read the file again to check if IDs were added + with open("test_no_ids.md", "r") as f: + updated_content = f.read() + + # The file should now contain nid: lines + assert f"nid: {notes[0].n.id}" in updated_content + assert f"nid: {notes[1].n.id}" in updated_content + + # Clean up + os.remove("test_no_ids.md") + + +def test_update_file_with_mixed_notes(collection): + """Test that --update-file option updates only new notes in update-from-file.""" + # First create a note to get its ID + with open("test_initial.md", "w") as f: + f.write( + textwrap.dedent( + """\ + model: Basic + tags: initial-note + + # Initial Note + ## Front + Initial question + + ## Back + Initial answer + """ + ) + ) + + with Anki(collection_db_path=collection) as a: + # Add the initial note + initial_note = a.add_notes_from_file("test_initial.md")[0] + note_id = initial_note.n.id + + # Now create a file with the existing note ID and a new note + with open("test_update_mix.md", "w") as f: + f.write( + textwrap.dedent( + f"""\ + model: Basic + tags: common-tag + + # Existing Note + nid: {note_id} + tags: update-note + + ## Front + Updated question text + + ## Back + Updated answer text + + # New Note Without ID + tags: new-note-tag + + ## Front + New question without ID + + ## Back + New answer without ID + """ + ) + ) + + # Update notes with update_file=True + notes = a.update_notes_from_file("test_update_mix.md", update_file=True) + + # Verify two notes were affected + assert len(notes) == 2 + + # Read the updated file + with open("test_update_mix.md", "r") as f: + updated_content = f.read() + + # Verify the original ID is preserved and the new note got an ID + new_note = next(n for n in notes if n.n.id != note_id) + assert f"nid: {note_id}" in updated_content # Original ID + assert f"nid: {new_note.n.id}" in updated_content # New ID + + # Clean up + os.remove("test_initial.md") + os.remove("test_update_mix.md") diff --git a/tests/test_cli.py b/tests/test_cli.py index 04b059f..82a1e59 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,13 +37,27 @@ def test_cli_base_directory(): @pytest.mark.parametrize( "note_files", [test_data_dir + file for file in note_files_input] ) -def test_cli_add_from_file(note_files): - """Test 'apy add-from-file' for various note file inputs.""" +def test_cli_update_from_file(note_files): + """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, "add-from-file", note_files]) + result = runner.invoke(main, ["-b", tmpdirname, "update-from-file", note_files]) + + assert result.exit_code == 0 + + +def test_cli_add_from_file_alias(): + """Test that 'apy add-from-file' works as an alias for 'update-from-file'.""" + runner = CliRunner() + + with tempfile.TemporaryDirectory() as tmpdirname: + shutil.copytree(test_collection_dir, tmpdirname, dirs_exist_ok=True) + # Should work as an alias to update-from-file + result = runner.invoke( + main, ["-b", tmpdirname, "add-from-file", test_data_dir + "basic.md"] + ) assert result.exit_code == 0