#!/usr/bin/env python3
"""
apple_kg_report.py — see what Apple Intelligence's on-device knowledge graph holds about you.

Reads ~/Library/IntelligencePlatform (read-only), decodes Apple's ontology,
writes one standalone HTML file, and opens it in your default browser.

Nothing leaves your Mac: no network calls, no third-party packages, and every
database is opened with SQLite's read-only mode.

Requirements:
  * macOS 13+ with Apple Intelligence data present
  * Full Disk Access for the app you run this from (Terminal, iTerm, etc.):
    System Settings > Privacy & Security > Full Disk Access
  * python3 (ships with the Xcode Command Line Tools)

Usage:
  python3 apple_kg_report.py                 # writes ~/Desktop/apple-knowledge-graph.html and opens it
  python3 apple_kg_report.py -o report.html  # choose the output path
  python3 apple_kg_report.py --no-open       # don't launch the browser
  python3 apple_kg_report.py --people 100    # how many people to list (default 60)

Based on the structure documented in "Anatomy of Apple's Personal Knowledge Graph"
(8rank.ai) and the open-source iKnow project (github.com/8rank/iKnow).
Apple changes these schemas between macOS releases, so every section degrades
gracefully: anything it can't read is noted in the report, not fatal.
"""

import argparse
import html
import json
import os
import sqlite3
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone

GRAPH_DIR = os.path.expanduser("~/Library/IntelligencePlatform")
ONTOLOGY_DIRS = [
    "/System/Library/PrivateFrameworks/IntelligencePlatformCore.framework/Resources",
    "/System/Library/PrivateFrameworks/IntelligencePlatformCore.framework/Versions/A/Resources",
]
APPLE_EPOCH = 978307200  # 2001-01-01 in unix seconds
NAME_PREDICATES = ("PS33", "PS312", "PS441")
OWNER_PREDICATE = "nm_isCurrentUser"
LAYERS = ("stable_graph", "event_graph")
NAMESPACES = {
    "md": "graph entity", "cnContact": "Contacts card", "phPerson": "Photos face",
    "ekEvent": "Calendar event", "sgEvent": "Suggested event", "sgContact": "Suggested contact",
    "inPerson": "Siri/Intents person", "lsBundleRecord": "installed app",
    "faFamilyCircle": "Family Sharing", "association": "association", "tel": "phone",
    "mailto": "email", "https": "web", "http": "web",
}

gaps = []  # things we couldn't read, shown in the report


# ---------------------------------------------------------------- sqlite helpers

def connect(path):
    uri = "file:" + path.replace("?", "%3f").replace("#", "%23") + "?mode=ro"
    con = sqlite3.connect(uri, uri=True, timeout=5)
    con.row_factory = sqlite3.Row
    con.text_factory = lambda b: b.decode("utf-8", "replace")
    return con


def q(con, sql, params=(), label=None):
    try:
        return con.execute(sql, params).fetchall()
    except sqlite3.Error as e:
        if label:
            gaps.append(f"{label}: {e}")
        return []


def tables(con):
    return [r[0] for r in q(con, "select name from sqlite_master where type='table' order by name")]


def columns(con, table):
    return [r[1] for r in q(con, f'pragma table_info("{table}")')]


def find_dbs(root):
    out = []
    for d, _, files in os.walk(root):
        for f in files:
            if f.endswith((".db", ".sqlite", ".sqlitedb")):
                out.append(os.path.join(d, f))
    return sorted(out)


def apple_date(v):
    try:
        v = float(v)
    except (TypeError, ValueError):
        return ""
    if v > 1e12:          # nanoseconds or ms — normalize loosely
        v = v / 1e9 if v > 1e15 else v / 1e3
    unix = v + APPLE_EPOCH if v < 1.5e9 else v
    try:
        return datetime.fromtimestamp(unix).strftime("%Y-%m-%d %H:%M")
    except (OverflowError, OSError, ValueError):
        return ""


MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
          "August", "September", "October", "November", "December"]
JSON_NOISE = {"calendar", "timeZone", "locale", "era", "isLeapMonth", "firstWeekday",
              "minimumDaysInFirstWeek", "identifier", "current"}
BAD = 9223372036854775807  # NSDateComponents "undefined"


