diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..fde3206 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,20 @@ +on: + push: + tags: + - 'v*' +jobs: + publish: + runs-on: based-alpine + steps: + - uses: actions/checkout@v4 + - name: setup cache + id: uv-cache + uses: https://git.ficd.sh/ficd/uv-cache@v1 + - name: build + run: | + uv sync + uv build + - name: publish + run: | + uv publish --token ${{ secrets.PYPI_TOKEN }} + diff --git a/README.md b/README.md index e09b662..ff2c780 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,21 @@

Mail Format

`mailfmt` is a simple plain text email formatter. It's designed to ensure -consistent paragraph spacing while preserving markdown syntax, email headers, -sign-offs, and signature blocks. +consistent paragraph spacing while preserving markdown syntax, email +headers, sign-offs, and signature blocks. -By default, this script accepts its input on `stdin` and prints to `stdout`. -This makes it well suited for use as a formatter with a text editor like Kakoune -or Helix. It has no dependencies besides the standard Python interpreter, and -was written and tested against Python 3.13.3. +By default, the command accepts its input on `stdin` and prints to +`stdout`. This makes it well suited for use as a formatter with a text +editor like Kakoune or Helix. - [Features](#features) +- [Installation](#installation) - [Usage](#usage) - [Output Example](#output-example) - [Markdown Safety](#markdown-safety) - [Aerc Integration](#aerc-integration) -- [Contributing](#contributing) @@ -33,9 +32,27 @@ was written and tested against Python 3.13.3. - Markdown-style code blocks. - Usenet-style signature block at EOF. - Sign-offs. -- If specified, output can be made safe for passing to a Markdown renderer. - - Use case: piping the output to `pandoc` to write a `text/html` message. See - [Markdown Safety](#markdown-safety). +- If specified, output can be made safe for passing to a Markdown + renderer. + - Use case: piping the output to `pandoc` to write a `text/html` + message. See [Markdown Safety](#markdown-safety). + +## Installation + +`mailfmt` is intended for use as a standaole tool. The package is +available on PyPI as `mailfmt`. I recommend using +[uv](https://github.com/astral-sh/uv) or `pipx` to install it so the +`mailfmt` command is available on your path: + +```sh +uv tool install mailfmt +``` + +Verify that the installation was successful: + +```sh +mailfmt --help +``` ## Usage @@ -63,8 +80,7 @@ options: -o, --output OUTPUT Output file. (default: STDOUT) Author : Daniel Fichtinger -License: ISC -Contact: daniel@ficd.ca +Contact: daniel@ficd.sh ``` ## Output Example @@ -87,8 +103,7 @@ Daniel -- Daniel -sr.ht/~ficd -daniel@ficd.ca +daniel@ficd.sh ``` After: @@ -110,23 +125,24 @@ Daniel -- Daniel -sr.ht/~ficd -daniel@ficd.ca +daniel@ficd.sh ``` ## Markdown Safety -In some cases, you may want to generate an HTML email. Ideally, you'd want the -HTML to be generated directly from the plain text message, and for _both_ -versions to be legible and have the same semantics. +In some cases, you may want to generate an HTML email. Ideally, you'd want +the HTML to be generated directly from the plain text message, and for +_both_ versions to be legible and have the same semantics. -Although `mailfmt` was written with Markdown markup in mind, its intended output -is still the `text/plain` format. If you pass its output directly to a Markdown -renderer, line breaks in sign-offs and the signature block won't be preserved. +Although `mailfmt` was written with Markdown markup in mind, its intended +output is still the `text/plain` format. If you pass its output directly +to a Markdown renderer, line breaks in sign-offs and the signature block +won't be preserved. -If you invoke `mailfmt --markdown-safe`, then `\` characters will be appended to -mark line breaks that would otherwise be squashed, making the output suitable -for conversion into HTML. Here's an example of one such pipeline: +If you invoke `mailfmt --markdown-safe`, then `\` characters will be +appended to mark line breaks that would otherwise be squashed, making the +output suitable for conversion into HTML. Here's an example of one such +pipeline: ```bash cat message.txt | mailfmt --markdown-safe | pandoc -f markdown -t html @@ -152,24 +168,20 @@ Daniel \ -- \ Daniel \ -sr.ht/~ficd \ -daniel@ficd.ca \ +daniel@ficd.sh \ ``` ## Aerc Integration -For integration with `aerc`, consider adding the following to your `aerc.conf`: +For integration with `aerc`, consider adding the following to your +`aerc.conf`: ```ini [multipart-converters] text/html=mailfmt --markdown-safe | pandoc -f markdown -t html --standalone ``` -When you're done writing your email, you can call the `:multipart text/html` -command to generate a `multipart/alternative` message which includes _both_ your -original `text/plain` _and_ the newly generated `text/html` content. - -## Contributing - -Please send patches, requests, and concerns to my -[public inbox](https://lists.sr.ht/~ficd/public-inbox). +When you're done writing your email, you can call the +`:multipart text/html` command to generate a `multipart/alternative` +message which includes _both_ your original `text/plain` _and_ the newly +generated `text/html` content. diff --git a/justfile b/justfile new file mode 100644 index 0000000..b0828ed --- /dev/null +++ b/justfile @@ -0,0 +1,21 @@ +clean: + #!/bin/sh + if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then + echo "Nothing to clean." + exit 0 + fi + if [ -d "dist" ]; then + echo "Removing dist/" + rm -r dist/ + fi + if [ -d "__pycache__" ]; then + echo "Removing __pycache__/" + rm -r "__pycache__" + fi +publish: + #!/bin/sh + just clean + uv build + export UV_PUBLISH_TOKEN="$(pass show pypi)" + uv publish + diff --git a/mailfmt.py b/mailfmt.py index fadc3dc..995e9aa 100755 --- a/mailfmt.py +++ b/mailfmt.py @@ -11,10 +11,11 @@ # Author: Daniel Fichtinger # License: ISC -import textwrap -import sys -import re import argparse +import re +import sys +import textwrap +from importlib.metadata import version def main() -> None: @@ -75,7 +76,7 @@ def main() -> None: if not signoff_cache and 1 <= n <= 5 and line[-1] == ",": return True # second potential line - elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha(): + elif signoff_cache and 1 <= n <= 5: for w in words: if not w[0].isupper(): return False @@ -83,146 +84,156 @@ def main() -> None: else: return False - if __name__ == "__main__": - parser = argparse.ArgumentParser( - description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.', - epilog=""" - Author : Daniel Fichtinger - License: ISC - Contact: daniel@ficd.ca - """, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "-w", - "--width", - required=False, - help="Text width for wrapping. (default: %(default)s)", - default=width, - type=int, - ) - parser.add_argument( - "-b", - "--break-long-words", - required=False, - help="Break long words while wrapping. (default: %(default)s)", - action="store_true", - ) - parser.add_argument( - "--no-replace-whitespace", - required=False, - help="Don't normalize whitespace when wrapping.", - action="store_false", - ) - parser.add_argument( - "--no-reflow", - required=False, - help="Don't reflow lines.", - action="store_false", - ) - parser.add_argument( - "--no-signoff", - required=False, - help="Don't preserve signoff line breaks.", - action="store_false", - ) - parser.add_argument( - "--no-signature", - required=False, - help="Don't preserve signature block.", - action="store_false", - ) - parser.add_argument( - "--no-squash", - required=False, - help="Don't squash consecutive paragraph breaks.", - action="store_false", - ) - parser.add_argument( - "-m", - "--markdown-safe", - required=False, - help="Output format safe for Markdown rendering.", - action="store_true", - ) - parser.add_argument( - "-i", - "--input", - required=False, - type=str, - default="STDIN", - help="Input file. (default: %(default)s)", - ) - parser.add_argument( - "-o", - "--output", - required=False, - type=str, - default="STDOUT", - help="Output file. (default: %(default)s)", - ) - args = parser.parse_args() - width = args.width - should_check_signoff = args.no_signoff - should_check_signature = args.no_signature - reflow = args.no_reflow - squash = args.no_squash - replace_whitespace = args.no_replace_whitespace - break_long_words = args.break_long_words - markdown_safe = args.markdown_safe + parser = argparse.ArgumentParser( + description="Heuristic formatter for plain text email. Preserves markup, signoffs, and signature blocks.", + epilog=""" +Author : Daniel Fichtinger +Repository: https://git.ficd.sh/ficd/mailfmt +License : ISC +Contact : daniel@ficd.sh + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-v", + "--version", + required=False, + help="Print version info and exit.", + action="store_true", + ) + parser.add_argument( + "-w", + "--width", + required=False, + help="Text width for wrapping. (default: %(default)s)", + default=width, + type=int, + ) + parser.add_argument( + "-b", + "--break-long-words", + required=False, + help="Break long words while wrapping. (default: %(default)s)", + action="store_true", + ) + parser.add_argument( + "--no-replace-whitespace", + required=False, + help="Don't normalize whitespace when wrapping.", + action="store_false", + ) + parser.add_argument( + "--no-reflow", + required=False, + help="Don't reflow lines.", + action="store_false", + ) + parser.add_argument( + "--no-signoff", + required=False, + help="Don't preserve signoff line breaks.", + action="store_false", + ) + parser.add_argument( + "--no-signature", + required=False, + help="Don't preserve signature block.", + action="store_false", + ) + parser.add_argument( + "--no-squash", + required=False, + help="Don't squash consecutive paragraph breaks.", + action="store_false", + ) + parser.add_argument( + "-m", + "--markdown-safe", + required=False, + help="Output format safe for Markdown rendering.", + action="store_true", + ) + parser.add_argument( + "-i", + "--input", + required=False, + type=str, + default="STDIN", + help="Input file. (default: %(default)s)", + ) + parser.add_argument( + "-o", + "--output", + required=False, + type=str, + default="STDOUT", + help="Output file. (default: %(default)s)", + ) + args = parser.parse_args() + if args.version: + print(version("mailfmt")) + exit(0) + width = args.width + should_check_signoff = args.no_signoff + should_check_signature = args.no_signature + reflow = args.no_reflow + squash = args.no_squash + replace_whitespace = args.no_replace_whitespace + break_long_words = args.break_long_words + markdown_safe = args.markdown_safe - if args.input == "STDIN": - reader = sys.stdin - else: - with open(args.input, "r") as in_stream: - reader = in_stream - if args.output != "STDOUT": - out_stream = open(args.output, "w") + if args.input == "STDIN": + reader = sys.stdin + else: + with open(args.input, "r") as in_stream: + reader = in_stream + if args.output != "STDOUT": + out_stream = open(args.output, "w") - for line in reader: - line = line.rstrip() - if should_check_signoff: - is_signoff = check_signoff(line) - if is_signoff: - in_signoff = True - if not signoff_cache: - signoff_cache = line - else: - pprint(signoff_cache) - pprint(line) - in_signoff = False - signoff_cache = "" - continue - elif not is_signoff and signoff_cache: - paragraph.append(signoff_cache) - signoff_cache = "" + for line in reader: + line = line.rstrip() + if should_check_signoff: + is_signoff = check_signoff(line) + if is_signoff: + in_signoff = True + if not signoff_cache: + signoff_cache = line + else: + pprint(signoff_cache) + pprint(line) in_signoff = False - if line.startswith("```"): - flush_paragraph() - skipping = not skipping - pprint(line) - elif should_check_signature and line == "--": - flush_paragraph() - skipping = True - in_signature = True - pprint("-- ") - elif not line or re.match( - r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)", - line, - ): - flush_paragraph() - pprint(line) - elif skipping: - pprint(line) - else: - paragraph.append(line) - else: - if signoff_cache: + signoff_cache = "" + continue + elif not is_signoff and signoff_cache: paragraph.append(signoff_cache) signoff_cache = "" + in_signoff = False + if line.startswith("```"): flush_paragraph() - if out_stream is not None: - out_stream.close() + skipping = not skipping + pprint(line) + elif should_check_signature and line == "--": + flush_paragraph() + skipping = True + in_signature = True + pprint("-- ") + elif not line or re.match( + r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)", + line, + ): + flush_paragraph() + pprint(line) + elif skipping: + pprint(line) + else: + paragraph.append(line) + else: + if signoff_cache: + paragraph.append(signoff_cache) + signoff_cache = "" + flush_paragraph() + if out_stream is not None: + out_stream.close() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index d8dedf2..5694f65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,19 @@ [project] name = "mailfmt" -version = "0.1.0" -description = "Add your description here" +version = "1.0.4" +description = "Heuristic plain text email formatter." readme = "README.md" authors = [ - { name = "Daniel Fichtinger", email = "daniel@ficd.ca" } + { name = "Daniel Fichtinger", email = "daniel@ficd.sh" } ] requires-python = ">=3.11" dependencies = [] +license = "ISC" +license-files = ["LICENSE"] +keywords = ["email", "formatter", "cli"] + +[project.urls] +Repository = "https://git.ficd.sh/ficd/mailfmt" [project.scripts] mailfmt = "mailfmt:main" diff --git a/src/mailfmt/__init__.py b/src/mailfmt/__init__.py deleted file mode 100644 index ffdcf09..0000000 --- a/src/mailfmt/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import mailfmt - - -def main() -> None: - print("Hello from mail format") - mailfmt.mailfmt() diff --git a/src/mailfmt/mailfmt.py b/src/mailfmt/mailfmt.py deleted file mode 100755 index 2a1f964..0000000 --- a/src/mailfmt/mailfmt.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/bin/env python - -# Simple text-wrapping script for email. -# Preserves code blocks, quotes, and signature. -# Automatically joins and re-wraps paragraphs to -# ensure even spacing & avoid ugly wrapping. -# Preserves signoffs. -# Signoff heuristic: -# 1-5 words ending with a comma, followed by -# 1-5 words that each start with capital letters. -# Author: Daniel Fichtinger -# License: ISC - -import textwrap -import sys -import re -import argparse - - -def mailfmt() -> None: - paragraph: list[str] = [] - skipping = False - squash = True - prev_is_parbreak = False - out_stream = sys.stdout - reflow = True - width = 74 - break_long_words = False - replace_whitespace = True - markdown_safe = False - - in_signoff = False - in_signature = False - - def pprint(string: str): - if markdown_safe and (in_signoff or in_signature) and string: - string += " \\" - if not squash: - print(string, file=out_stream) - else: - parbreak = not string - nonlocal prev_is_parbreak - if skipping or not (parbreak and prev_is_parbreak): - print(string, file=out_stream) - prev_is_parbreak = parbreak - - def wrap(text: str): - return textwrap.wrap( - text, - width=width, - break_long_words=break_long_words, - replace_whitespace=replace_whitespace, - ) - - def flush_paragraph(): - if paragraph: - if reflow: - joined = " ".join(paragraph) - wrapped = wrap(joined) - pprint("\n".join(wrapped)) - else: - for line in paragraph: - for wrapped_line in wrap(line): - pprint(wrapped_line) - paragraph.clear() - - signoff_cache: str = "" - - def check_signoff(line: str) -> bool: - if not line: - return False - words = line.split() - n = len(words) - # first potential signoff line - if not signoff_cache and 1 <= n <= 5 and line[-1] == ",": - return True - # second potential line - elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha(): - for w in words: - if not w[0].isupper(): - return False - return True - else: - return False - - parser = argparse.ArgumentParser( - description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.', - epilog=""" -Author : Daniel Fichtinger -License: ISC -Contact: daniel@ficd.ca - """, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "-w", - "--width", - required=False, - help="Text width for wrapping. (default: %(default)s)", - default=width, - type=int, - ) - parser.add_argument( - "-b", - "--break-long-words", - required=False, - help="Break long words while wrapping. (default: %(default)s)", - action="store_true", - ) - parser.add_argument( - "--no-replace-whitespace", - required=False, - help="Don't normalize whitespace when wrapping.", - action="store_false", - ) - parser.add_argument( - "--no-reflow", - required=False, - help="Don't reflow lines.", - action="store_false", - ) - parser.add_argument( - "--no-signoff", - required=False, - help="Don't preserve signoff line breaks.", - action="store_false", - ) - parser.add_argument( - "--no-signature", - required=False, - help="Don't preserve signature block.", - action="store_false", - ) - parser.add_argument( - "--no-squash", - required=False, - help="Don't squash consecutive paragraph breaks.", - action="store_false", - ) - parser.add_argument( - "-m", - "--markdown-safe", - required=False, - help="Output format safe for Markdown rendering.", - action="store_true", - ) - parser.add_argument( - "-i", - "--input", - required=False, - type=str, - default="STDIN", - help="Input file. (default: %(default)s)", - ) - parser.add_argument( - "-o", - "--output", - required=False, - type=str, - default="STDOUT", - help="Output file. (default: %(default)s)", - ) - args = parser.parse_args() - width = args.width - should_check_signoff = args.no_signoff - should_check_signature = args.no_signature - reflow = args.no_reflow - squash = args.no_squash - replace_whitespace = args.no_replace_whitespace - break_long_words = args.break_long_words - markdown_safe = args.markdown_safe - - if args.input == "STDIN": - reader = sys.stdin - else: - with open(args.input, "r") as in_stream: - reader = in_stream - if args.output != "STDOUT": - out_stream = open(args.output, "w") - - for line in reader: - line = line.rstrip() - if should_check_signoff: - is_signoff = check_signoff(line) - if is_signoff: - in_signoff = True - if not signoff_cache: - signoff_cache = line - else: - pprint(signoff_cache) - pprint(line) - in_signoff = False - signoff_cache = "" - continue - elif not is_signoff and signoff_cache: - paragraph.append(signoff_cache) - signoff_cache = "" - in_signoff = False - if line.startswith("```"): - flush_paragraph() - skipping = not skipping - pprint(line) - elif should_check_signature and line == "--": - flush_paragraph() - skipping = True - in_signature = True - pprint("-- ") - elif not line or re.match( - r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)", - line, - ): - flush_paragraph() - pprint(line) - elif skipping: - pprint(line) - else: - paragraph.append(line) - else: - if signoff_cache: - paragraph.append(signoff_cache) - signoff_cache = "" - flush_paragraph() - if out_stream is not None: - out_stream.close() - - -if __name__ == "__main__": - mailfmt() - -# pyright: basic diff --git a/uv.lock b/uv.lock index 6688ee9..029043b 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11" [[package]] name = "mailfmt" -version = "0.1.0" +version = "1.0.3" source = { editable = "." }