#! /usr/bin/env python3 """ script for selecting content from my art folder and copying them into my Zola static site template. Requires two arguments: A root directory to search for toml files (which describe the art sets), and the directory to copy the structured art/posts into (could be default "." output dir, tbh) This is mostly because I wish to both highly structure the data on the site, and also treat it as ephemeral/rebuildable from the source (as this will also process the images to web-encode them). """ import argparse import os import re import subprocess import tomllib # basic arg parsing. usually executed as `./gallery.py $NAS_MOUNT/art .` parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("in_dir", nargs='?', type=str) parser.add_argument("out_dir", nargs='?', type=str) # zola project root parser.add_argument("--verbose", "-v", action=argparse.BooleanOptionalAction) args = parser.parse_args() # YYYY-MM-DD; just a quick verify so zola doesn't explode. DATE_PATTERN = r'\d\d\d\d-\d\d-\d\d' MAX_HEIGHT = 1440 # https://github.com/SalOne22/rimage/ RESIZE_ARGS = ["--resize", f"height={MAX_HEIGHT}"] JPG_CMD_BASE = ["rimage", "mozjpeg", "-d"] # , output dir, [resize_args], input_file PNG_CMD_BASE = ["rimage", "oxipng", "-d"] # , output dir, [resize_args], input_file CMD_BASE = { "png": PNG_CMD_BASE, "jpg": JPG_CMD_BASE, "jpeg": JPG_CMD_BASE, } # helper funcs def log(s): print(s) if args.verbose else None def find_tomls(root_dir): """ takes directory to search as input returns iterator of (rootdir, file) tuples that's purely to make my life easier in the next step as we operate rooted in each dir """ return ((d, file) for d, __, files in os.walk(root_dir) for file in files if file.endswith(".toml")) # classes so I do not need to pass around 8 million args perpetually class Config: def __init__(this, root_dir, name): this.dir = root_dir this.name = name this.__load() this.__parse() log(this.blob) def __load(this): with open(os.path.join(this.dir, this.name), "rb") as f: this.blob = tomllib.load(f) log(f"{this.dir}: {this.blob}") def __parse(this): # pull out top-level fields artist[s], url[s] # each of this fields is mandatory if this.blob["artist"]: this.artists = [this.blob.pop("artist")] else: this.artists = this.blob.pop("artists") if this.blob["url"]: this.urls = [this.blob.pop("url")] elif this.blob["urls"]: this.urls = this.blob.pop("urls") def process(this): # remaining config should a map of maps (an array of named hashtables) for name, table in this.blob.items(): art = Art(this.dir, name, table, this.artists, this.urls) art.process() class Art: def __init__(this, directory, slug, meta, artists, urls): this.dir = directory this.slug = slug this.meta = meta this.artists = artists this.urls = urls this.output_root = args.out_dir this.url_map = { artist: url for artist, url in zip(this.artists, this.urls) } this.__parse() def __parse(this): this.files = this.meta.pop("files") this.downscale = this.meta.pop("downscale", False) this.date = this.meta.pop("date", None) # oh god it's gotta be formatted for Zola # see https://www.getzola.org/documentation/content/page/ this.title = this.meta.pop("pretty_name") # mandatory descriptor this.description = this.meta.pop("flavor") # mandatory descriptor this.who = this.meta.pop("who") # mandatory descriptor this.vibes = this.meta.pop("vibes", []) # tags can be empty/nonexistent this.nsfw = this.meta.pop("nsfw", False) # reasonable default for most of the gallery this.alt_text = this.meta.pop("alt", "") # config should be consumed fully now. # this Could be a class by now, but you forget: this is a core part of a glorified shell script. Yeehaw. if this.meta: log(f"remaining config keys: {this.meta}") def process(this): # todo: arg to skip reprocessing files when I just want to regen the md pages this.process_files() this.process_gallery_page() def process_files(this): output_dir = os.path.join(this.output_root, "static", "art") os.makedirs(output_dir, exist_ok=True) for file in this.files: in_path = os.path.join(this.dir, file) img = Image(in_path, output_dir, this.downscale) img.process() def process_gallery_page(this): out_dir = os.path.join(this.output_root, "content", "gallery") os.makedirs(out_dir, exist_ok=True) index_path = os.path.join(out_dir, f"{this.slug}.md") file_contents = this.render_markdown() with open(index_path, "w", encoding="utf-8") as index: index.write(file_contents) def render_markdown(this): lines = ["+++", 'template="gallery_page.html"'] lines.append(f'title = "{this.title}"') lines.append(f'description = "{this.description}"') if this.date and re.fullmatch(DATE_PATTERN, this.date): lines.append(f'date = "{this.date}"') lines.append("[taxonomies]") for k, v in zip(["artists", "tags", "who"], [this.artists, this.vibes, this.who]): lines.append(f'"gallery/{k}"={v}') lines.append("[extra]") lines.append(f'artist_urls={this.urls}') lines.append(f'url_map={this.url_map}'.replace("': ", "' = ")) # zola wants maps to be {k = v, } python serializes as k: v; we sed and continue on. # HACK files = [f.split("/")[-1] for f in this.files] # END HACK lines.append(f'files={files}') if this.nsfw: lines.append(f'nsfw="{this.nsfw}"') lines.append(f'alt="{this.alt_text}"') lines.append("+++") lines.append(f"# {this.title}") if this.description: lines.append(this.description) lines.append("") # trailing newline :) return "\n".join(lines) class Image: def __init__(this, in_path, out_dir, downscale): this.input = in_path this.outdir = out_dir this.downscale = downscale def process(this): log(f"processing {this.input} to {this.outdir}") extension = this.input.lower().split(".")[-1] cmdline = [] cmdline += CMD_BASE[extension] cmdline.append(this.outdir) if this.check_resize(): cmdline += RESIZE_ARGS cmdline.append(this.input) subprocess.run(cmdline) def check_resize(this): if this.downscale: # imagemagick; removable once rimage has conditional resizing/downsample-only mode check = subprocess.run(["identify", "-ping", "-format", "%h", this.input], capture_output=True) print(f"image height: {check.stdout}") height = int(check.stdout) return height > MAX_HEIGHT return False # yipee! def main(): for root, name in find_tomls(args.in_dir): if name.startswith("._"): log(f"skipping macos toml: {name}") continue if root == args.in_dir: # filter out the template ones in the root dir log(f"ignoring toml in root dir: {name}") continue toml = Config(root, name) toml.process() if __name__ == "__main__": main()