def date_components(d):
    """NSDateComponents-style dict -> 'May 1, 1980' (or partial), else None."""
    if not isinstance(d, dict) or not ({"year", "month", "day"} & d.keys()):
        return None
    ok = lambda k: isinstance(d.get(k), int) and d[k] != BAD and d[k] > 0
    y, m, dd = (d["year"] if ok("year") else None, d["month"] if ok("month") and d["month"] <= 12 else None,
                d["day"] if ok("day") else None)
    if m and dd and y:
        s = f"{MONTHS[m]} {dd}, {y}"
    elif m and dd:
        s = f"{MONTHS[m]} {dd}"
    elif m and y:
        s = f"{MONTHS[m]} {y}"
    elif y:
        s = str(y)
    else:
        return None
    if ok("hour"):
        s += f" {d['hour']:02d}:{d.get('minute', 0) if ok('minute') else 0:02d}"
    return s


def flatten_json(v, depth=0):
    """Any parsed JSON -> short readable text, dropping calendar/locale boilerplate."""
    dc = date_components(v)
    if dc:
        return dc
    if isinstance(v, dict):
        bits = []
        for k, x in v.items():
            if k in JSON_NOISE or x in (None, "", [], {}):
                continue
            inner = flatten_json(x, depth + 1)
            if inner:
                bits.append(f"{k}: {inner}")
        return "; ".join(bits)
    if isinstance(v, list):
        return ", ".join(filter(None, (flatten_json(x, depth + 1) for x in v[:12]))) + ("…" if len(v) > 12 else "")
    if isinstance(v, bool):
        return "yes" if v else "no"
    return str(v)


def pretty_json(s):
    """HTML for a JSON-looking string, or None if it isn't JSON."""
    t = s.strip()
    if not (t[:1] in "{[" and t[-1:] in "}]"):
        return None
    try:
        v = json.loads(t)
    except ValueError:
        return None
    text = flatten_json(v)
    if not text:
        return '<span class="muted">(empty)</span>'
    return f'<span title="{html.escape(t[:2000])}">{html.escape(text[:400])}</span>'


# ---------------------------------------------------------------- ontology

def load_ontology():
    preds, classes = {}, {}
    for d in ONTOLOGY_DIRS + [GRAPH_DIR]:
        p, c = os.path.join(d, "predicate.json"), os.path.join(d, "class.json")
        try:
            if not preds and os.path.exists(p):
                for x in json.load(open(p))["predicates"]:
                    preds[x["predicateId"]] = x.get("label") or x["predicateId"]
            if not classes and os.path.exists(c):
                for x in json.load(open(c))["classes"]:
                    classes[x["classId"]] = x.get("label") or x["classId"]
        except (OSError, ValueError, KeyError, TypeError) as e:
            gaps.append(f"ontology in {d}: {e}")
    if not preds:
        gaps.append("Couldn't load predicate.json — predicates are shown as raw ids (e.g. PS33).")
    return preds, classes


# ---------------------------------------------------------------- html helpers

def e(s):
    return html.escape("" if s is None else str(s))


def table_html(headers, rows, cls=""):
    h = "".join(f"<th>{e(x)}</th>" for x in headers)
    b = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
    return f'<table class="{cls}"><thead><tr>{h}</tr></thead><tbody>{b}</tbody></table>'


def section(title, body, sub=""):
    sub = f'<p class="sub">{sub}</p>' if sub else ""
    return f"<section><h2>{e(title)}</h2>{sub}{body}</section>"


def bars(pairs, maxn=None):
    if not pairs:
        return "<p class='muted'>Nothing found.</p>"
    maxn = maxn or max(n for _, n in pairs) or 1
    rows = "".join(
        f'<div class="bar"><span class="lbl">{e(k)}</span>'
        f'<span class="track"><span class="fill" style="width:{100*n/maxn:.1f}%"></span></span>'
        f'<span class="n">{n:,}</span></div>' for k, n in pairs)
    return f'<div class="bars">{rows}</div>'


