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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 5 additions & 2 deletions completion/_apy
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down Expand Up @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions completion/apy.fish
Original file line number Diff line number Diff line change
@@ -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'
137 changes: 133 additions & 4 deletions src/apyanki/anki.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
from pathlib import Path
import pickle
import re
import sqlite3
import tempfile
import time
Expand Down Expand Up @@ -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,
Expand Down
Loading