diff --git a/README.md b/README.md index d367327..ff6bbf1 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,12 @@ stackvox speak --normalize --file post.md # normalize, then synthesize stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon ``` -`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively). +`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively). **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone. +Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one. + Bash completion: ```bash diff --git a/stackvox/cli.py b/stackvox/cli.py index a47a6eb..77d8bcc 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -77,13 +77,17 @@ def _configure_logging() -> None: COMPREPLY=( $(compgen -W "drop csv" -- "$cur") ) return 0 ;; + --code-blocks) + COMPREPLY=( $(compgen -W "drop placeholder" -- "$cur") ) + return 0 + ;; --locale) COMPREPLY=( $(compgen -W "en-GB" -- "$cur") ) return 0 ;; esac - local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale" + local norm_flags="--no-markdown --no-dev-terms --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale" case "$subcommand" in speak) @@ -240,6 +244,19 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) - default="drop", help="How Markdown tables are voiced (default: drop)", ) + parser.add_argument( + "--code-blocks", + dest="code_blocks", + choices=["drop", "placeholder"], + default="drop", + help="How fenced code blocks are voiced (default: drop)", + ) + parser.add_argument( + "--code-placeholder", + dest="code_placeholder", + default="Code block.", + help='Spoken stand-in for a code block when --code-blocks placeholder (default: "Code block.")', + ) parser.add_argument( "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking" ) @@ -281,6 +298,8 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict: "expand_numbers": args.expand_numbers, "pauses": args.pauses, "tables": args.tables, + "code_blocks": args.code_blocks, + "code_placeholder": args.code_placeholder, "strip_emoji": args.strip_emoji, "terminal_stops": args.terminal_stops, "locale": args.locale, diff --git a/stackvox/text.py b/stackvox/text.py index 5febc1f..e272b29 100644 --- a/stackvox/text.py +++ b/stackvox/text.py @@ -230,11 +230,24 @@ def _strip_md_inline(line: str) -> str: return line -def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag: bool = False) -> list[str]: +def markdown_to_paragraphs( + text: str, + *, + tables: str = "drop", + strip_emoji_flag: bool = False, + code_blocks: str = "drop", + code_placeholder: str = "", +) -> list[str]: """Reduce Markdown to a list of speakable paragraphs. Headings and list items become their own paragraphs (so each gets its own pause). Tables are - dropped or rendered comma-separated per ``tables``.""" - text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", "\n", text) # fenced code + dropped or rendered comma-separated per ``tables``. Fenced code blocks are + dropped, or (``code_blocks="placeholder"``) replaced with a spoken + ``code_placeholder`` so a silently-skipped block doesn't sound disjointed.""" + speak_code = code_blocks == "placeholder" and bool(code_placeholder) + # A function replacement keeps the placeholder literal (no backref parsing); + # blank lines around it make it a standalone paragraph. + fenced = f"\n\n{code_placeholder}\n\n" if speak_code else "\n" + text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", lambda _: fenced, text) # fenced code text = re.sub(r"```+|~~~+", " ", text) # stray fences if strip_emoji_flag: text = strip_emoji(text) @@ -319,6 +332,14 @@ def emit_csv_row(cells_source: str) -> None: flush() flush() + + if speak_code: # collapse runs of adjacent code blocks into one placeholder + collapsed: list[str] = [] + for para in paragraphs: + if para == code_placeholder and collapsed and collapsed[-1] == code_placeholder: + continue + collapsed.append(para) + return collapsed return paragraphs @@ -366,6 +387,8 @@ def normalize_for_speech( expand_numbers: bool = True, pauses: bool = True, tables: str = "drop", + code_blocks: str = "drop", + code_placeholder: str = "Code block.", strip_emoji: bool = False, terminal_stops: bool = True, locale: str = DEFAULT_LOCALE, @@ -380,7 +403,13 @@ def normalize_for_speech( effective_pronunciations[written.lower()] = spoken if markdown: - paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji) + paragraphs = markdown_to_paragraphs( + text, + tables=tables, + strip_emoji_flag=strip_emoji, + code_blocks=code_blocks, + code_placeholder=code_placeholder, + ) else: # `strip_emoji` (the bool kwarg) shadows the module function here, so # reach for the underlying pattern directly. diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f0b633..92f7764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -133,6 +133,8 @@ def _norm_ns(text=None, file=None, **overrides): expand_numbers=True, pauses=True, tables="drop", + code_blocks="drop", + code_placeholder="Code block.", strip_emoji=False, terminal_stops=True, locale="en-GB", @@ -524,6 +526,26 @@ def test_paths_and_config_route(self, mocker): assert cli.main() == 0 assert paths_handler.called + def test_code_block_flags_parse(self, mocker): + norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0) + mocker.patch.object( + cli.sys, + "argv", + [ + "stackvox", + "normalize", + "--code-blocks", + "placeholder", + "--code-placeholder", + "see the chat", + "hi", + ], + ) + assert cli.main() == 0 + args = norm.call_args.args[0] + assert args.code_blocks == "placeholder" + assert args.code_placeholder == "see the chat" + class TestCmdCancel: def test_running_calls_daemon_cancel(self, mocker): diff --git a/tests/test_text.py b/tests/test_text.py index 11329df..161ca2f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -175,6 +175,29 @@ def test_fenced_code_dropped(): assert markdown_to_paragraphs(md) == ["Before.", "After."] +def test_fenced_code_replaced_with_placeholder(): + md = "Before.\n\n```py\nprint('hi')\n```\n\nAfter." + actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code") + assert actual == ["Before.", "see the code", "After."] + + +def test_consecutive_code_blocks_collapse_to_one_placeholder(): + md = "```py\na\n```\n\n```py\nb\n```" + actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code") + assert actual == ["see the code"] + + +def test_code_placeholder_spoken_end_to_end(): + md = "Here is how:\n\n```py\nx = 1\n```\n\nDone." + out = normalize_for_speech(md, code_blocks="placeholder", code_placeholder="see the chat history") + assert out == "Here is how:\nsee the chat history.\nDone." + + +def test_code_blocks_dropped_by_default_end_to_end(): + md = "Here is how:\n\n```py\nx = 1\n```\n\nDone." + assert normalize_for_speech(md) == "Here is how:\nDone." + + def test_tables_dropped_by_default(): md = "| A | B |\n| --- | --- |\n| 1 | 2 |" assert markdown_to_paragraphs(md) == []