feat: initial commit
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user