My journey to Write Freely
Ok, story time kids. Once upon a time, I decided I needed a blog. That time was 1996 or so, and I hosted it on whatever I could, posting whatever came through my mind. The whole thing moved from hand coded html to hand coded active server pages, to hand coded php to Serendipity and eventually to WordPress.
It stayed a WordPress site for maybe 10, 15 years?
At some point it got hacked, I cleaned it up, then it got hacked again, so I cleaned it up again and sometime near the end of last year it got so thoroughly fucked that I took everything offline and looked into static site generation.
Hugo, eleventy and others got evaluated and I hated all of it. Eventually I settled on Grav, but it lacked things like interaction and was finicky in other parts.
So, this is my attempt with Write Freely, mostly because it does offer ActivityPub federation.
But while Write Freely can import markdown files, it isn't really good at doing so as a migration functionality. Metadata gets lost, most importantly posting dates.
I griped about it a while and eventually realised that I could import things via SQL. So the task became clear:
- Export everything from my previous WordPress installation into a WordPress export XML file
- let te WordPress Export to markdown tool loose on that file to generate a bunch of markdown files and download all the images that are linked in there.
- ask Copilot to write a python script that converts those markdown files with frontmatter metadata into an SQL script
- tweak that script until it actually works and produces SQL that doesn't clash with the WriteFreely database constraints
- Then realise that a bunch of pictures are missing because the wordpress-to-markdown tool couldn't find them at their original path anymore. Hunt those down.
- Realise that Write Freely doesn't really do pictures. Hah.
- set up an extra web service on the NAS to serve those pictures.
- Do regex search&replace to fix picture URLs
- Import and test, repeat until everything works.
- ...
- Just kidding, there's no profit here!
If you're interested, here's the code I got from Copilot, slightly tweaked by me:
#!/usr/bin/env python3
import os
import re
import yaml
import logging
import unicodedata
import random
import string
from datetime import datetime
from tqdm import tqdm
# ---------- CONFIG ----------
ROOT_DIR = "./output" # folder containing your markdown hierarchy
OUTPUT_SQL = "writefreely_import.sql"
OWNER_ID = 1
COLLECTION_ID = 1
# ----------------------------
# 🔥 MUST be here — global slug registry
slug_registry = {}
logging.basicConfig(
filename="conversion.log",
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
def generate_post_id():
"""Generate a WriteFreely-style 16-char base62 ID."""
alphabet = string.ascii_letters + string.digits
return ''.join(random.choice(alphabet) for _ in range(16))
def slugify_unique(title):
"""Generate a slug and ensure uniqueness by adding numeric suffixes."""
# Base slug
base = unicodedata.normalize("NFKD", title)
base = base.encode("ascii", "ignore").decode("ascii")
base = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower()[:20]
if base == "":
base = "post"
# Check for duplicates
if base not in slug_registry:
slug_registry[base] = 1
return base
# Collision → append suffix
slug_registry[base] += 1
new_slug = f"{base}-{slug_registry[base]}"
logging.warning(f"Duplicate slug detected: '{base}' → using '{new_slug}'")
return new_slug
def detect_language(text):
german_chars = "äöüÄÖÜß"
german_count = sum(text.count(c) for c in german_chars)
return "de" if german_count > 2 else "en"
def normalize_taxonomy(value):
"""Ensure taxonomy fields are always lists, even if quoted or single."""
if value is None:
return []
if isinstance(value, list):
return [str(v).strip('"') for v in value]
if isinstance(value, str):
return [value.strip('"')]
return []
def parse_markdown(path):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
parts = content.split("---")
if len(parts) < 3:
raise ValueError(f"File {path} missing front matter")
front_matter = yaml.safe_load(parts[1])
body = "---".join(parts[2:]).strip()
title = front_matter.get("title", "Untitled")
date_raw = front_matter.get("date", "2000-01-01")
# normalize date
# dt = datetime.strptime(date_raw, "%Y-%M-%D")
created = date_raw
taxonomy = front_matter.get("taxonomy", {})
categories = normalize_taxonomy(taxonomy.get("category"))
tags = normalize_taxonomy(taxonomy.get("tag"))
all_tags = list({t for t in categories + tags})
language = detect_language(body)
slug = slugify_unique(title)
return {
"title": title,
"slug": slug,
"created": created,
"content": body,
"language": language,
"tags": all_tags
}
def generate_sql(posts):
sql_lines = []
tag_lines = []
for post in posts:
post_id = generate_post_id()
title_sql = post['title'].replace("'", "''")
content_sql = post['content'].replace("'", "''")
sql_lines.append(
f"INSERT INTO posts "
f"(id, slug, text_appearance, language, rtl, view_count, privacy, owner_id, collection_id, "
f"created, updated, title, content) VALUES ("
f"'{post_id}', '{post['slug']}', 'norm', '{post['language']}', 0, 0, 0, "
f"{OWNER_ID}, {COLLECTION_ID}, "
f"'{post['created']}', '{post['created']}', "
f"'{title_sql}', '{content_sql}');"
)
for tag in post["tags"]:
tag_clean = tag.replace("'", "''")
tag_lines.append(
f"INSERT INTO post_tags (post_id, tag) VALUES ('{post_id}', '{tag_clean}');"
)
return "\n".join(sql_lines + tag_lines)
def main():
posts = []
md_files = []
# Collect markdown files
for root, dirs, files in os.walk(ROOT_DIR):
for file in files:
if file.endswith(".md"):
md_files.append(os.path.join(root, file))
logging.info(f"Found {len(md_files)} markdown files.")
# Process with progress bar
for path in tqdm(md_files, desc="Processing markdown files"):
try:
post = parse_markdown(path)
posts.append(post)
logging.info(f"Parsed: {path}")
except Exception as e:
logging.error(f"Error parsing {path}: {e}")
sql = generate_sql(posts)
with open(OUTPUT_SQL, "w", encoding="utf-8") as f:
f.write(sql)
logging.info(f"SQL written to {OUTPUT_SQL}")
print(f"Done! SQL written to {OUTPUT_SQL}")
if __name__ == "__main__":
main()
I also write about roleplaying games in english und auf Deutsch!



