diff options
Diffstat (limited to 'tag_preview.py')
| -rw-r--r-- | tag_preview.py | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/tag_preview.py b/tag_preview.py new file mode 100644 index 0000000..d3e28c7 --- /dev/null +++ b/tag_preview.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Preview tag assignments for all blog posts. +Run with --write to actually add #+filetags to org files. +""" +import re +import sys +from pathlib import Path + +CONTENT_DIR = Path("./content/blog") + +# Explicit overrides take priority over rules +OVERRIDES = { + "2018-11-28-cpp-compiler": ["linux"], + "2019-12-16-password-security": ["security"], + "2020-07-26-business-analysis": ["audit"], + "2020-08-22-redirect-github-pages": ["web"], + "2020-09-22-internal-audit": ["audit"], + "2020-12-28-neon-drive": ["personal"], + "2020-12-29-zork": ["personal"], + "2021-08-25-audit-sampling": ["audit"], + "2021-12-04-cisa": ["audit"], + "2023-05-22-burnout": ["personal"], + "2023-06-20-audit-review-template": ["audit"], + "2023-08-18-agile-auditing": ["audit"], + "2025-05-30-it-audit-career": ["audit"], + "2025-11-13-wcag": ["audit", "web"], + "2025-11-23-it-audit-ai": ["audit"], + "2026-02-21-auditing-aws-iam": ["audit"], + "2026-02-21-auditing-aws-passwords": ["audit"], + "2026-03-03-auditing-aws-s3": ["audit"], + "2020-09-01-visual-recognition": ["linux"], + "2024-01-26-audit-dashboard": ["audit"], + "2023-09-19-audit-sql-scripts": ["audit"], + "2021-04-28-photography": ["personal"], + "2022-06-22-daily-poetry": ["personal"], + "2020-09-25-happiness-map": ["personal"], + "2022-03-03-financial-database": ["self-hosting"], + "2023-01-03-recent-website-changes": ["personal", "web"], + "2025-09-25-minimalist-website-redesign": ["web"], + "2026-02-07-indieweb-carnival-2026-02-intersecting-interests": ["personal", "web"], +} + +TAG_RULES = { + "audit": [ + "audit", "cisa", "ansoff", + ], + "emacs": [ + "emacs", "org-mode", "org-blog", "mu4e", "doom-emacs", + "emacs-carnival", "gnu-stow", + ], + "linux": [ + "linux", "ubuntu", "debian", "fedora", "alpine", + "customizing-ubuntu", "linux-software", "linux-display", + "mtp-linux", "serenity-os", "fedora-i3", "fedora-login", + "flatpak", "zfs", "ubuntu-emergency", "ubuntu-on-macos", + "steam-on-ntfs", "bash-it", "byobu", "scli", + "terminal-lifestyle", "curseradio", "st", "flac-to-opus", + "pinetime", "server-build", "macos", "macos-customization", + "deprecated-trusted-gpg", "exiftool", "graphene", + "asahi", "aerc", "rocket-league", + ], + "self-hosting": [ + "self-hosting", "homelab", "vps-web-server", "git-server", + "plex", "nextcloud", "wireguard", "backblaze", "syncthing", + "docker", "n8n", "prometheus-grafana", "transmission", + "goaccess", "unifi", "git-mirror", "automating-weblorg", + ], + "security": [ + "aes-encryption", "cryptography", "gnupg", "password", + "server-hardening", "ssh-mfa", "ufw", "hardening", + "authelia", "wireguard", "njalla-dns", "cloudflare-dns", + "ditching-cloudflare", "obscura-vpn", + ], + "privacy": [ + "privacy", "graphene-os", "session-messenger", "fediverse", + "digital-minimalism", "privacy-com", "local-llm", + "email-migration", "dont-say-hello", + ], + "web": [ + "nginx", "apache", "php", "css", "redirect", "website-redesign", + "gemini", "indieweb", "weblorg", "html", "digital-garden", + "tableau", + ], + "personal": [ + "mediocrity", "burnout", "tuesday", "leaving-the-office", + "vaporwave", "video-game-sales", "reliable-notes", + "mass-unlike-tumblr", "changing-git-authors", "delete-gitlab", + "clone-github", "this-year-i-will", "completion", + "seum", "neon-drive", "zork", + ], +} + +def assign_tags(slug: str, title: str) -> list[str]: + if slug in OVERRIDES: + return OVERRIDES[slug] + slug_lower = slug.lower() + title_lower = title.lower() + tags = [] + for tag, patterns in TAG_RULES.items(): + for pattern in patterns: + if pattern in slug_lower or pattern in title_lower: + if tag not in tags: + tags.append(tag) + break + return sorted(tags) + +def get_title(path: Path) -> str: + title_pat = re.compile(r"^#\+title:\s*(.+)$", re.IGNORECASE) + with path.open("r", encoding="utf-8") as f: + for line in f: + m = title_pat.match(line) + if m: + return m.group(1).strip() + return path.stem + +def has_filetags(path: Path) -> bool: + with path.open("r", encoding="utf-8") as f: + for line in f: + if re.match(r"^#\+filetags:", line, re.IGNORECASE): + return True + return False + +def write_filetags(path: Path, tags: list[str]): + tag_str = ":" + ":".join(tags) + ":" + content = path.read_text(encoding="utf-8") + slug_pat = re.compile(r"(#\+slug:\s*.+\n)", re.IGNORECASE) + m = slug_pat.search(content) + if m: + insert_at = m.end() + new_content = content[:insert_at] + f"#+filetags: {tag_str}\n" + content[insert_at:] + path.write_text(new_content, encoding="utf-8") + return True + return False + +write_mode = "--write" in sys.argv + +untagged = [] +rows = [] +for org_path in sorted(CONTENT_DIR.glob("*.org")): + slug = org_path.stem + title = get_title(org_path) + tags = assign_tags(slug, title) + if not tags: + untagged.append((slug, title)) + rows.append((slug, title, tags)) + +print(f"{'SLUG':<50} {'TAGS'}") +print("-" * 85) +for slug, title, tags in rows: + tag_str = ", ".join(tags) if tags else "-- UNTAGGED --" + print(f"{slug:<50} {tag_str}") + +print(f"\nTotal: {len(rows)} posts | Untagged: {len(untagged)}") +if untagged: + print("\nUntagged posts:") + for slug, title in untagged: + print(f" {slug}") + +if write_mode: + written = 0 + skipped = 0 + for org_path in sorted(CONTENT_DIR.glob("*.org")): + slug = org_path.stem + title = get_title(org_path) + tags = assign_tags(slug, title) + if not tags: + skipped += 1 + continue + if has_filetags(org_path): + skipped += 1 + continue + if write_filetags(org_path, tags): + written += 1 + print(f"\nWrote filetags to {written} files, skipped {skipped}.") +else: + print("\nRun with --write to apply filetags to org files.") |
