From d2b607b14200e2836a14e8c745a6b11242afb09e Mon Sep 17 00:00:00 2001 From: ereverter <38497803+ereverter@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:57:43 +0100 Subject: [PATCH 1/2] add flag command with color name support - flag command to set/clear flags on cards (by --card or --query) - accepts color names (red, orange, ...) or numbers (1-7, 0=clear) - list --flag also accepts color names --- README.md | 1 + src/anki_cli/cli.py | 78 +++++++++++++++++++++++++++++++++++----- src/anki_cli/client.py | 7 ++++ tests/test_cli_search.py | 46 ++++++++++++++++++++++-- 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8b9964a..2d6970a 100644 --- a/README.md +++ b/README.md @@ -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. │ diff --git a/src/anki_cli/cli.py b/src/anki_cli/cli.py index 306f96d..565f298 100644 --- a/src/anki_cli/cli.py +++ b/src/anki_cli/cli.py @@ -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: @@ -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() @@ -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: @@ -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)")], diff --git a/src/anki_cli/client.py b/src/anki_cli/client.py index 11a04f5..d8b1325 100644 --- a/src/anki_cli/client.py +++ b/src/anki_cli/client.py @@ -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") diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index aa7e84d..41aeba3 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -2,7 +2,7 @@ from typer.testing import CliRunner -from anki_cli.cli import _fuzzy_score, app +from anki_cli.cli import _fuzzy_score, _parse_flag, app runner = CliRunner() @@ -27,12 +27,54 @@ 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 ParseFlagTests(unittest.TestCase): + def test_color_names(self) -> None: + self.assertEqual(_parse_flag("red"), 1) + self.assertEqual(_parse_flag("orange"), 2) + self.assertEqual(_parse_flag("green"), 3) + self.assertEqual(_parse_flag("blue"), 4) + self.assertEqual(_parse_flag("pink"), 5) + self.assertEqual(_parse_flag("turquoise"), 6) + self.assertEqual(_parse_flag("purple"), 7) + + def test_case_insensitive(self) -> None: + self.assertEqual(_parse_flag("Red"), 1) + self.assertEqual(_parse_flag("ORANGE"), 2) + + def test_numbers(self) -> None: + self.assertEqual(_parse_flag("0"), 0) + self.assertEqual(_parse_flag("3"), 3) + + def test_invalid_returns_negative(self) -> None: + self.assertEqual(_parse_flag("nope"), -1) + + +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() From c491cb80e634e27c2f7af488b84d186d7b1c9104 Mon Sep 17 00:00:00 2001 From: ereverter <38497803+ereverter@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:53:37 +0100 Subject: [PATCH 2/2] tests --- tests/test_cli_search.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index 41aeba3..b22893b 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -2,7 +2,7 @@ from typer.testing import CliRunner -from anki_cli.cli import _fuzzy_score, _parse_flag, app +from anki_cli.cli import _fuzzy_score, app runner = CliRunner() @@ -34,28 +34,6 @@ def test_flag_rejects_negative(self) -> None: self.assertNotEqual(result.exit_code, 0) -class ParseFlagTests(unittest.TestCase): - def test_color_names(self) -> None: - self.assertEqual(_parse_flag("red"), 1) - self.assertEqual(_parse_flag("orange"), 2) - self.assertEqual(_parse_flag("green"), 3) - self.assertEqual(_parse_flag("blue"), 4) - self.assertEqual(_parse_flag("pink"), 5) - self.assertEqual(_parse_flag("turquoise"), 6) - self.assertEqual(_parse_flag("purple"), 7) - - def test_case_insensitive(self) -> None: - self.assertEqual(_parse_flag("Red"), 1) - self.assertEqual(_parse_flag("ORANGE"), 2) - - def test_numbers(self) -> None: - self.assertEqual(_parse_flag("0"), 0) - self.assertEqual(_parse_flag("3"), 3) - - def test_invalid_returns_negative(self) -> None: - self.assertEqual(_parse_flag("nope"), -1) - - class FlagCommandTests(unittest.TestCase): def test_flag_rejects_out_of_range(self) -> None: result = runner.invoke(app, ["flag", "9", "--card", "123"])