diff --git a/README.md b/README.md index 33cad99..1b4d286 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ currently recognized: - `query`: Specify default query for `apy list`, `apy review` and `apy tag`. - `review_show_cards`: Whether to show list of cards by default during note review +- `review_verbose`: Whether to show note details by default during note + review An example configuration: diff --git a/completion/_apy b/completion/_apy index 812f1f3..48abc26 100644 --- a/completion/_apy +++ b/completion/_apy @@ -46,17 +46,19 @@ _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 (alias for update-from-file)' \ - 'update-from-file:Update existing or add new notes from Markdown file' \ + 'add-single:Add a single note from command line arguments' \ + 'backup:Backup Anki database to specified target file' \ 'check-media:Check media' \ + 'edit:Edit notes that match QUERY directly' \ 'info:Print some basic statistics' \ + 'list-cards-table:List cards that match QUERY in table format' \ 'model:Interact with the models' \ - 'list:Print cards that match query' \ - 'review:Review/Edit notes that match query [default: marked/flagged]' \ - 'reposition:Reposition new card with given CID' \ + 'reposition:Reposition new cards that match QUERY' \ + 'review:Review/Edit notes that match QUERY [default: marked/flagged]' \ 'sync:Synchronize collection with AnkiWeb' \ - 'tag:Add or remove tags from notes that match query' \ + 'tag:Add or remove tags from notes that match QUERY' \ + 'update-from-file:Update existing or add new notes from Markdown file' \ ) _arguments $opts '*:: :->subcmds' && return 0 @@ -91,10 +93,22 @@ _apy() { '(-u --update-file)'{-u,--update-file}'[Update original file with note IDs]' \ $opts_help \ );; + backup) + opts=( \ + '::Target file' \ + '(-m --include-media)'{-m,--include-media}'[Include media files]:media:' \ + '(-l --legacy)'{-l,--legacy}'[Support older Anki Versions]:legacy:' \ + $opts_help \ + );; + edit) + opts=( \ + '::Query' \ + '(-f --force-multiple)'{-f,--force-multiple}'[Allow editing multiple notes]' \ + $opts_help \ + );; info) opts=( $opts_help );; - model) __model; return;; - list) + list-cards-table) opts=( \ '::Query' \ '(-w --wrap)'{-w,--wrap}'[Wrap the question and answer on multiple lines]' \ @@ -107,9 +121,12 @@ _apy() { '(-l --show-lapses)'{-l,--show-lapses}'[Display card number of lapses]' \ $opts_help \ );; + model) __model; return;; review) opts=( \ - '(-q --query)'{-q,--query}'[Query string]:query:' \ + '::Query' \ + '(-m --check-markdown-consistency)'{-m,----check-markdown-consistency}'[Check for Markdown consistency]' \ + '(-n --cmc-range)'{-n,--cmc-range}'[Number of days to check]:range:' \ $opts_help \ );; reposition) diff --git a/src/apyanki/anki.py b/src/apyanki/anki.py index fe6d4f3..b703939 100644 --- a/src/apyanki/anki.py +++ b/src/apyanki/anki.py @@ -388,13 +388,27 @@ def edit_model_css(self, model_name: str) -> None: self.col.models.save(model, templates=True) self.modified = True - def list_notes(self, query: str) -> None: - """List notes that match a query""" + def list_note_questions(self, query: str) -> None: + """List first card questions for notes that match a query""" for note in self.find_notes(query): cards.print_question(note.n.cards()[0]) - def list_cards(self, query: str, opts_display: dict[str, bool]) -> None: + def list_notes( + self, query: str, show_cards: bool, show_raw_fields: bool, verbose: bool + ) -> None: + """List notes that match a query""" + for note in self.find_notes(query): + note.pprint( + print_raw=show_raw_fields, list_cards=show_cards, verbose=verbose + ) + + def list_cards(self, query: str, verbose: bool) -> None: """List cards that match a query""" + for cid in self.col.find_cards(query): + cards.card_pprint(self.col.get_card(cid), verbose) + + def list_cards_as_table(self, query: str, opts_display: dict[str, bool]) -> None: + """List cards that match a query in tabular format""" width = console.width - 1 if opts_display.get("show_cid", False): width -= 15 diff --git a/src/apyanki/cards.py b/src/apyanki/cards.py index 69cecdf..a227267 100644 --- a/src/apyanki/cards.py +++ b/src/apyanki/cards.py @@ -4,15 +4,57 @@ from typing import TYPE_CHECKING +from rich.markdown import Markdown from rich.text import Text -from apyanki.console import console +from apyanki.console import console, consolePlain +from apyanki.fields import check_if_generated_from_markdown, prepare_field_for_cli from apyanki.fields import prepare_field_for_cli_oneline if TYPE_CHECKING: from anki.cards import Card +def card_pprint(card: Card, verbose: bool = True) -> None: + """Pretty print a card.""" + flag = get_flag(card) + consolePlain.print(f"[green]# Card (cid: {card.id})[/green]{flag}\n") + + if verbose: + card_type = ["new", "learning", "review", "relearning"][int(card.type)] + details = [ + f"[yellow]nid:[/yellow] {card.nid}", + f"[yellow]model:[/yellow] {card.note_type()['name']}", + f"[yellow]type:[/yellow] {card_type}", + f"[yellow]due:[/yellow] {card.due} days", + f"[yellow]interval:[/yellow] {card.ivl} days", + f"[yellow]repetitions:[/yellow] {card.reps}", + f"[yellow]lapses:[/yellow] {card.lapses}", + f"[yellow]ease:[/yellow] {int(card.factor / 10)} %", + "", + ] + for detail in details: + consolePlain.print(detail) + + rendered = card.render_output() + for title, field in [ + ["Front", rendered.question_text], + ["Back", rendered.answer_text], + ]: + is_markdown = check_if_generated_from_markdown(field) + if is_markdown: + title += " [italic](markdown)[/italic]" + + console.print(f"[blue]## {title}[/blue]\n") + prepared = prepare_field_for_cli(field) + prepared = prepared.replace("\n\n", "\n") + if is_markdown: + console.print(Markdown(prepared)) + else: + console.print(prepared) + console.print() + + def card_field_to_text(field: str, max_width: int = 0) -> Text: prepared_field = prepare_field_for_cli_oneline(field) if max_width > 0: diff --git a/src/apyanki/cli.py b/src/apyanki/cli.py index 172b885..4b0b298 100644 --- a/src/apyanki/cli.py +++ b/src/apyanki/cli.py @@ -374,7 +374,57 @@ def rename(old_name: str, new_name: str) -> None: a.rename_model(old_name, new_name) -@main.command("list") +@main.command("list-cards") +@click.argument("query", required=False, nargs=-1) +@click.option("-v", "--verbose", is_flag=True, help="Print details for each card") +def list_cards(query: str, verbose: bool) -> None: + """List cards that match QUERY. + + The default QUERY is "tag:marked OR -flag:0". This default can be + customized in the config file `~/.config/apy/apy.json`, e.g. with + + \b + { + "query": "tag:marked OR tag:leech" + } + """ + if query: + query = " ".join(query) + else: + query = cfg["query"] + + with Anki(**cfg) as a: + a.list_cards(query, verbose) + + +@main.command("list-notes") +@click.argument("query", required=False, nargs=-1) +@click.option("-c", "--show-cards", is_flag=True, help="Print card specs") +@click.option("-r", "--show-raw-fields", is_flag=True, help="Print raw field data") +@click.option("-v", "--verbose", is_flag=True, help="Print note details") +def list_notes( + query: str, show_cards: bool, show_raw_fields: bool, verbose: bool +) -> None: + """List notes that match QUERY. + + The default QUERY is "tag:marked OR -flag:0". This default can be + customized in the config file `~/.config/apy/apy.json`, e.g. with + + \b + { + "query": "tag:marked OR tag:leech" + } + """ + if query: + query = " ".join(query) + else: + query = cfg["query"] + + with Anki(**cfg) as a: + a.list_notes(query, show_cards, show_raw_fields, verbose) + + +@main.command("list-cards-table") @click.argument("query", required=False, nargs=-1) @click.option("-a", "--show-answer", is_flag=True, help="Display answer") @click.option("-m", "--show-model", is_flag=True, help="Display model") @@ -383,7 +433,7 @@ def rename(old_name: str, new_name: str) -> None: @click.option("-t", "--show-type", is_flag=True, help="Display card type") @click.option("-e", "--show-ease", is_flag=True, help="Display card ease") @click.option("-l", "--show-lapses", is_flag=True, help="Display card number of lapses") -def list_cards( +def list_cards_table( query: str, show_answer: bool, show_model: bool, @@ -393,7 +443,7 @@ def list_cards( show_lapses: bool, show_cid: bool, ) -> None: - """List cards that match QUERY. + """List cards that match QUERY in a tabular format. The default QUERY is "tag:marked OR -flag:0". This default can be customized in the config file `~/.config/apy/apy.json`, e.g. with @@ -409,7 +459,7 @@ def list_cards( query = cfg["query"] with Anki(**cfg) as a: - a.list_cards( + a.list_cards_as_table( query, { "show_answer": show_answer, @@ -656,7 +706,7 @@ def tag( raise click.Abort() console.print(f"The operation will be applied to {n_notes} matched notes:") - a.list_notes(query) + a.list_note_questions(query) console.print("") if add_tags is not None: diff --git a/src/apyanki/config.py b/src/apyanki/config.py index 1021275..c818d6c 100644 --- a/src/apyanki/config.py +++ b/src/apyanki/config.py @@ -54,6 +54,7 @@ def get_base_path() -> str | None: "profile_name": None, "query": "tag:marked OR -flag:0", "review_show_cards": False, + "review_verbose": False, } # Ensure that cfg has required keys diff --git a/src/apyanki/note.py b/src/apyanki/note.py index e83fd02..90202c7 100644 --- a/src/apyanki/note.py +++ b/src/apyanki/note.py @@ -80,41 +80,47 @@ def __repr__(self) -> str: return "\n".join(lines) - def pprint(self, print_raw: bool = False, list_cards: bool = False) -> None: + def pprint( + self, print_raw: bool = False, list_cards: bool = False, verbose: bool = False + ) -> None: """Print to screen""" from anki import latex header = f"[green]# Note (nid: {self.n.id})[/green]" if self.suspended: header += " [red](suspended)[/red]" - - created = strftime("%F %H:%M", localtime(self.n.id / 1000)) - modified = strftime("%F %H:%M", localtime(self.n.mod)) - columned = [ - f"[yellow]model:[/yellow] {self.model_name} ({len(self.n.cards())} cards)", - f"[yellow]tags:[/yellow] {self.get_tag_string()}", - f"[yellow]created:[/yellow] {created}", - f"[yellow]modified:[/yellow] {modified}", - ] - if self.a.n_decks > 1: - columned += ["[yellow]deck:[/yellow] " + self.get_deck()] - - if not list_cards: - flagged = [ - cards.get_flag(c, str(c.template()["name"])) - for c in self.n.cards() - if c.flags > 0 + consolePlain.print(header + "\n") + + if verbose: + created = strftime("%F %H:%M", localtime(self.n.id / 1000)) + modified = strftime("%F %H:%M", localtime(self.n.mod)) + details = [ + f"[yellow]model:[/yellow] {self.model_name} ({len(self.n.cards())} cards)", + f"[yellow]tags:[/yellow] {self.get_tag_string()}", + f"[yellow]created:[/yellow] {created}", + f"[yellow]modified:[/yellow] {modified}", ] - if flagged: - columned += [f"[yellow]flagged:[/yellow] {', '.join(flagged)}"] + if self.a.n_decks > 1: + details += ["[yellow]deck:[/yellow] " + self.get_deck()] + + if not list_cards: + flagged = [ + cards.get_flag(c, str(c.template()["name"])) + for c in self.n.cards() + if c.flags > 0 + ] + if flagged: + details += [f"[yellow]flagged:[/yellow] {', '.join(flagged)}"] - consolePlain.print(header) - consolePlain.print(Columns(columned, width=37)) + for detail in details: + consolePlain.print(detail) if list_cards: self.print_cards() - console.print() + if verbose or list_cards: + console.print() + imgs: list[Path] = [] for name, field in self.n.items(): is_markdown = check_if_generated_from_markdown(field) @@ -158,7 +164,7 @@ def print_cards(self) -> None: table.add_column("Interval", justify="right", header_style="white") table.add_column("Reps", justify="right", header_style="white") table.add_column("Lapses", justify="right", header_style="white") - table.add_column("Factor", justify="right", header_style="white") + table.add_column("Ease", justify="right", header_style="white") for card in sorted(self.n.cards(), key=lambda x: x.factor): table.add_row( "- " + str(card.template()["name"]) + cards.get_flag(card), @@ -441,6 +447,7 @@ def review( "N": "Change model", "s": "Save and stop", "v": "Show cards", + "V": "Show details", "x": "Save and stop", } @@ -471,14 +478,15 @@ def review( ) print_raw_fields = False - refresh = True + verbose = cfg["review_verbose"] show_cards = cfg["review_show_cards"] + refresh = True while True: if refresh: console.clear() console.print(menu) console.print("") - self.pprint(print_raw_fields, list_cards=show_cards) + self.pprint(print_raw_fields, list_cards=show_cards, verbose=verbose) refresh = True choice = readchar.readchar() @@ -560,6 +568,12 @@ def review( if action == "Show cards": show_cards = not show_cards + cfg["review_show_cards"] = show_cards + continue + + if action == "Show details": + verbose = not verbose + cfg["review_verbose"] = verbose continue