214 lines
7.4 KiB
Python
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)} TYPE: {_safe(item['file_category'])} ACCESS: {_safe(access)} OWNER: {_safe(item['owner_email'])} 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")
|