Files
heygo/scripts/export-django-links.py

419 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Standalone exporter for the old Django "Links" shortlinks database.
Reads a Django SQLite DB (the `links` app's `Link` model, plus `auth_user`
for owner emails and `django_content_type` for table discovery) and writes
one JSON object per line (JSONL) with the canonical shortlink fields:
alias, link_type, target_url, content_markdown, description,
tags, created_at, updated_at
This script is intentionally standalone: it imports nothing from the old
Django project and makes no modifications to it. It only opens the SQLite
file read-only and runs SELECTs.
Field mapping (Django Link model -> export):
alias -> alias
link_type (LINK/CUSTOM) -> link_type ("redirect" / "custom")
original_url -> target_url
text -> content_markdown
description -> description
tags (M2M -> Tag) -> tags (list of tag names)
created_at -> created_at
updated_at -> updated_at
Owner handling:
The current Django Link model has no owner/user FK. To stay forward-
compatible with older dumps that did carry an owner column, the script
introspects the `links_link` table for an owner-like FK
(`owner_id` / `user_id` / `created_by_id`) and, when present, joins it to
`auth_user` so `--owner-email` can filter. When no such column exists,
`--owner-email` prints a warning to stderr and exports all links.
"""
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import sys
from typing import Any, Iterable
# Django LinkType choices -> export vocabulary.
LINK_TYPE_MAP = {"LINK": "redirect", "CUSTOM": "custom"}
DEFAULT_LINK_TYPE = "redirect"
# Columns on `links_link` that, if present, are treated as an owner FK into
# auth_user.id (checked in this order; first match wins).
OWNER_COLUMNS = ("owner_id", "user_id", "created_by_id")
# Columns we expect to read from the link table. Names match the Django
# model; we tolerate missing columns by falling back to None.
LINK_COLUMNS = (
"id",
"alias",
"link_type",
"original_url",
"text",
"description",
"created_at",
"updated_at",
)
def _stderr(msg: str) -> None:
print(msg, file=sys.stderr)
def resolve_db_path(arg_db: str | None) -> str:
"""Resolve the DB path from --db, then $DJANGO_DB_PATH. Exit on missing."""
db_path = arg_db or os.environ.get("DJANGO_DB_PATH")
if not db_path:
_stderr("error: no DB path provided. Use --db or set DJANGO_DB_PATH.")
sys.exit(2)
if not os.path.exists(db_path):
_stderr(f"error: DB file not found: {db_path}")
sys.exit(2)
if os.path.isdir(db_path):
_stderr(f"error: DB path is a directory, not a file: {db_path}")
sys.exit(2)
return db_path
def open_db(db_path: str) -> sqlite3.Connection:
"""Open the SQLite DB read-only so we can never mutate the old DB."""
uri = f"file:{db_path}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.OperationalError:
# Very old / locked DBs may reject read-only URI; fall back to a
# plain read-only connection via immutability, never writing.
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = 1") # belt-and-suspenders: no writes
return conn
def table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
rows = conn.execute(f'PRAGMA table_info("{table}")').fetchall()
return [r["name"] for r in rows]
def discover_link_table(conn: sqlite3.Connection) -> str:
"""Find the links_link table via django_content_type, else a schema scan."""
try:
ct = conn.execute(
"SELECT app_label, model FROM django_content_type "
"WHERE app_label = ? AND model = ?",
("links", "link"),
).fetchone()
except sqlite3.OperationalError:
ct = None
if ct:
return f"{ct['app_label']}_{ct['model']}" # => "links_link"
# Fallback: any table containing a unique-ish "alias" column.
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
for t in tables:
cols = table_columns(conn, t["name"])
if "alias" in cols and "original_url" in cols:
return t["name"]
raise LookupError("could not find the links_link table in this DB")
def discover_tag_table(conn: sqlite3.Connection) -> str | None:
"""Find the links_tag table (tag names), if it exists."""
try:
ct = conn.execute(
"SELECT app_label, model FROM django_content_type "
"WHERE app_label = ? AND model = ?",
("links", "tag"),
).fetchone()
except sqlite3.OperationalError:
ct = None
if ct:
return f"{ct['app_label']}_{ct['model']}".replace(" ", "_")
# Fallback: a table with a `name` column in the links app namespace.
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'links_%'"
).fetchall()
for t in tables:
cols = table_columns(conn, t["name"])
if "name" in cols and "id" in cols and "alias" not in cols:
return t["name"]
return None
def discover_m2m_table(conn: sqlite3.Connection, link_table: str, tag_table: str) -> str | None:
"""Find the links_link -> links_tag M2M through table, if any."""
# Django default name: links_link_tags
candidates = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND (name LIKE ? OR name LIKE ?)",
(f"{link_table}_%", "%_tags"),
).fetchall()
for t in candidates:
name = t["name"]
cols = table_columns(conn, name)
# An M2M through table has FKs to both sides. Django names them
# <link_table> (or link) and <tag_table> (or tag).
has_link_fk = any(c in cols for c in ("link_id", link_table + "_id"))
has_tag_fk = any(c in cols for c in ("tag_id", tag_table + "_id"))
if has_link_fk and has_tag_fk:
return name
return None
def load_users(conn: sqlite3.Connection) -> dict[int, str]:
"""Map auth_user.id -> email (lowercased) for owner filtering."""
users: dict[int, str] = {}
try:
rows = conn.execute("SELECT id, email FROM auth_user").fetchall()
except sqlite3.OperationalError:
try:
rows = conn.execute("SELECT id, username FROM auth_user").fetchall()
# treat username as the "email"-ish identifier when email missing
except sqlite3.OperationalError:
return users
for r in rows:
users[r["id"]] = (r["email"] if "email" in r.keys() else r["username"]).lower()
return users
def detect_owner_column(conn: sqlite3.Connection, link_table: str) -> str | None:
"""Return the owner FK column name on the link table, if any."""
cols = table_columns(conn, link_table)
for c in OWNER_COLUMNS:
if c in cols:
return c
return None
def link_table_has_link_id_column(conn: sqlite3.Connection, m2m_table: str, link_table: str) -> str:
"""Pick the FK column on the M2M table that points at the link row."""
cols = table_columns(conn, m2m_table)
for cand in (link_table + "_id", "link_id"):
if cand in cols:
return cand
# last resort: the first <something>_id column
for c in cols:
if c.endswith("_id") and c != "tag_id" and not c.endswith("tag_id"):
return c
raise LookupError(f"could not find link FK on M2M table {m2m_table}")
def query_links(
conn: sqlite3.Connection,
link_table: str,
tag_table: str | None,
m2m_table: str | None,
owner_col: str | None,
owner_email: str | None,
users: dict[int, str],
) -> Iterable[sqlite3.Row]:
"""Build and run the SELECT, applying the owner filter when possible."""
if owner_email and owner_col is None:
_stderr(
"warning: --owner-email given but the links_link table has no "
"owner/user FK column; owner filter cannot be applied. "
"Exporting all links."
)
# Assemble column list defensively (some columns may not exist in older
# DBs); we read only the ones present.
cols = table_columns(conn, link_table)
select_cols = [c for c in LINK_COLUMNS if c in cols]
if owner_col and owner_col in cols and owner_col not in select_cols:
select_cols.append(owner_col)
base = f'SELECT {", ".join(select_cols)} FROM "{link_table}"'
where_parts: list[str] = []
params: list[Any] = []
# Validate owner email against known users when an owner column exists.
if owner_email and owner_col is not None:
owner_email_lc = owner_email.strip().lower()
if not users:
_stderr(
"warning: --owner-email given but auth_user table is empty "
"or missing; no links will match the owner filter."
)
where_parts.append("1=0")
else:
owner_user_ids = [uid for uid, em in users.items() if em == owner_email_lc]
if not owner_user_ids:
_stderr(
f"warning: no auth_user row matches --owner-email "
f"{owner_email!r}; export will be empty."
)
where_parts.append("1=0")
else:
placeholders = ",".join("?" for _ in owner_user_ids)
where_parts.append(f"{owner_col} IN ({placeholders})")
params.extend(owner_user_ids)
sql = base
if where_parts:
sql += " WHERE " + " AND ".join(where_parts)
sql += " ORDER BY alias ASC"
return conn.execute(sql, params)
def collect_tags(
conn: sqlite3.Connection,
link_id: int,
tag_table: str | None,
m2m_table: str | None,
link_fk_col: str,
) -> list[str]:
if not tag_table or not m2m_table:
return []
rows = conn.execute(
f'SELECT t."name" FROM "{tag_table}" t '
f'JOIN "{m2m_table}" m ON m."tag_id" = t."id" '
f'WHERE m."{link_fk_col}" = ? ORDER BY t."name" ASC',
(link_id,),
).fetchall()
return [r["name"] for r in rows]
def row_to_record(
conn: sqlite3.Connection,
row: sqlite3.Row,
tag_table: str | None,
m2m_table: str | None,
link_fk_col: str,
) -> dict[str, Any]:
link_id = row["id"] if "id" in row.keys() else None
def g(col: str) -> Any:
return row[col] if col in row.keys() else None
raw_link_type = g("link_type")
if raw_link_type in LINK_TYPE_MAP:
link_type = LINK_TYPE_MAP[raw_link_type]
elif not raw_link_type:
link_type = DEFAULT_LINK_TYPE
else:
link_type = raw_link_type
tags = collect_tags(conn, link_id, tag_table, m2m_table, link_fk_col) if link_id is not None else []
return {
"alias": g("alias"),
"link_type": link_type,
"target_url": g("original_url"),
"content_markdown": g("text"),
"description": g("description"),
"tags": tags,
"created_at": g("created_at"),
"updated_at": g("updated_at"),
}
def emit(records: Iterable[dict[str, Any]], out_path: str | None) -> int:
"""Write records as JSONL to out_path or stdout. Returns count written."""
fh: Any = sys.stdout
if out_path and out_path != "-":
fh = open(out_path, "w", encoding="utf-8")
count = 0
for rec in records:
fh.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
count += 1
if fh is not sys.stdout:
fh.close()
return count
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="export-django-links",
description=(
"Export shortlinks from an old Django 'links' app SQLite DB to "
"JSONL. Standalone; does not modify the Django project."
),
)
p.add_argument(
"--db",
metavar="PATH",
default=None,
help="Path to the Django SQLite DB file (overrides $DJANGO_DB_PATH).",
)
p.add_argument(
"--output",
"-o",
metavar="PATH",
default=None,
help=(
"Output JSONL file path. Use '-' or omit for stdout (default)."
),
)
p.add_argument(
"--owner-email",
metavar="EMAIL",
default=None,
help=(
"Only export links owned by this admin user (by auth_user email). "
"Requires an owner/user FK column on the links table; if none "
"exists, warns and exports all links."
),
)
return p
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
db_path = resolve_db_path(args.db)
conn = open_db(db_path)
try:
link_table = discover_link_table(conn)
except LookupError as e:
_stderr(f"error: {e}")
conn.close()
return 1
tag_table = discover_tag_table(conn)
m2m_table = (
discover_m2m_table(conn, link_table, tag_table) if tag_table else None
)
users = load_users(conn)
owner_col = detect_owner_column(conn, link_table)
link_fk_col = "link_id"
if m2m_table:
try:
link_fk_col = link_table_has_link_id_column(conn, m2m_table, link_table)
except LookupError:
link_fk_col = "link_id"
rows = query_links(
conn,
link_table,
tag_table,
m2m_table,
owner_col,
args.owner_email,
users,
)
records = (
row_to_record(conn, r, tag_table, m2m_table, link_fk_col) for r in rows
)
count = emit(records, args.output)
conn.close()
if args.output and args.output != "-":
_stderr(f"exported {count} link(s) to {args.output}")
else:
_stderr(f"exported {count} link(s) to stdout")
return 0
if __name__ == "__main__":
raise SystemExit(main())