# ---------------------------------------------------------------- the report

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("-o", "--output", default=os.path.expanduser("~/Desktop/apple-knowledge-graph.html"))
    ap.add_argument("--no-open", action="store_true")
    ap.add_argument("--people", type=int, default=60)
    args = ap.parse_args()

    if not os.path.isdir(GRAPH_DIR):
        sys.exit(f"No {GRAPH_DIR} on this Mac — Apple Intelligence may not have built a graph yet.")
    try:
        os.listdir(GRAPH_DIR)
        dbs = find_dbs(GRAPH_DIR)
        if dbs:
            open(dbs[0], "rb").close()
    except PermissionError:
        sys.exit(
            "Permission denied reading ~/Library/IntelligencePlatform.\n"
            "Give your terminal app Full Disk Access:\n"
            "  System Settings > Privacy & Security > Full Disk Access > add Terminal (or iTerm)\n"
            "then quit and reopen the terminal and run this again.")

    preds, classes = load_ontology()
    plabel = lambda p: preds.get(p, p) if p else ""
    parts = []

    # ---- graph.db ------------------------------------------------------
    graph_path = next((p for p in dbs if os.path.basename(p) == "graph.db"), None)
    names, owner, owner_rows, layer_counts = {}, None, [], {}
    if graph_path:
        g = connect(graph_path)
        have = set(tables(g))
        layers = [t for t in LAYERS if t in have]
        for t in layers + [x for x in have if x.startswith("expired_")]:
            layer_counts[t] = (q(g, f"select count(*) from {t}") or [[0]])[0][0]

        # display names for every entity. One entity can carry several names; pick the one
        # Apple is most confident in, then the one it repeats most, then predicate preference.
        name_votes = defaultdict(lambda: defaultdict(lambda: [0.0, 0, 9]))  # subj -> name -> [conf, count, rank]
        for t in layers:
            conf = "confidence" if "confidence" in columns(g, t) else "1.0"
            for r in q(g, f"select subject, predicate, object, {conf} c from {t} where predicate in "
                          f"({','.join('?'*len(NAME_PREDICATES))})", NAME_PREDICATES, f"{t} names"):
                if not r["object"]:
                    continue
                v = name_votes[r["subject"]][r["object"].strip()]
                v[0] = max(v[0], float(r["c"] or 0)); v[1] += 1
                v[2] = min(v[2], NAME_PREDICATES.index(r["predicate"]))
        names = {s: max(c.items(), key=lambda kv: (kv[1][0], kv[1][1], -kv[1][2]))[0]
                 for s, c in name_votes.items()}

        def decode(obj):
            if not isinstance(obj, str):
                return e(obj)
            pretty = pretty_json(obj)
            if pretty is not None:
                return pretty
            head, sep, tail = obj.partition(":")
            if sep and head == "md":
                try:
                    n = names.get(int(tail))
                except ValueError:
                    n = None
                return f'<span class="ref">{e(n)}</span>' if n else f'<span class="tag">entity</span> <code>{e(tail)}</code>'
            if sep and head in NAMESPACES:
                short = tail if len(tail) < 48 else tail[:45] + "…"
                return f'<span class="tag">{e(NAMESPACES[head])}</span> <code>{e(short)}</code>'
            if len(obj) > 300:
                obj = obj[:300] + "…"
            return e(obj)

        def facts_for(subject, limit=400):
            out = []
            for t in layers:
                cols = set(columns(g, t))
                sel = ", ".join(c for c in ("predicate", "relationshipId", "relationshipPredicate",
                                            "object", "confidence", "timestamp") if c in cols)
                for r in q(g, f"select {sel} from {t} where subject=? limit ?", (subject, limit), f"{t} facts"):
                    d = dict(r); d["layer"] = t.replace("_graph", "")
                    out.append(d)
            return out

        def render_facts(rows):
            # collapse reified statements: rows sharing a non-zero relationshipId are facets of one fact
            simple, grouped = [], defaultdict(list)
            for r in rows:
                rid = r.get("relationshipId")
                if rid and str(rid) != "0":
                    grouped[(r["layer"], r["predicate"], rid)].append(r)
                elif r["predicate"] not in (OWNER_PREDICATE,):
                    simple.append(r)
            # (label, value html) -> [layers, max confidence, latest timestamp, count]
            merged = {}

            def add(label, value, layer, conf, ts):
                m = merged.setdefault((label, value), [set(), None, None, 0])
                m[0].add(layer); m[3] += 1
                try:
                    m[1] = max(m[1] or 0, float(conf))
                except (TypeError, ValueError):
                    pass
                try:
                    m[2] = max(m[2] or 0, float(ts))
                except (TypeError, ValueError):
                    pass

            for r in simple:
                add(plabel(r["predicate"]), decode(r.get("object")), r["layer"],
                    r.get("confidence"), r.get("timestamp"))
            for (layer, p, _), fs in grouped.items():
                desc = "<br>".join(
                    (f'<span class="muted">{e(plabel(f.get("relationshipPredicate")))}:</span> '
                     if f.get("relationshipPredicate") else "") + decode(f.get("object")) for f in fs)
                add(plabel(p), desc, layer, max((f.get("confidence") or 0) for f in fs), fs[0].get("timestamp"))
            out = []
            for (label, value), (lays, conf, ts, n) in sorted(merged.items(), key=lambda kv: (kv[0][0].lower(), -(kv[1][1] or 0))):
                extra = f' <span class="muted">×{n}</span>' if n > 1 else ""
                out.append([e(label), value + extra,
                            f"{conf:.0%}" if conf is not None else "",
                            e(", ".join(sorted(lays))),
                            e(apple_date(ts)[:10])])
            return table_html(["what", "value", "confidence", "layer", "seen"], out, "facts")

        # owner — note every entity flagged as current user, not just the first
        owners = []
        for t in layers:
            for r in q(g, f"select distinct subject from {t} where predicate=?", (OWNER_PREDICATE,)):
                if r[0] not in owners:
                    owners.append(r[0])
        owner = owners[0] if owners else None
        if owner is not None:
            owner_rows = facts_for(owner)
            variants = sorted(name_votes.get(owner, {}).items(), key=lambda kv: (-kv[1][0], -kv[1][1]))
            check = ""
            if len(variants) > 1 or len(owners) > 1:
                vrows = [[e(n), f"{c:.0%}", str(k)] for n, (c, k, _) in variants]
                check = (
                    '<div class="warn"><b>Identity check.</b> '
                    + (f"{len(owners)} different entities are flagged as the current user. " if len(owners) > 1 else "")
                    + (f"Apple attached {len(variants)} different names to the entity it treats as you — "
                       "it appears to have merged more than one person into a single record. "
                       "The heading uses the name with the highest confidence."
                       if len(variants) > 1 else "")
                    + table_html(["name on this entity", "confidence", "times"], vrows) + "</div>")
            parts.append(section(
                f"You (as Apple sees it): {names.get(owner, 'the device owner')}",
                check + render_facts(owner_rows),
                f"{len(owner_rows):,} facts attached directly to the entity Apple flags as the current user."))
        else:
            gaps.append("No entity flagged nm_isCurrentUser — skipped the 'You' section.")

        # people ranked by how much the graph holds on them
        if layers:
            union = " union all ".join(f"select subject from {t}" for t in layers)
            ranked = q(g, f"select subject, count(*) n from ({union}) group by subject "
                          f"order by n desc limit 5000", (), "entity ranking")
            people = [(r["subject"], r["n"]) for r in ranked
                      if r["subject"] in names and r["subject"] != owner][:args.people]
            cards = []
            for sid, n in people:
                rows = facts_for(sid, 150)
                cards.append(f'<details><summary><b>{e(names[sid])}</b>'
                             f'<span class="muted"> — {n:,} facts</span></summary>{render_facts(rows)}</details>')
            parts.append(section("People and things it knows most about", "".join(cards),
                                 f"Top {len(people)} named entities by number of facts. Click to expand."))

            # predicate census
            census = Counter()
            for t in layers:
                for r in q(g, f"select predicate, count(*) n from {t} group by predicate", (), f"{t} census"):
                    census[plabel(r["predicate"])] += r["n"]
            parts.append(section("What kinds of facts it stores", bars(census.most_common(40)),
                                 f"{len(census):,} distinct predicates in use; top 40 shown."))

            # cross-app references
            ns = Counter()
            for t in layers:
                for r in q(g, f"select substr(object,1,instr(object,':')-1) h, count(*) n from {t} "
                              f"where instr(object,':')>0 group by h", (), f"{t} namespaces"):
                    if r["h"] in NAMESPACES and r["h"] != "md":
                        ns[NAMESPACES[r["h"]]] += r["n"]
            parts.append(section("Links into your other apps", bars(ns.most_common()),
                                 "References from the graph into Contacts, Photos faces, Calendar, apps, and more."))
        g.close()
    else:
        gaps.append("graph.db not found.")

    # ---- communication log (metadata only — no message text is stored) ----
    comm_path = next((p for p in dbs if p.endswith(os.path.join("remembers", "view.db"))
                      or os.path.basename(p) == "Artifacts__siri__remembers__view.db"), None)
    if comm_path:
        c = connect(comm_path)
        if "interactions" in tables(c):
            cols = set(columns(c, "interactions"))
            body = ""
            if "bundleId" in cols:
                body += "<h3>By app</h3>" + bars([(r[0] or "?", r[1]) for r in q(
                    c, "select bundleId, count(*) from interactions group by 1 order by 2 desc limit 25")])
            if "startDate" in cols:
                span = q(c, "select min(startDate), max(startDate), count(*) from interactions")
                if span:
                    body = (f"<p>{span[0][2]:,} interactions logged from "
                            f"<b>{e(apple_date(span[0][0]))}</b> to <b>{e(apple_date(span[0][1]))}</b>.</p>") + body
                hours = q(c, "select cast(strftime('%H', startDate+978307200, 'unixepoch', 'localtime') as int) h, "
                             "count(*) from interactions group by h order by h")
                if hours:
                    body += "<h3>By hour of day</h3>" + bars([(f"{r[0]:02d}:00", r[1]) for r in hours if r[0] is not None])
                months = q(c, "select strftime('%Y-%m', startDate+978307200, 'unixepoch', 'localtime') m, "
                              "count(*) from interactions group by m order by m")
                if months:
                    body += "<h3>By month</h3>" + bars([(r[0], r[1]) for r in months if r[0]])
            # counterparties: join through interactionEntities -> entities, whatever the columns are called
            ct = tables(c)
            if "interactionEntities" in ct and "entities" in ct:
                ie, en = columns(c, "interactionEntities"), columns(c, "entities")
                link = next((x for x in ie if x.lower() in ("entityrowid", "entityid", "entity_id")), None)
                namecol = next((x for x in en if "name" in x.lower()), None)
                if link and namecol:
                    top = q(c, f'select en."{namecol}" n, count(*) k from interactionEntities ie '
                               f'join entities en on en.rowid = ie."{link}" '
                               f'where en."{namecol}" is not null and en."{namecol}" <> "" '
                               f'group by 1 order by 2 desc limit 40', (), "communication counterparties")
                    if top:
                        body += "<h3>Most frequent counterparties</h3>" + bars([(r[0], r[1]) for r in top])
            parts.append(section("Communication log", body or "<p class='muted'>Couldn't read this store's layout.</p>",
                                 "Who, when, which app, and direction — Apple's log keeps no message text."))
        c.close()

    # ---- every database, raw ---------------------------------------------
    inv_rows, raw = [], []
    total_rows = 0
    for p in dbs:
        rel = os.path.relpath(p, GRAPH_DIR)
        try:
            con = connect(p)
        except sqlite3.Error as ex:
            gaps.append(f"{rel}: {ex}")
            continue
        ts, counts = tables(con), []
        for t in ts:
            n = (q(con, f'select count(*) from "{t}"') or [[0]])[0][0]
            counts.append((t, n)); total_rows += n
        size = os.path.getsize(p)
        inv_rows.append([f"<code>{e(rel)}</code>", f"{size/1024/1024:,.1f} MB", str(len(ts)),
                         f"{sum(n for _, n in counts):,}"])
        inner = []
        for t, n in counts:
            if n == 0:
                continue
            cols = columns(con, t)
            sample = q(con, f'select * from "{t}" limit 8')
            rows = [[e((str(v)[:120] + "…") if v is not None and len(str(v)) > 120 else v) for v in r] for r in sample]
            inner.append(f"<details><summary><code>{e(t)}</code> <span class='muted'>{n:,} rows</span></summary>"
                         f"<div class='scroll'>{table_html(cols, rows, 'raw')}</div></details>")
        if inner:
            raw.append(f"<details><summary><b><code>{e(rel)}</code></b></summary>{''.join(inner)}</details>")
        con.close()

    summary = (f"<div class='stats'><div><b>{len(dbs)}</b>databases</div>"
               f"<div><b>{total_rows:,}</b>rows total</div>"
               f"<div><b>{len(names):,}</b>named entities</div>"
               + "".join(f"<div><b>{n:,}</b>{e(t)} triples</div>" for t, n in layer_counts.items())
               + "</div>")
    parts.insert(0, section("Overview", summary,
                            f"Read from <code>{e(GRAPH_DIR)}</code> on {datetime.now():%B %-d, %Y at %-I:%M %p}."))
    parts.append(section("Every database", table_html(["file", "size", "tables", "rows"], inv_rows)
                         + "<h3>Sample rows</h3>" + "".join(raw),
                         "The first 8 rows of every non-empty table, for poking around."))
    if gaps:
        parts.append(section("Couldn't read", "<ul>" + "".join(f"<li>{e(x)}</li>" for x in gaps) + "</ul>",
                             "Apple renames tables and columns between macOS releases; these parts were skipped."))

    page = HTML.replace("{{BODY}}", "\n".join(parts))
    out = os.path.abspath(os.path.expanduser(args.output))
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, "w", encoding="utf-8") as fh:
        fh.write(page)
    os.chmod(out, 0o600)
    print(f"Wrote {out}")
    print("Heads up: this file contains your personal data. Don't share it.")
    if not args.no_open:
        subprocess.run(["open", out], check=False)


