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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- run: uv sync --group dev

- name: Ruff lint
run: uv run ruff check src/ tests/

- name: Ruff format
run: uv run ruff format --check src/ tests/

- name: Tests
run: uv run pytest tests/ -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ __pycache__/
*.egg-info/
dist/
.venv/
.ruff_cache/
.claude/*
!.claude/skills/
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ dependencies = [
dev = [
"ruff>=0.8",
"pre-commit>=4",
"pytest>=8",
]

[project.scripts]
anki = "anki_cli.cli:app"
anki = "anki_cli.cli:main"

[build-system]
requires = ["hatchling"]
Expand Down
53 changes: 42 additions & 11 deletions src/anki_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,23 @@
from rich.panel import Panel
from rich.table import Table

from anki_cli.client import AnkiClient, AnkiError
from anki_cli.client import AnkiClient, AnkiConnectionError, AnkiError

app = typer.Typer(no_args_is_help=True)
console = Console()


def main() -> None:
try:
app()
except AnkiConnectionError as exc:
console.print(f"[red]{exc}[/red]")
raise SystemExit(1) from None
except AnkiError as exc:
console.print(f"[red]Anki error:[/red] {exc}")
raise SystemExit(1) from None


def _parse_fields(raw: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for f in raw:
Expand Down Expand Up @@ -91,6 +102,8 @@ def _fuzzy_score(query: str, note: dict[str, Any]) -> float:
if not words:
return 0.0
query_words = query.lower().split()
if not query_words:
return 0.0
return sum(max(fuzz.ratio(qw, w) for w in words) for qw in query_words) / len(query_words)


Expand Down Expand Up @@ -130,6 +143,10 @@ def search(
fuzzy: Annotated[bool, typer.Option("--fuzzy", "-f", help="Fuzzy text search")] = False,
) -> None:
"""Search notes using Anki query syntax."""
if fuzzy and not query.strip():
console.print("[red]Fuzzy search requires a non-empty query.[/red]")
raise typer.Exit(2)

client = AnkiClient()

if fuzzy:
Expand Down Expand Up @@ -341,7 +358,11 @@ def _write_json(notes: list[dict], path: Path) -> None:
def _write_csv(notes: list[dict], path: Path) -> None:
if not notes:
return
field_names = list(notes[0]["fields"].keys())
seen: dict[str, None] = {}
for note in notes:
for key in note["fields"]:
seen.setdefault(key, None)
field_names = list(seen)
with path.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["noteId", "modelName", "tags"] + field_names)
Expand Down Expand Up @@ -411,13 +432,26 @@ def import_(
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without applying")] = False,
) -> None:
"""Import notes from JSON or CSV."""
if not file.exists():
console.print(f"[red]File not found:[/red] {file}")
raise typer.Exit(1)

ext = file.suffix.lower()
if ext == ".json":
notes = _read_json(file)
elif ext == ".csv":
notes = _read_csv(file)
else:
console.print(f"[red]Unsupported format:[/red] {ext} (use .json or .csv)")
try:
if ext == ".json":
notes = _read_json(file)
elif ext == ".csv":
notes = _read_csv(file)
else:
console.print(f"[red]Unsupported format:[/red] {ext} (use .json or .csv)")
raise typer.Exit(1)
except (json.JSONDecodeError, csv.Error, KeyError, ValueError) as exc:
console.print(f"[red]Failed to parse {file.name}:[/red] {exc}")
raise typer.Exit(1) from None

has_new_notes = any(not n.get("noteId") for n in notes)
if has_new_notes and not deck:
console.print("[red]--deck required for new notes (missing noteId)[/red]")
raise typer.Exit(1)

client = AnkiClient()
Expand Down Expand Up @@ -457,9 +491,6 @@ def import_(
updated += 1
else:
model = note.get("modelName", "Basic")
if not deck:
console.print("[red]--deck required for new notes (missing noteId)[/red]")
raise typer.Exit(1)
if dry_run:
console.print(f" [green]create[/green] {model} → {deck}")
created += 1
Expand Down
2 changes: 1 addition & 1 deletion src/anki_cli/stats.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/jquery@1.12.4/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flot@0.8.3/jquery.flot.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flot@0.8.3/jquery.flot.pie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flot@0.8.3/jquery.flot.stack.min.js"></script>
Expand Down
117 changes: 117 additions & 0 deletions tests/test_cli_export_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Tests for CSV export (mixed models) and import (error handling, --deck validation)."""

from __future__ import annotations

import csv
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from typer.testing import CliRunner

from anki_cli.cli import _write_csv, app

runner = CliRunner()


class TestWriteCsvMixedModels(unittest.TestCase):
"""CSV export should include the union of all field names across note models."""

def test_union_of_fields_in_header(self) -> None:
notes = [
{
"noteId": 1,
"modelName": "Basic",
"tags": "",
"fields": {"Front": "q1", "Back": "a1"},
},
{
"noteId": 2,
"modelName": "Cloze",
"tags": "",
"fields": {"Text": "c1", "Extra": "e1"},
},
]
with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f:
path = Path(f.name)
try:
_write_csv(notes, path)
text = path.read_text()
reader = csv.reader(io.StringIO(text))
header = next(reader)
expected = ["noteId", "modelName", "tags", "Front", "Back", "Text", "Extra"]
self.assertEqual(header, expected)
rows = list(reader)
self.assertEqual(rows[0], ["1", "Basic", "", "q1", "a1", "", ""])
self.assertEqual(rows[1], ["2", "Cloze", "", "", "", "c1", "e1"])
finally:
path.unlink(missing_ok=True)

def test_empty_notes_writes_nothing(self) -> None:
with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f:
path = Path(f.name)
try:
_write_csv([], path)
self.assertEqual(path.read_text(), "")
finally:
path.unlink(missing_ok=True)


class TestImportErrorHandling(unittest.TestCase):
"""Import should produce clean errors for missing files and malformed input."""

def test_missing_file(self) -> None:
result = runner.invoke(app, ["import", "/nonexistent/file.json"])
self.assertEqual(result.exit_code, 1)
self.assertIn("File not found", result.stdout)

def test_malformed_json(self) -> None:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write("{not valid json!!")
path = f.name
try:
result = runner.invoke(app, ["import", path])
self.assertEqual(result.exit_code, 1)
self.assertIn("Failed to parse", result.stdout)
finally:
Path(path).unlink(missing_ok=True)


class TestImportDeckValidation(unittest.TestCase):
"""--deck check should fire before any AnkiClient calls."""

def test_fails_before_api_calls(self) -> None:
notes = [{"fields": {"Front": "q", "Back": "a"}, "modelName": "Basic"}]
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(notes, f)
path = f.name
try:
with patch("anki_cli.cli.AnkiClient") as mock_client:
result = runner.invoke(app, ["import", path])
self.assertEqual(result.exit_code, 1)
self.assertIn("--deck required", result.stdout)
mock_client.assert_not_called()
finally:
Path(path).unlink(missing_ok=True)

def test_passes_with_deck_and_existing_notes(self) -> None:
"""Notes with noteId should not require --deck."""
notes = [{"noteId": 123, "fields": {"Front": "q"}, "modelName": "Basic", "tags": ""}]
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(notes, f)
path = f.name
try:
with patch("anki_cli.cli.AnkiClient") as mock_client:
mock_instance = mock_client.return_value
mock_instance.notes_info.return_value = [{"noteId": 123, "tags": []}]
result = runner.invoke(app, ["import", path])
self.assertEqual(result.exit_code, 0)
finally:
Path(path).unlink(missing_ok=True)


if __name__ == "__main__":
unittest.main()
27 changes: 27 additions & 0 deletions tests/test_cli_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import unittest

from typer.testing import CliRunner

from anki_cli.cli import _fuzzy_score, app

runner = CliRunner()


class SearchCommandTests(unittest.TestCase):
def test_empty_fuzzy_query_fails_cleanly(self) -> None:
result = runner.invoke(app, ["search", "", "--fuzzy", "--limit", "1"])
self.assertEqual(result.exit_code, 2)
self.assertIn("Fuzzy search requires a non-empty query.", result.stdout)

def test_fuzzy_score_empty_query_returns_zero(self) -> None:
note = {
"fields": {
"Front": {"value": "What is Python?"},
"Back": {"value": "A programming language"},
}
}
self.assertEqual(_fuzzy_score("", note), 0.0)


if __name__ == "__main__":
unittest.main()
45 changes: 45 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.