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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Usage: anki [OPTIONS] COMMAND [ARGS]...
│ suspend Suspend cards by ID or query. │
│ unsuspend Unsuspend cards by ID or query. │
│ media Store a media file in the Anki collection. │
│ flag Set or clear a flag on cards. │
│ create-deck Create a new deck. │
│ delete-deck Delete a deck. │
│ change-deck Move cards to a different deck. │
Expand Down
78 changes: 69 additions & 9 deletions src/anki_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@
app = typer.Typer(no_args_is_help=True)
console = Console()

FLAG_NAMES: dict[str, int] = {
"red": 1,
"orange": 2,
"green": 3,
"blue": 4,
"pink": 5,
"turquoise": 6,
"purple": 7,
}
FLAG_COLORS: dict[int, str] = {v: k for k, v in FLAG_NAMES.items()}


def _parse_flag(value: str) -> int:
low = value.lower()
if low in FLAG_NAMES:
return FLAG_NAMES[low]
try:
return int(value)
except ValueError:
return -1


def main() -> None:
try:
Expand Down Expand Up @@ -179,26 +200,30 @@ def list_(
deck: Annotated[str | None, typer.Option("--deck", "-d", help="Filter by deck")] = None,
tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
flag: Annotated[
int | None, typer.Option("--flag", "-F", help="Flag color (1-7) or 0 for any")
str | None,
typer.Option("--flag", "-F", help="Flag color name or number (1-7, 0=any)"),
] = None,
limit: Annotated[int, typer.Option("--limit", "-l", help="Max results")] = 20,
brief: Annotated[bool, typer.Option("--brief", "-B", help="Truncated table view")] = False,
) -> None:
"""List notes, optionally filtered by deck, tag, and/or flag."""
if flag is not None and not 0 <= flag <= 7:
console.print("[red]--flag must be 0 (any) or 1-7.[/red]")
raise typer.Exit(2)
flag_num: int | None = None
if flag is not None:
flag_num = _parse_flag(flag)
if not 0 <= flag_num <= 7:
console.print("[red]--flag must be 0 (any), 1-7, or a color name.[/red]")
raise typer.Exit(2)

parts = []
if deck:
parts.append(f'"deck:{deck}"')
if tag:
parts.append(f'"tag:{tag}"')
if flag is not None:
if flag == 0:
if flag_num is not None:
if flag_num == 0:
parts.append("(" + " OR ".join(f"flag:{i}" for i in range(1, 8)) + ")")
else:
parts.append(f"flag:{flag}")
parts.append(f"flag:{flag_num}")
query = " ".join(parts) or "deck:*"

client = AnkiClient()
Expand All @@ -212,8 +237,10 @@ def list_(
title = f"Deck: {deck}"
if tag:
title += f" [tag:{tag}]" if deck else f"Tag: {tag}"
if flag is not None:
flag_label = "Flagged" if flag == 0 else f"Flag: {flag}"
if flag_num is not None:
flag_label = (
"Flagged" if flag_num == 0 else f"Flag: {FLAG_COLORS.get(flag_num, str(flag_num))}"
)
if deck or tag:
title += f" [{flag_label.lower()}]"
else:
Expand Down Expand Up @@ -608,6 +635,39 @@ def media(
console.print(f"[green]Media stored:[/green] {stored}")


@app.command()
def flag(
flag_value: Annotated[str, typer.Argument(help="Color name or number (1-7, 0=clear)")],
card_id: Annotated[int | None, typer.Option("--card", "-c", help="Card ID")] = None,
query: Annotated[
str | None, typer.Option("--query", "-q", help="Anki query to select cards")
] = None,
) -> None:
"""Set or clear a flag on cards."""
flag_num = _parse_flag(flag_value)
if not 0 <= flag_num <= 7:
console.print("[red]Flag must be 0 (clear), 1-7, or a color name.[/red]")
raise typer.Exit(2)
if card_id is not None and query is not None:
console.print("[red]Specify either --card or --query, not both.[/red]")
raise typer.Exit(1)
if card_id is None and query is None:
console.print("[red]Specify --card or --query.[/red]")
raise typer.Exit(1)

client = AnkiClient()
ids = client.find_cards(query) if query else [card_id]
if not ids:
console.print("[yellow]No cards found.[/yellow]")
return
client.set_flag(ids, flag_num)
label = FLAG_COLORS.get(flag_num, str(flag_num))
if flag_num == 0:
console.print(f"[green]Cleared flag on {len(ids)} card(s).[/green]")
else:
console.print(f"[green]Set {label} flag on {len(ids)} card(s).[/green]")


@app.command("create-deck")
def create_deck(
name: Annotated[str, typer.Argument(help="Deck name (use :: for nested)")],
Expand Down
7 changes: 7 additions & 0 deletions src/anki_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ def suspend(self, card_ids: list[int]) -> None:
def unsuspend(self, card_ids: list[int]) -> None:
self._invoke("unsuspend", cards=card_ids)

def cards_info(self, card_ids: list[int]) -> list[dict[str, Any]]:
return self._invoke("cardsInfo", cards=card_ids)

def set_flag(self, card_ids: list[int], flag: int) -> None:
for cid in card_ids:
self._invoke("setSpecificValueOfCard", card=cid, keys=["flags"], newValues=[flag])

def sync(self) -> None:
self._invoke("sync")

Expand Down
22 changes: 21 additions & 1 deletion tests/test_cli_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,32 @@ class ListFlagTests(unittest.TestCase):
def test_flag_rejects_out_of_range(self) -> None:
result = runner.invoke(app, ["list", "--flag", "9"])
self.assertEqual(result.exit_code, 2)
self.assertIn("--flag must be 0 (any) or 1-7.", result.stdout)
self.assertIn("--flag must be 0 (any), 1-7, or a color name.", result.stdout)

def test_flag_rejects_negative(self) -> None:
result = runner.invoke(app, ["list", "--flag", "-1"])
self.assertNotEqual(result.exit_code, 0)


class FlagCommandTests(unittest.TestCase):
def test_flag_rejects_out_of_range(self) -> None:
result = runner.invoke(app, ["flag", "9", "--card", "123"])
self.assertEqual(result.exit_code, 2)

def test_flag_rejects_invalid_name(self) -> None:
result = runner.invoke(app, ["flag", "nope", "--card", "123"])
self.assertEqual(result.exit_code, 2)

def test_flag_requires_card_or_query(self) -> None:
result = runner.invoke(app, ["flag", "red"])
self.assertEqual(result.exit_code, 1)
self.assertIn("Specify --card or --query.", result.stdout)

def test_flag_rejects_both_card_and_query(self) -> None:
result = runner.invoke(app, ["flag", "red", "--card", "123", "--query", "deck:*"])
self.assertEqual(result.exit_code, 1)
self.assertIn("not both", result.stdout)


if __name__ == "__main__":
unittest.main()