#!/usr/bin/env python3 """ Export the museum guide/explain static data package from a Navicat SQL dump. The parser streams the dump statement by statement and only buffers CREATE TABLE or INSERT statements for the guide static-data tables. It does not connect to a database and never records credentials in generated metadata. """ from __future__ import annotations import argparse import datetime as dt import decimal import importlib.util import json import re from pathlib import Path from typing import Any, Iterable PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT = PROJECT_ROOT / "static" / "guide-data" DEFAULT_NAV_ASSETS = ( PROJECT_ROOT / "static" / "nav-assets" / "codex_nav_20260621_175310_museum_default_centerline_finalxy" ) SOURCE_SCRIPT = Path(__file__).with_name("export-guide-static-data.py") def load_source_module(): spec = importlib.util.spec_from_file_location("guide_static_export", SOURCE_SCRIPT) if spec is None or spec.loader is None: raise RuntimeError(f"failed to load source export script: {SOURCE_SCRIPT}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module guide_export = load_source_module() SCHEMA_VERSION = guide_export.SCHEMA_VERSION TABLES: dict[str, str] = guide_export.TABLES TARGET_TABLES = set(TABLES) CREATE_RE = re.compile(r'CREATE\s+TABLE\s+"NATURE_SGS"\."(?P[A-Z0-9_]+)"', re.I) INSERT_RE = re.compile(r'INSERT\s+INTO\s+"NATURE_SGS"\."(?P
[A-Z0-9_]+)"', re.I | re.S) INSERT_VALUES_RE = re.compile( r'^\s*INSERT\s+INTO\s+"NATURE_SGS"\."(?P
[A-Z0-9_]+)"\s*' r'(?P\((?:\s*"[^"]+"\s*,?)+\))?\s*VALUES\s*(?P.*?);?\s*$', re.I | re.S, ) HEADER_FIELD_RE = re.compile(r"^\s*(Source Host|Source Schema|Date)\s*:\s*(.*?)\s*$", re.I) def statement_complete(sql: str) -> bool: in_string = False i = 0 while i < len(sql): char = sql[i] if char == "'": if in_string and i + 1 < len(sql) and sql[i + 1] == "'": i += 2 continue in_string = not in_string elif char == ";" and not in_string: return True i += 1 return False def iter_target_statements(sql_dump_path: Path) -> Iterable[tuple[str, str]]: buffer: list[str] = [] table: str | None = None with sql_dump_path.open("r", encoding="utf-8", errors="replace", newline="") as handle: for line in handle: if table is None: create_match = CREATE_RE.search(line) insert_match = INSERT_RE.search(line) match = create_match or insert_match if not match or match.group("table").upper() not in TARGET_TABLES: continue table = match.group("table").upper() buffer = [line] else: buffer.append(line) current = "".join(buffer) if statement_complete(current): yield table, current buffer = [] table = None if table is not None: raise RuntimeError(f"unterminated SQL statement for {table}") def parse_header(sql_dump_path: Path) -> dict[str, str]: source: dict[str, str] = {} with sql_dump_path.open("r", encoding="utf-8", errors="replace") as handle: for line_number, line in enumerate(handle, 1): match = HEADER_FIELD_RE.match(line) if match: key = match.group(1).lower().replace(" ", "") value = match.group(2).strip() if key == "sourcehost": source["sourceHost"] = value elif key == "sourceschema": source["sourceSchema"] = value elif key == "date": source["sourceDate"] = value if line_number > 80 or line.strip() == "*/": break return source def extract_parenthesized(text: str, start_index: int = 0) -> tuple[str, int]: while start_index < len(text) and text[start_index].isspace(): start_index += 1 if start_index >= len(text) or text[start_index] != "(": raise ValueError("expected parenthesized expression") in_string = False depth = 0 start = start_index + 1 i = start_index while i < len(text): char = text[i] if char == "'": if in_string and i + 1 < len(text) and text[i + 1] == "'": i += 2 continue in_string = not in_string elif not in_string: if char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: return text[start:i], i + 1 i += 1 raise ValueError("unterminated parenthesized expression") def split_top_level_csv(text: str) -> list[str]: values: list[str] = [] start = 0 depth = 0 in_string = False i = 0 while i < len(text): char = text[i] if char == "'": if in_string and i + 1 < len(text) and text[i + 1] == "'": i += 2 continue in_string = not in_string elif not in_string: if char == "(": depth += 1 elif char == ")": depth -= 1 elif char == "," and depth == 0: values.append(text[start:i].strip()) start = i + 1 i += 1 values.append(text[start:].strip()) return values def parse_sql_string(token: str) -> str: token = token.strip() if token[:2].upper() == "N'": token = token[1:].lstrip() if not token.startswith("'"): raise ValueError(f"not a SQL string literal: {token[:80]}") result: list[str] = [] i = 1 while i < len(token): char = token[i] if char == "'": if i + 1 < len(token) and token[i + 1] == "'": result.append("'") i += 2 continue return "".join(result) result.append(char) i += 1 raise ValueError("unterminated SQL string literal") def timestamp_to_iso(value: str) -> str: value = value.strip() if re.match(r"^[+-]?\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}", value): return value.replace(" ", "T", 1) return value def parse_function_value(token: str) -> Any: name_match = re.match(r"^\s*([A-Z_][A-Z0-9_]*)\s*\(", token, re.I) if not name_match: return token function_name = name_match.group(1).upper() args_text, _ = extract_parenthesized(token, name_match.end() - 1) args = split_top_level_csv(args_text) if function_name in {"TO_TIMESTAMP", "TO_DATE", "TIMESTAMP"} and args: return timestamp_to_iso(str(parse_sql_value(args[0]))) if function_name in {"TO_CLOB", "TO_NCLOB"} and args: return parse_sql_value(args[0]) return token def parse_sql_value(token: str) -> Any: value = token.strip() if not value: return None if value.upper() == "NULL": return None if value.startswith("'") or value[:2].upper() == "N'": return parse_sql_string(value) if re.match(r"^[A-Z_][A-Z0-9_]*\s*\(", value, re.I): return parse_function_value(value) if re.match(r"^[+-]?\d+$", value): return decimal.Decimal(value) if re.match(r"^[+-]?(?:\d+\.\d*|\.\d+)(?:[Ee][+-]?\d+)?$", value) or re.match(r"^[+-]?\d+[Ee][+-]?\d+$", value): return decimal.Decimal(value) return value def parse_create_columns(statement: str) -> list[str]: body, _ = extract_parenthesized(statement, statement.index("(")) columns: list[str] = [] for raw_line in body.splitlines(): match = re.match(r'\s*"([^"]+)"\s+', raw_line) if match: columns.append(match.group(1)) if not columns: raise RuntimeError("no columns found in CREATE TABLE statement") return columns def parse_insert_columns(columns_text: str | None) -> list[str] | None: if not columns_text: return None return re.findall(r'"([^"]+)"', columns_text) def parse_insert_rows(statement: str, create_columns: dict[str, list[str]]) -> tuple[str, list[dict[str, Any]]]: match = INSERT_VALUES_RE.match(statement) if not match: raise RuntimeError(f"unsupported INSERT syntax: {statement[:240]}") table = match.group("table").upper() columns = parse_insert_columns(match.group("columns")) or create_columns.get(table) if not columns: raise RuntimeError(f"CREATE TABLE column order not found before INSERT for {table}") rows: list[dict[str, Any]] = [] values_text = match.group("values").strip() cursor = 0 while cursor < len(values_text): while cursor < len(values_text) and values_text[cursor] in " \r\n\t,": cursor += 1 if cursor >= len(values_text) or values_text[cursor] == ";": break row_text, cursor = extract_parenthesized(values_text, cursor) raw_values = split_top_level_csv(row_text) if len(raw_values) != len(columns): raise RuntimeError( f"{table} INSERT value count mismatch: {len(raw_values)} values for {len(columns)} columns" ) parsed_values = [parse_sql_value(value) for value in raw_values] rows.append(guide_export.normalize_record(columns, parsed_values)) return table, rows def load_data_from_sql_dump(sql_dump_path: Path) -> tuple[dict[str, list[dict[str, Any]]], dict[str, str]]: create_columns: dict[str, list[str]] = {} data: dict[str, list[dict[str, Any]]] = {file_stem: [] for file_stem in TABLES.values()} for table, statement in iter_target_statements(sql_dump_path): if CREATE_RE.search(statement): create_columns[table] = parse_create_columns(statement) continue if INSERT_RE.search(statement): insert_table, rows = parse_insert_rows(statement, create_columns) data[TABLES[insert_table]].extend(rows) missing_create = sorted(table for table in TARGET_TABLES if table not in create_columns) if missing_create: raise RuntimeError(f"CREATE TABLE statements not found: {', '.join(missing_create)}") return data, parse_header(sql_dump_path) def write_package( data: dict[str, list[dict[str, Any]]], output_dir: Path, nav_assets_dir: Path, source: dict[str, Any], ) -> dict[str, Any]: output_dir.mkdir(parents=True, exist_ok=True) for table, file_stem in TABLES.items(): rows = data[file_stem] guide_export.write_json( output_dir / f"{file_stem}.json", { "schemaVersion": SCHEMA_VERSION, "sourceTable": table, "rowCount": len(rows), "rows": rows, }, ) indexes = guide_export.build_indexes(data) guide_export.write_json(output_dir / "indexes.json", indexes) poi_bridge = guide_export.build_poi_bridge(data, nav_assets_dir) guide_export.write_json(output_dir / "poi-bridge.json", poi_bridge) manifest = { "schemaVersion": SCHEMA_VERSION, "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), "source": source, "files": { "halls": "halls.json", "outlines": "outlines.json", "guideStops": "guide-stops.json", "exhibits": "exhibits.json", "guideContents": "guide-contents.json", "pois": "pois.json", "indexes": "indexes.json", "poiBridge": "poi-bridge.json", }, "counts": {key: len(value) for key, value in data.items()}, "bridgeStats": poi_bridge["stats"], } guide_export.write_json(output_dir / "manifest.json", manifest) return manifest def export_package_from_sql_dump(sql_dump_path: Path, output_dir: Path, nav_assets_dir: Path) -> dict[str, Any]: data, header = load_data_from_sql_dump(sql_dump_path) source = { "type": "sql-dump", "sqlDumpPath": str(sql_dump_path), "sourceHost": header.get("sourceHost"), "sourceSchema": header.get("sourceSchema", "NATURE_SGS"), "sourceDate": header.get("sourceDate"), } return write_package(data, output_dir, nav_assets_dir, source) def main() -> None: parser = argparse.ArgumentParser(description="Export frontend guide static data from a Navicat SQL dump.") parser.add_argument("--sql-dump", type=Path, required=True) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--nav-assets", type=Path, default=DEFAULT_NAV_ASSETS) args = parser.parse_args() manifest = export_package_from_sql_dump( args.sql_dump.resolve(), args.output.resolve(), args.nav_assets.resolve(), ) print(f"Exported {manifest['schemaVersion']} to {args.output.resolve()}") print("Counts:", json.dumps(manifest["counts"], ensure_ascii=False, sort_keys=True)) print("Bridge:", json.dumps(manifest["bridgeStats"], ensure_ascii=False, sort_keys=True)) print("Source:", json.dumps(manifest["source"], ensure_ascii=False, sort_keys=True)) if __name__ == "__main__": main()