From bcf213414f03dfa74ca8659c4a37aa40019fc996 Mon Sep 17 00:00:00 2001 From: Ari Archer Date: Sun, 15 Dec 2024 02:53:32 +0200 Subject: [PATCH] update @ Sun Dec 15 02:53:32 EET 2024 Signed-off-by: Ari Archer --- content/post_critical.css | 22 ++++ requirements-extra.txt | 2 + scripts/blog.py | 244 +++++++++++++++++++++++++++++++++++++- 3 files changed, 265 insertions(+), 3 deletions(-) diff --git a/content/post_critical.css b/content/post_critical.css index ce2f27a..ba1bdc3 100644 --- a/content/post_critical.css +++ b/content/post_critical.css @@ -3,6 +3,7 @@ --qb: #ede9d3; --qf: #e6e0c8; --lf: #d3ceba; + --ef: #bb6868; } pre { @@ -42,3 +43,24 @@ blockquote:before { #legal { color: var(--lf); } + +.error { + color: var(--ef); +} + +.media { + text-align: center; + background-color: var(--cb); + padding: 1em; +} + +.media > audio { + max-width: 600px; + width: 100%; +} + +.media > img { + max-width: 400px; + width: 100%; + height: auto; +} diff --git a/requirements-extra.txt b/requirements-extra.txt index 941c754..f4141ff 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -1,2 +1,4 @@ pyfzf requests +pillow +python-magic diff --git a/scripts/blog.py b/scripts/blog.py index 71a4a7a..25139b8 100755 --- a/scripts/blog.py +++ b/scripts/blog.py @@ -28,6 +28,7 @@ import mistune.inline_parser import mistune.plugins import unidecode import web_mini +from mistune.renderers.html import HTMLRenderer from readtime import of_markdown as read_time_of_markdown # type: ignore from readtime.result import Result as MarkdownResult # type: ignore @@ -37,6 +38,19 @@ from readtime.result import Result as MarkdownResult # type: ignore __version__: typing.Final[int] = 2 GEN: typing.Final[str] = f"ari-web blog generator version {__version__}" +MEDIA_MIME: dict[str, str] = { + "image/jpeg": "jpeg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "image/avif": "avif", + "image/svg+xml": "svg", + "audio/mpeg": "mp3", + "audio/wav": "wav", + "audio/ogg": "ogg", + "audio/flac": "flac", +} + OK: typing.Final[int] = 0 ER: typing.Final[int] = 1 @@ -415,10 +429,14 @@ STATS_TEMPLATE: typing.Final[str] = ( if NCI: import http.server + import magic import pyfzf # type: ignore + from PIL import Image else: pyfzf: typing.Any = None http: typing.Any = None + magic: typing.Any = None + Image: typing.Any = None class Commands: @@ -656,6 +674,9 @@ def s_to_str(seconds: float) -> str: # markdown TITLE_LINKS_RE: typing.Final[str] = r"<#:[^>]+?>" +MEDIA_EMBED_RE: typing.Final[str] = r"<@:[^>]+?>" + +MEDIA_INDEX: dict[str, dict[str, typing.Any]] = {} def parse_inline_titlelink( @@ -676,20 +697,114 @@ def parse_inline_titlelink( return m.end() +def parse_inline_media_embed( + _: mistune.inline_parser.InlineParser, + m: re.Match[str], + state: mistune.core.InlineState, +) -> int: + text: str = m.group(0)[3:-1] + + if text not in MEDIA_INDEX: + state.append_token( + { + "type": "block_div", + "children": [ + { + "type": "strong_error", + "raw": f"ERROR: Media '{html_escape(text)}' does not exist.", + } + ], + "attrs": {"classes": "media"}, + } + ) + return m.end() + + mdx: dict[str, typing.Any] = MEDIA_INDEX[text] + source: str = f"/media/{text}.{mdx['ext']}" + + if mdx["type"] == "image": + child: dict[str, typing.Any] = { + "type": "image", + "raw": source, + "attrs": { + "alt": mdx["alt"], + "type": mdx["mime"], + "width": mdx["width"], + "height": mdx["height"], + }, + } + elif mdx["type"] == "audio": + child = { + "type": "audio", + "raw": source, + "attrs": { + "alt": mdx["alt"], + "type": mdx["mime"], + }, + } + else: + state.append_token( + { + "type": "block_div", + "children": [ + { + "type": "strong_error", + "raw": f"ERROR: Media '{html_escape(text)}' has an unsupported type.", + } + ], + "attrs": {"classes": "media"}, + } + ) + return m.end() + + state.append_token( + { + "type": "block_div", + "children": [ + child, + {"type": "linebreak"}, + { + "type": "emphasis", + "raw": f"{mdx['alt']} | \"{mdx['title']}\" by {mdx['credit']} ({mdx['license']}). Purpose: {html_escape(mdx['purpose'])}.", + }, + ], + "attrs": {"classes": "media"}, + } + ) + + return m.end() + + def titlelink(md: mistune.Markdown) -> None: md.inline.register("titlelink", TITLE_LINKS_RE, parse_inline_titlelink, before="link") # type: ignore -class BlogRenderer(mistune.HTMLRenderer): - def heading(self, text: str, level: int, **_: typing.Any) -> str: +def media_embed(md: mistune.Markdown) -> None: + md.inline.register("media_embed", MEDIA_EMBED_RE, parse_inline_media_embed, before="link") # type: ignore + + +class BlogRenderer(HTMLRenderer): + def heading(self, text: str, level: int) -> str: slug: str = slugify(text, [], 768, 768) level = max(2, level) return f'# {text}' + def strong_error(self, text: str) -> str: + return f"{text}" + + def block_div(self, text: str, classes: str): + return f'
{text}
' + + def image(self, text: str, alt: str, type: str, width: int, height: int) -> str: + return f'{html_escape(alt)}' + + def audio(self, text: str, alt: str, type: str) -> str: + return f'' + def markdown(md: str, plugins: list[typing.Any]) -> str: - return mistune.create_markdown(plugins=plugins + [titlelink], renderer=BlogRenderer())(md) # type: ignore + return mistune.create_markdown(plugins=plugins + [titlelink, media_embed], renderer=BlogRenderer())(md) # type: ignore # edit commands @@ -1499,6 +1614,123 @@ def blog(config: dict[str, typing.Any]) -> int: return OK +@cmds.new +def media(config: dict[str, typing.Any]) -> int: + """add media""" + + path: str = iinput("media path") + path = os.path.expanduser(path) + + if not os.path.isfile(path): + return err(f"file {path!r} is not a file or does not exist") + + purpose: str = iinput("media purpose") + title: str = iinput("media title") + license: str = iinput("media license (SPDX)") + credit: str = iinput("media credit") + + # MIME stuff + + mime: str = magic.from_file(path, mime=True) + + if mime not in MEDIA_MIME: + return err(f"mime {mime!r} is not a supported media type") + + ext: str = MEDIA_MIME[mime] + + # Filename + + sha256_hash: typing.Any = hashlib.sha256() # type: ignore + with open(path, "rb") as fp: + for byte_block in iter(lambda: fp.read(4096), b""): + sha256_hash.update(byte_block) + hash_hex: str = sha256_hash.hexdigest() + + # Create media dir + media index + + os.makedirs("media", exist_ok=True) + + if os.path.exists("media/media.json"): + with open("media/media.json", "r") as fp: + index: dict[str, dict[str, typing.Any]] = json.load(fp) + else: + index = {} + + # Check if it exists + + if hash_hex in index: + return err(f"media pointing to {path!r} already exists") + + # Process stuff + + filename: str = f"{hash_hex}.{ext}" + fpath: str = f"media/{filename}" + + if mime.startswith("image/"): + with Image.open(path) as img: + width, height = img.size + + shutil.copy(path, fpath) + + if ext in {"jpeg", "png"}: + quality_s: str = iinput("image quality % (1-100)") + + try: + quality: int = int(quality_s) + quality = 100 if quality > 100 else quality + except Exception: + quality = 100 + + img.save( + fpath, + format=ext, + quality=quality, + optimize=True, + ) + + index[hash_hex] = { + "type": "image", + "width": width, + "height": height, + "alt": iinput("alt text"), + } + elif mime.startswith("audio/"): + shutil.copy(path, fpath) + + index[hash_hex] = { + "type": "audio", + "alt": iinput("alt text"), + } + else: + return err(f"unsupported MIME: {mime!r}") + + index[hash_hex].update( + { + "purpose": purpose, + "title": title, + "license": license, + "credit": credit, + "ext": ext, + "mime": mime, + } + ) + + lnew(f"media {hash_hex} created") + + # Update media.json + + with open("media/media.json", "w") as fp: + json.dump(index, fp, indent=config["indent"]) + + with open("media/media_json_hash.txt", "w") as fp: + with open("media/media.json", "rb") as fk: + fp.write(hashlib.sha256(fk.read()).hexdigest()) + + lnew("Updated media.json and media_json_hash.txt") + + return OK + + def main() -> int: """entry / main function""" @@ -1518,6 +1750,12 @@ def main() -> int: else: lnew("using the default config") + if os.path.exists("media/media.json"): + with open("media/media.json", "r") as fp: + MEDIA_INDEX.update(json.load(fp)) + + log("Loaded the media index (media/media.json)") + sort(cfg) log(f"looking command {sys.argv[1]!r} up")