"""Shared helpers for reading the WordPress WXR export.""" from __future__ import annotations import re import unicodedata import xml.etree.ElementTree as ET from dataclasses import dataclass, field from pathlib import Path NS = { "wp": "http://wordpress.org/export/1.2/", "content": "http://purl.org/rss/1.0/modules/content/", "excerpt": "http://wordpress.org/export/1.2/excerpt/", "dc": "http://purl.org/dc/elements/1.1/", } REPO_ROOT = Path(__file__).resolve().parent.parent EXPORT = REPO_ROOT / "wordpress-export" / "motorradclubgiebelwaldev.WordPress.2026-07-28.xml" CACHE = REPO_ROOT / ".cache" / "uploads" # German transliteration first, so "Jubiläum" becomes "jubilaeum" and not "jubilaum". UMLAUTS = { "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", } def slugify(value: str, *, keep_dots: bool = False) -> str: for src, dst in UMLAUTS.items(): value = value.replace(src, dst) value = unicodedata.normalize("NFKD", value) value = "".join(c for c in value if not unicodedata.combining(c)) allowed = r"[^a-z0-9._-]+" if keep_dots else r"[^a-z0-9-]+" value = re.sub(allowed, "-", value.lower()) return re.sub(r"-{2,}", "-", value).strip("-") def slugify_filename(name: str) -> str: stem, _, ext = name.rpartition(".") return f"{slugify(stem, keep_dots=False)}.{ext.lower()}" def clean_media_name(name: str) -> str: """Strip the bookkeeping suffixes WordPress bakes into upload filenames. ``Harry_2-e1654849333614.jpg`` -> ``harry-2.jpg`` ``IMG_20220527_121440-small-scaled.jpg`` -> ``img-20220527-121440-small.jpg`` """ stem, _, ext = slugify_filename(name).rpartition(".") stem = re.sub(r"-e\d{9,}$", "", stem) # post-crop revision id stem = re.sub(r"-\d+x\d+$", "", stem) # generated size variant stem = re.sub(r"-scaled$", "", stem) # WP "big image" downscale return f"{stem or 'bild'}.{ext}" def text(node: ET.Element | None, path: str, default: str = "") -> str: if node is None: return default found = node.find(path, NS) if found is None or found.text is None: return default return found.text @dataclass class Item: element: ET.Element post_id: str post_type: str status: str title: str slug: str link: str parent: str date_gmt: str modified_gmt: str content: str excerpt: str attachment_url: str meta: dict[str, str] = field(default_factory=dict) categories: list[tuple[str, str]] = field(default_factory=list) def load_items() -> dict[str, Item]: channel = ET.parse(EXPORT).getroot().find("channel") items: dict[str, Item] = {} for el in channel.findall("item"): meta = {} for m in el.findall("wp:postmeta", NS): key = text(m, "wp:meta_key") meta[key] = text(m, "wp:meta_value") cats = [ (c.get("domain", ""), c.get("nicename", "")) for c in el.findall("category") ] item = Item( element=el, post_id=text(el, "wp:post_id"), post_type=text(el, "wp:post_type"), status=text(el, "wp:status"), title=(el.findtext("title") or "").strip(), slug=text(el, "wp:post_name"), link=(el.findtext("link") or "").strip(), parent=text(el, "wp:post_parent"), date_gmt=text(el, "wp:post_date_gmt"), modified_gmt=text(el, "wp:post_modified_gmt"), content=text(el, "content:encoded"), excerpt=text(el, "excerpt:encoded"), attachment_url=text(el, "wp:attachment_url"), meta=meta, categories=cats, ) items[item.post_id] = item return items def attachments(items: dict[str, Item]) -> dict[str, Item]: return {k: v for k, v in items.items() if v.post_type == "attachment"} def cache_path(url: str) -> Path: """Local cache location mirroring the wp-content/uploads/YYYY/MM layout.""" rel = url.split("/wp-content/uploads/", 1)[-1] parts = rel.split("/") return CACHE.joinpath(*parts[:-1], slugify_filename(parts[-1]))