HTML = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>What Apple Intelligence knows about you</title>
<style>
:root{--bg:#fbfaf8;--fg:#1d1d1f;--mut:#6e6e73;--line:#e3e1dc;--acc:#0a66d8;--card:#fff}
@media(prefers-color-scheme:dark){:root{--bg:#141416;--fg:#f2f2f4;--mut:#9a9aa1;--line:#2c2c30;--acc:#4ea1ff;--card:#1c1c1f}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif}
header{padding:48px 32px 24px;max-width:1100px;margin:auto}
header h1{font-size:34px;margin:0 0 6px;letter-spacing:-.02em}header p{color:var(--mut);margin:0}
main{max-width:1100px;margin:auto;padding:0 32px 80px}
section{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px 24px;margin:18px 0}
h2{margin:0 0 4px;font-size:20px}h3{font-size:14px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:22px 0 8px}
.sub,.muted{color:var(--mut)}.sub{margin:0 0 14px}
.stats{display:flex;flex-wrap:wrap;gap:12px}.stats div{border:1px solid var(--line);border-radius:10px;padding:10px 16px;color:var(--mut);font-size:13px}
.stats b{display:block;font-size:24px;color:var(--fg)}
table{border-collapse:collapse;width:100%;font-size:13px;margin:6px 0}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--mut);font-weight:600}td:first-child{white-space:nowrap}
table.raw td{white-space:nowrap;font-family:ui-monospace,Menlo,monospace;font-size:12px}
.scroll{overflow-x:auto}code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
.tag{font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;color:var(--mut)}
.ref{color:var(--acc)}
table.facts td:nth-child(3),table.facts td:nth-child(5){white-space:nowrap;color:var(--mut);font-variant-numeric:tabular-nums}
table.facts td:nth-child(4){color:var(--mut)}
.warn{border:1px solid #e0a800;background:rgba(224,168,0,.08);border-radius:10px;padding:12px 16px;margin:0 0 16px}
.warn table{margin-top:8px}
details{border-top:1px solid var(--line);padding:8px 0}details details{margin-left:16px}
summary{cursor:pointer}
.bars{display:grid;gap:4px}.bar{display:grid;grid-template-columns:minmax(120px,260px) 1fr 70px;gap:10px;align-items:center;font-size:13px}
.lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track{background:var(--line);height:10px;border-radius:5px;overflow:hidden}
.fill{display:block;height:100%;background:var(--acc);border-radius:5px}.n{text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
</style></head><body>
<header><h1>What Apple Intelligence knows about you</h1>
<p>A local, read-only snapshot of the on-device personal knowledge graph. Generated on this Mac; nothing was sent anywhere.</p></header>
<main>{{BODY}}</main></body></html>"""

if __name__ == "__main__":
    main()
