Files
2026-05-19 14:53:39 +02:00

214 lines
7.4 KiB
Python

from html import escape
from pathlib import Path
from typing import Dict, List
ACCESS_COLOR = {
"PRIVATE": "#6b7280",
"SHARED_USERS": "#d97706",
"DOMAIN": "#ea580c",
"PUBLIC": "#dc2626",
}
def _safe(value: str) -> str:
return escape(str(value or ""))
def render_diagram(summary_rows: List[dict], output_html: Path, max_rows: int = 120) -> None:
rows = summary_rows[:max_rows]
by_root: Dict[str, List[dict]] = {}
for row in rows:
root = row["root_path"]
by_root.setdefault(root, []).append(row)
roots = sorted(by_root.keys())
for root in roots:
by_root[root].sort(key=lambda x: -int(x["item_count"]))
total_items = sum(int(r["item_count"]) for r in rows)
root_cols = []
for root in roots:
items = by_root[root][:16]
root_total = sum(int(x["item_count"]) for x in items)
cards = []
for item in items:
access = item["access_scope"]
color = ACCESS_COLOR.get(access, "#6b7280")
owner = item["owner_email"]
if len(owner) > 32:
owner = owner[:31] + "..."
cards.append(
f"""
<div class=\"detail-card\" style=\"--card-color:{color}\" title=\"PATH: {_safe(root)}&#10;TYPE: {_safe(item['file_category'])}&#10;ACCESS: {_safe(access)}&#10;OWNER: {_safe(item['owner_email'])}&#10;COUNT: {_safe(item['item_count'])}\">
<div class=\"line\"><b>TYPE</b> {_safe(item['file_category'])}</div>
<div class=\"line\"><b>ACCESS</b> {_safe(access)}</div>
<div class=\"line\"><b>OWNER</b> {_safe(owner)}</div>
<div class=\"line\"><b>COUNT</b> {_safe(item['item_count'])}</div>
</div>
""".strip()
)
root_cols.append(
f"""
<section class=\"root-col\">
<div class=\"root-connector\"></div>
<div class=\"root-card\">
<div class=\"line\"><b>PATH GROUP</b></div>
<div class=\"line root-name\">{_safe(root)}</div>
<div class=\"line\"><b>ITEMS</b> {_safe(root_total)}</div>
</div>
<div class=\"cards-stack\">{''.join(cards)}</div>
</section>
""".strip()
)
html = f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>GDrive Diagram</title>
<style>
:root {{
--bg: #eef2f6;
--ink: #0f172a;
--card-text: #ffffff;
--top: #111827;
--path: #facc15;
--path-text: #1f2937;
--line: #e11d48;
--soft-line: #cbd5e1;
--card-w: 270px;
--card-h: 116px;
--radius: 10px;
--font: "Segoe UI", Arial, sans-serif;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; background: var(--bg); color: var(--ink); font-family: var(--font); }}
.toolbar {{ position: sticky; top: 0; z-index: 5; background: #ffffffeb; backdrop-filter: blur(6px); border-bottom: 1px solid #dbe2ea; padding: 10px 12px; display: flex; gap: 8px; align-items: center; }}
.btn {{ border: 1px solid #cdd7e2; background: #fff; color: #111827; border-radius: 8px; height: 34px; min-width: 34px; font-weight: 700; cursor: pointer; }}
.hint {{ color: #475569; font-size: 12px; margin-left: 8px; }}
#viewport {{ width: 100%; height: 810px; overflow: auto; background: linear-gradient(145deg,#edf2f7,#f8fafc); }}
#canvas {{ min-width: 1400px; min-height: 1000px; transform-origin: top left; padding: 36px 24px 40px; }}
.top-wrap {{ display: flex; justify-content: center; margin-bottom: 36px; position: relative; }}
.top-card {{ width: var(--card-w); height: var(--card-h); border-radius: var(--radius); background: var(--top); color: var(--card-text); border: 1px solid #0b1220; padding: 10px 12px; font-size: 13px; box-shadow: 0 5px 18px rgba(17,24,39,.18); }}
.line {{ line-height: 1.22; margin: 2px 0; font-size: 13px; }}
.roots-row {{
display: grid;
grid-template-columns: repeat(auto-fit,minmax(320px,1fr));
gap: 32px;
align-items: start;
position: relative;
}}
.roots-row::before {{
content: "";
position: absolute;
top: -20px;
left: 5%;
right: 5%;
border-top: 3px solid var(--line);
}}
.root-col {{ position: relative; padding-top: 18px; }}
.root-connector {{
position: absolute;
top: -20px;
left: 50%;
height: 18px;
border-left: 3px solid var(--line);
transform: translateX(-1px);
}}
.root-card {{ width: var(--card-w); height: var(--card-h); margin: 0 auto 14px; border-radius: var(--radius); background: var(--path); color: var(--path-text); border: 1px solid #eab308; padding: 10px 12px; font-size: 13px; box-shadow: 0 4px 14px rgba(234,179,8,.24); }}
.root-name {{ font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.cards-stack {{ width: var(--card-w); margin: 0 auto; position: relative; padding-left: 14px; }}
.cards-stack::before {{
content: "";
position: absolute;
top: 10px;
bottom: 10px;
left: 0;
border-left: 2px solid var(--soft-line);
}}
.detail-card {{ width: var(--card-w); height: var(--card-h); border-radius: var(--radius); margin: 10px 0; background: var(--card-color); color: var(--card-text); border: 1px solid rgba(15,23,42,.32); padding: 10px 12px; box-shadow: 0 4px 14px rgba(2,6,23,.14); position: relative; font-size: 13px; }}
.detail-card::before {{
content: "";
position: absolute;
left: -14px;
top: calc(50% - 1px);
width: 14px;
border-top: 2px solid var(--soft-line);
}}
@media (max-width: 900px) {{
#viewport {{ height: 70vh; }}
#canvas {{ min-width: 980px; }}
}}
</style>
</head>
<body>
<div class=\"toolbar\">
<button class=\"btn\" onclick=\"zoomIn()\">+</button>
<button class=\"btn\" onclick=\"zoomOut()\">-</button>
<button class=\"btn\" onclick=\"resetZoom()\">Reset</button>
<span class=\"hint\">Zoom/pan: scroll to move, Ctrl+scroll to zoom</span>
</div>
<div id=\"viewport\">
<div id=\"canvas\">
<div class=\"top-wrap\">
<div class=\"top-card\">
<div class=\"line\"><b>GOOGLE DRIVE INVENTORY</b></div>
<div class=\"line\"><b>TOTAL ITEMS</b> {_safe(total_items)}</div>
<div class=\"line\"><b>GROUPS</b> {_safe(len(roots))}</div>
<div class=\"line\"><b>VIEW MODE</b> Org-chart cards</div>
</div>
</div>
<div class=\"roots-row\">{''.join(root_cols)}</div>
</div>
</div>
<script>
let scale = 1;
const canvas = document.getElementById('canvas');
const viewport = document.getElementById('viewport');
function applyScale() {{
canvas.style.transform = `scale(${{scale}})`;
}}
function zoomIn() {{
scale = Math.min(2.4, +(scale + 0.1).toFixed(2));
applyScale();
}}
function zoomOut() {{
scale = Math.max(0.45, +(scale - 0.1).toFixed(2));
applyScale();
}}
function resetZoom() {{
scale = 1;
applyScale();
viewport.scrollTo({{ top: 0, left: 0, behavior: 'smooth' }});
}}
viewport.addEventListener('wheel', (e) => {{
if (!e.ctrlKey) return;
e.preventDefault();
if (e.deltaY < 0) zoomIn(); else zoomOut();
}}, {{ passive: false }});
</script>
</body>
</html>
"""
output_html.write_text(html, encoding="utf-8")