Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 69 additions & 12 deletions src/apyanki/anki.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import os
import pickle
import re
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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}"

Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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

Expand All @@ -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]
168 changes: 121 additions & 47 deletions src/apyanki/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import sys
from pathlib import Path
from typing import Any
from typing import Any, Literal

import click

Expand Down Expand Up @@ -130,49 +130,52 @@ 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")
@click.argument("file", type=click.Path(exists=True, dir_okay=False))
@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?

## Back
Updated answer.

# Note 2
cid: 1619153168152
nid: 1619153168152

## Front
Another updated question?
Expand All @@ -188,35 +191,115 @@ 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
@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.
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:
Expand All @@ -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]}")
Expand All @@ -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")
Expand Down
Loading
Loading