blog.ari.lt/scripts/blog
Ari Archer d0e66254ab
update @ Sun 17 Apr 17:22:38 EEST 2022
Signed-off-by: Ari Archer <ari.web.xyz@gmail.com>
2022-04-17 17:22:38 +03:00

687 lines
19 KiB
Python
Executable file

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Manage blogs"""
import json
import os
import random
import readline
import string
import sys
from atexit import register as fn_register
from base64 import b64decode, b64encode
from datetime import datetime
from glob import iglob
from html import escape as html_escape
from shutil import rmtree
from threading import Thread
from timeit import default_timer as code_timer
from typing import Dict, List, Set, Tuple
from warnings import filterwarnings as filter_warnings
from css_html_js_minify import html_minify # type: ignore
from css_html_js_minify import process_single_css_file
from markdown import markdown # type: ignore
from plumbum.commands.processes import ProcessExecutionError # type: ignore
from pyfzf import FzfPrompt # type: ignore
EXIT_OK: int = 0
EXIT_ERR: int = 1
DEFAULT_CONFIG: Dict = {
"editor-command": f"{os.environ.get('EDITOR', 'vim')} -- %s",
"blog-dir": "b",
"git-url": "/git",
"py-markdown-extensions": [
"markdown.extensions.abbr",
"markdown.extensions.def_list",
"markdown.extensions.fenced_code",
"markdown.extensions.footnotes",
"markdown.extensions.md_in_html",
"markdown.extensions.tables",
"markdown.extensions.admonition",
"markdown.extensions.sane_lists",
"markdown.extensions.toc",
"markdown.extensions.wikilinks",
"pymdownx.betterem",
"pymdownx.caret",
"pymdownx.magiclink",
"pymdownx.mark",
"pymdownx.tilde",
],
"default-keywords": ["website", "blog", "opinion", "article", "ari-web", "ari"],
"page-title": "Ari::web -> Blog",
"page-description": "My blog page",
"colourscheme-type": "dark",
"short-name": "Ari's blogs",
"home-keywords": ["ari", "ari-web", "blog", "ari-archer", "foss", "free", "linux"],
"base-homepage": "https://ari-web.xyz/",
"meta-icons": [{"src": "/favicon.ico", "sizes": "128x128", "type": "image/png"}],
"theme-colour": "#f9f6e8",
"background-colour": "#262220",
"blogs": {},
}
DEFAULT_CONFIG_FILE: str = "blog.json"
HISTORY_FILE: str = ".blog_history"
BLOG_VERSION: int = 1
BLOG_MARKDOWN_TEMPLATE: str = """# %s
<div id="info-bar" aria-hidden="true">
%s | <a href="%s">back</a> | <a href="/">home</a> | <a href="%s">git</a>
</div>
<hr/>
<div id="blog-content">
%s
</div>"""
HTML_HEADER: str = """<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<meta property="og:type" content="website">
<meta name="color-scheme" content="{theme_type}">
<meta name="keywords" content="{keywords}">
<meta name="robots" content="follow"/>
<link rel="stylesheet" href="/content/styles.min.css"/>"""
BLOG_HTML_TEMPLATE: str = f"""<!DOCTYPE html>
<html lang="en">
{HTML_HEADER}
<meta name="description" content="{{blog_description}}">
</head>
<body>
<div>
{{blog}}
</div>
</body>
</html>"""
HOME_PAGE_HTML_TEMPLATE: str = f"""<!DOCTYPE html>
<html lang="en">
{HTML_HEADER}
<meta name="description" content="{{home_page_description}}" />
<link rel="manifest" href="/manifest.json" />
</head>
<body>
<h1>My blogs</h1>
<div id="info-bar" aria-hidden="true">
<p>last blog on: <code>{{lastest_blog_time}}</code> | latest blog: <a href="{{latest_blog_url}}">{{latest_blog_title}}</a> | <a href="{{git_url}}">source code</a></p>
<hr/>
</div>
<div>
{{content}}
</div>
</body>
</html>"""
def log(message: str, header: str = "ERROR", code: int = EXIT_ERR) -> int:
sys.stderr.write(f"{header}: {message}\n")
return code
def sanitise_title(title: str, titleset: Dict) -> str:
_title: str = ""
for char in title:
_title += char if char not in string.whitespace + string.punctuation else "-"
_title = _title.lower()
return (
_title
if _title not in titleset and _title.strip()
else sanitise_title(_title + random.choice(string.digits), titleset)
)
def format_time(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def iinput(prompt: str, default_text: str = "") -> str:
default_text = default_text.strip()
def hook():
if not default_text:
return
readline.insert_text(default_text)
readline.redisplay()
readline.set_pre_input_hook(hook)
user_inpt: str = input(f"({prompt}) ").strip()
readline.set_pre_input_hook()
return user_inpt
def new_config() -> None:
log("Making new config...", "INFO")
with open(DEFAULT_CONFIG_FILE, "w") as cfg:
json.dump(DEFAULT_CONFIG, cfg, indent=4)
def pick_blog(config: Dict) -> str:
try:
blog_id: str = (
FzfPrompt()
.prompt(
map(
lambda key: f"{key} | {b64decode(config['blogs'][key]['title']).decode()!r}",
config["blogs"].keys(),
),
"--prompt='Pick blog: '",
)[0]
.split()[0]
)
except ProcessExecutionError:
log("Fzf process exited unexpectedly")
return ""
if blog_id not in config["blogs"]:
log(f"Blog {blog_id!r} does not exist")
return ""
return blog_id
def new_blog(config: Dict) -> Tuple[int, Dict]:
"""Make a new blog"""
if title := iinput("blog title"):
readline.add_history(title)
us_title: str = title
s_title: str = sanitise_title(us_title, config["blogs"])
blog = {
"title": b64encode(us_title.encode()).decode(),
"content": "",
"version": BLOG_VERSION,
"time": 0.0,
"keywords": "",
"minimise": True,
}
file: str = f"/tmp/{s_title}.md"
open(file, "w").close()
os.system(config["editor-command"] % file)
if not os.path.isfile(file):
return log(f"{file!r} does not exist"), config
with open(file, "r") as md:
blog["content"] = b64encode(md.read().encode()).decode()
os.remove(file)
if not blog["content"].strip(): # type: ignore
return log("Blog cannot be empty"), config
user_keywords: str = iinput("keywords (seperated by spaces)")
readline.add_history(user_keywords)
blog["keywords"] = html_escape(
user_keywords + " " + " ".join(config["default-keywords"])
)
blog["minimise"] = (iinput("Minimise blog? (Y/n)") + "y").lower()[0] == "y"
blog["time"] = datetime.now().timestamp()
config["blogs"][s_title] = blog
return EXIT_OK, config
def build(config: Dict) -> Tuple[int, Dict]:
"""Build, minimise and generate site"""
if len(config["blogs"]) < 1:
return log("Cannot build no blogs"), config
if os.path.isdir(config["blog-dir"]):
rmtree(config["blog-dir"])
os.makedirs(config["blog-dir"], exist_ok=True)
log("Minifying CSS...", "MINIFY")
def build_css() -> None:
saved_stdout = sys.stdout
sys.stdout = open(os.devnull, "w")
if os.path.isfile("content/styles.css"):
process_single_css_file("content/styles.css")
sys.stdout.close()
sys.stdout = saved_stdout
log("Done minifying CSS", "MINIFY")
Thread(target=build_css, daemon=True).start()
log("Building blogs...", "INFO")
def thread(blog_name: str, blog_meta: Dict):
if blog_meta["version"] != BLOG_VERSION:
log(
f"{blog_name}: unmatching version between {blog_meta['version']} and {BLOG_VERSION}",
"WARNING",
)
blog_dir: str = os.path.join(config["blog-dir"], blog_name)
os.makedirs(blog_dir, exist_ok=True)
with open(os.path.join(blog_dir, "index.html"), "w") as blog_html:
blog_time: str = format_time(blog_meta["time"])
blog_title: str = html_escape(b64decode(blog_meta["title"]).decode())
blog_base_html: str = markdown(
BLOG_MARKDOWN_TEMPLATE
% (
blog_title,
blog_time,
config["base-homepage"],
config["git-url"],
markdown(b64decode(blog_meta["content"]).decode())
.replace("<h1>", "<h2>")
.replace("<h1/>", "<h2/>"),
),
extensions=config["py-markdown-extensions"],
)
blog_html_full: str = BLOG_HTML_TEMPLATE.format(
title=config["page-title"],
theme_type=config["colourscheme-type"],
keywords=blog_meta["keywords"],
blog_description=f"Blog on {blog_time} -- {blog_title}",
blog=blog_base_html,
)
if blog_meta["minimise"]:
log(f"Minifying {blog_name!r} HTML", "MINIFY")
blog_html_full = html_minify(blog_html_full)
log(f"Done minifying the HTML of {blog_name!r}", "MINIFY")
blog_html.write(blog_html_full)
log(f"Finished building blog {blog_name!r}", "BUILD")
_tmp_threads: List = []
for blog_name, blog_meta in config["blogs"].items():
t: Thread = Thread(target=thread, args=(blog_name, blog_meta), daemon=True)
_tmp_threads.append(t)
t.start()
for awaiting_thread in _tmp_threads:
awaiting_thread.join()
log("Building blog index...", "INFO")
with open("index.html", "w") as index:
latest_blog_id: str = tuple(config["blogs"].keys())[-1]
lastest_blog: Dict = config["blogs"][latest_blog_id]
lastest_blog_time: str = format_time(lastest_blog["time"])
blog_list = "<ul>"
for blog_id, blog_meta in reversed(config["blogs"].items()):
blog_list += f'<li><a href="{os.path.join(config["blog-dir"], blog_id)}">{html_escape(b64decode(blog_meta["title"]).decode())}</a></li>'
blog_list += "</ul>"
index.write(
html_minify(
HOME_PAGE_HTML_TEMPLATE.format(
title=config["page-title"],
theme_type=config["colourscheme-type"],
keywords=config["home-keywords"],
home_page_description=config["page-description"],
lastest_blog_time=lastest_blog_time,
latest_blog_url=os.path.join(config["blog-dir"], latest_blog_id),
latest_blog_title=b64decode(
html_escape(lastest_blog["title"])
).decode(),
git_url=config["git-url"],
content=blog_list,
)
)
)
return EXIT_OK, config
def list_blogs(config: Dict) -> Tuple[int, Dict]:
"""List blogs"""
if not config["blogs"]:
return log("No blogs to list"), config
for blog_id, blog_meta in config["blogs"].items():
print(
f"""ID: {blog_id}
Title: {b64decode(blog_meta['title']).decode()!r}
Version: {blog_meta['version']}
Time of creation: {format_time(blog_meta['time'])}
Keywords: {blog_meta['keywords'].replace(' ', ', ')}
Minimise: {'Yes' if blog_meta['minimise'] else 'No'}
"""
)
return EXIT_OK, config
def remove_blog(config: Dict) -> Tuple[int, Dict]:
"""Remove a blog page"""
if not config["blogs"]:
return log("No blogs to remove"), config
blog_id: str = pick_blog(config)
if not blog_id:
return EXIT_ERR, config
del config["blogs"][blog_id]
return EXIT_OK, config
def dummy() -> None:
"""Print help/usage information"""
def edit_title(blog: str, config: Dict) -> int:
new_title: str = iinput(
"edit title", b64decode(config["blogs"][blog]["title"]).decode()
)
if not new_title.strip():
return log("New title cannot be empty")
old_blog: dict = config["blogs"][blog].copy()
old_blog["title"] = b64encode(new_title.encode()).decode()
del config["blogs"][blog]
config["blogs"][sanitise_title(new_title, config["blogs"])] = old_blog
del old_blog
return EXIT_OK
def edit_keywords(blog: str, config: Dict) -> int:
new_keywords: str = iinput("edit keywords", config["blogs"][blog]["keywords"])
if not new_keywords.strip():
return log("Keywords cannot be empty")
config["blogs"][blog]["keywords"] = new_keywords
return EXIT_OK
def edit_content(blog: str, config: Dict) -> int:
file: str = f"/tmp/{blog}.md"
with open(file, "w") as blog_md:
blog_md.write(b64decode(config["blogs"][blog]["content"]).decode())
os.system(config["editor-command"] % (file))
with open(file, "r") as blog_md_new:
content: str = blog_md_new.read()
if not content.strip():
blog_md_new.close()
return log("Content of a blog cannot be empty")
config["blogs"][blog]["content"] = b64encode(content.encode()).decode()
return EXIT_OK
def edit_minimise(blog: str, config: Dict) -> int:
minimise: bool = config["blogs"][blog]["minimise"]
config["blogs"][blog]["minimise"] = (
iinput(
f"Minimise this blog's HTML? [{minimise}] (y/n)", "y" if minimise else "n"
)
+ "y"
).lower()[0] == "y"
return EXIT_OK
EDIT_HOOKS: Dict = {
"title": edit_title,
"keywords": edit_keywords,
"content": edit_content,
"minimise": edit_minimise,
}
def edit(config: Dict) -> Tuple[int, Dict]:
"""Edit a blog"""
if not config["blogs"]:
return log("No blogs to edit"), config
blog_id: str = pick_blog(config)
if not blog_id:
return EXIT_ERR, config
try:
hook: str = FzfPrompt().prompt(EDIT_HOOKS.keys(), "--prompt='What to edit: '")[
0
]
if hook not in EDIT_HOOKS:
return log(f"Hook {hook!r} does not exist"), config
EDIT_HOOKS[hook](blog_id, config)
except ProcessExecutionError:
return log("Fzf unexpectedly exited"), config
return EXIT_OK, config
def gen_new_config(config: Dict) -> Tuple[int, Dict]:
"""Make default config"""
if os.path.exists(DEFAULT_CONFIG_FILE):
if iinput("Do you want to overwite config? (y/n)").lower()[0] != "y":
return log("Not overwritting config", "INFO", EXIT_OK), config
new_config()
with open(DEFAULT_CONFIG_FILE, "r") as cfg:
config = json.load(cfg)
return EXIT_OK, config
def clean(config: Dict) -> Tuple[int, Dict]:
"""Clean up current directory"""
TRASH: Set[str] = {
HISTORY_FILE,
config["blog-dir"],
"index.html",
"content/*.min.*",
"*.hash",
"manifest.json",
}
def remove(file: str) -> None:
log(f"Removing {file!r}", "REMOVE")
try:
os.remove(file)
except IsADirectoryError:
rmtree(file)
for glob_ex in TRASH:
for file in iglob(glob_ex, recursive=True):
remove(file)
open(HISTORY_FILE, "w").close()
return EXIT_OK, config
def generate_metadata(config: Dict) -> Tuple[int, Dict]:
"""Generate metadata"""
with open("manifest.json", "w") as manifest:
log(f"Generating {manifest.name}...", "GENERATE")
json.dump(
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"short_name": config["short-name"],
"name": config["page-title"],
"description": config["page-description"],
"icons": config["meta-icons"],
"start_url": ".",
"display": "standalone",
"theme_color": config["theme-colour"],
"background_color": config["background-colour"],
},
manifest,
)
return EXIT_OK, config
def generate_static_full(config: Dict) -> Tuple[int, Dict]:
"""Generate full static site"""
BUILD_CFG: Dict = {
"Cleaning up": clean,
"Building static site": build,
"Generating metatata": generate_metadata,
}
for logger_msg, function in BUILD_CFG.items():
log(f"{logger_msg}...", "STATIC")
code, config = function(config)
if code != EXIT_OK:
log("Failed to generate static site")
return EXIT_ERR, config
return EXIT_OK, config
SUBCOMMANDS: Dict = {
"help": dummy,
"new": new_blog,
"build": build,
"list": list_blogs,
"rm": remove_blog,
"edit": edit,
"new-config": gen_new_config,
"clean": clean,
"metadata": generate_metadata,
"static": generate_static_full,
}
def usage(code: int = EXIT_ERR, config: Dict = None) -> int:
sys.stderr.write(f"Usage: {sys.argv[0]} <subcommand>\n")
for subcommand, func in SUBCOMMANDS.items():
sys.stderr.write(f" {subcommand:20s}{func.__doc__ or ''}\n")
return code
def main() -> int:
"""Entry/main function"""
if not os.path.isfile(HISTORY_FILE):
open(HISTORY_FILE, "w").close()
readline.parse_and_bind("tab: complete")
fn_register(readline.write_history_file, HISTORY_FILE)
fn_register(readline.read_history_file, HISTORY_FILE)
readline.read_history_file(HISTORY_FILE)
readline.set_history_length(5000)
readline.set_auto_history(False)
if not os.path.isfile(DEFAULT_CONFIG_FILE):
new_config()
return EXIT_ERR
if len(sys.argv) != 2:
return usage()
elif sys.argv[1] not in SUBCOMMANDS:
return log(f"{sys.argv[1]!r} is not a subcommand, try `{sys.argv[0]} help`")
elif sys.argv[1] == "help":
return usage(EXIT_OK)
with open(DEFAULT_CONFIG_FILE, "r") as lcfg:
cmd_time_init = code_timer()
code, config = SUBCOMMANDS[sys.argv[1]](config=json.load(lcfg))
log(
f"Finished in {code_timer() - cmd_time_init} seconds with code {code}",
"TIME",
)
not_ci_build = not os.getenv("CI_BUILD")
if config["blogs"] and not_ci_build:
log("Sorting blogs by creation time...", "CLEANUP")
sort_timer = code_timer()
config["blogs"] = dict(
map(
lambda k: (k, config["blogs"][k]),
sorted(config["blogs"], key=lambda k: config["blogs"][k]["time"]),
)
)
log(f"Sorted in {code_timer() - sort_timer} seconds", "TIME")
if not_ci_build:
log("Redumping config", "CONFIG")
dump_timer = code_timer()
with open(DEFAULT_CONFIG_FILE, "w") as dcfg:
json.dump(config, dcfg, indent=4)
log(f"Dumped config in {code_timer() - dump_timer} seconds", "TIME")
return code
return EXIT_OK
if __name__ == "__main__":
assert main.__annotations__.get("return") is int, "main() should return an integer"
filter_warnings("error", category=Warning)
sys.exit(main())